-
-
Notifications
You must be signed in to change notification settings - Fork 9.3k
/
utils.ts
160 lines (142 loc) · 5.4 KB
/
utils.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
import { once } from '@storybook/client-logger';
import deepEqual from 'fast-deep-equal';
import isPlainObject from 'lodash/isPlainObject';
import memoize from 'memoizerific';
import qs from 'qs';
import dedent from 'ts-dedent';
export interface StoryData {
viewMode?: string;
storyId?: string;
refId?: string;
}
const splitPathRegex = /\/([^/]+)\/(?:(.*)_)?([^/]+)?/;
export const parsePath: (path: string | undefined) => StoryData = memoize(1000)(
(path: string | undefined | null) => {
const result: StoryData = {
viewMode: undefined,
storyId: undefined,
refId: undefined,
};
if (path) {
const [, viewMode, refId, storyId] = path.toLowerCase().match(splitPathRegex) || [];
if (viewMode) {
Object.assign(result, {
viewMode,
storyId,
refId,
});
}
}
return result;
}
);
interface Args {
[key: string]: any;
}
export const DEEPLY_EQUAL = Symbol('Deeply equal');
export const deepDiff = (value: any, update: any): any => {
if (typeof value !== typeof update) return update;
if (deepEqual(value, update)) return DEEPLY_EQUAL;
if (Array.isArray(value) && Array.isArray(update)) {
const res = update.reduce((acc, upd, index) => {
const diff = deepDiff(value[index], upd);
if (diff !== DEEPLY_EQUAL) acc[index] = diff;
return acc;
}, new Array(update.length));
if (update.length >= value.length) return res;
return res.concat(new Array(value.length - update.length).fill(undefined));
}
if (isPlainObject(value) && isPlainObject(update)) {
return Object.keys({ ...value, ...update }).reduce((acc, key) => {
const diff = deepDiff(value?.[key], update?.[key]);
return diff === DEEPLY_EQUAL ? acc : Object.assign(acc, { [key]: diff });
}, {});
}
return update;
};
// Keep this in sync with validateArgs in core-client/src/preview/parseArgsParam.ts
const VALIDATION_REGEXP = /^[a-zA-Z0-9 _-]*$/;
const NUMBER_REGEXP = /^-?[0-9]+(\.[0-9]+)?$/;
const HEX_REGEXP = /^#([a-f0-9]{3,4}|[a-f0-9]{6}|[a-f0-9]{8})$/i;
const COLOR_REGEXP = /^(rgba?|hsla?)\(([0-9]{1,3}),\s?([0-9]{1,3})%?,\s?([0-9]{1,3})%?,?\s?([0-9](\.[0-9]{1,2})?)?\)$/i;
const validateArgs = (key = '', value: unknown): boolean => {
if (key === null) return false;
if (key === '' || !VALIDATION_REGEXP.test(key)) return false;
if (value === null || value === undefined) return true; // encoded as `!null` or `!undefined`
if (value instanceof Date) return true; // encoded as modified ISO string
if (typeof value === 'number' || typeof value === 'boolean') return true;
if (typeof value === 'string') {
return (
VALIDATION_REGEXP.test(value) ||
NUMBER_REGEXP.test(value) ||
HEX_REGEXP.test(value) ||
COLOR_REGEXP.test(value)
);
}
if (Array.isArray(value)) return value.every((v) => validateArgs(key, v));
if (isPlainObject(value)) return Object.entries(value).every(([k, v]) => validateArgs(k, v));
return false;
};
const encodeSpecialValues = (value: unknown): any => {
if (value === undefined) return '!undefined';
if (value === null) return '!null';
if (typeof value === 'string') {
if (HEX_REGEXP.test(value)) return `!hex(${value.slice(1)})`;
if (COLOR_REGEXP.test(value)) return `!${value.replace(/[\s%]/g, '')}`;
return value;
}
if (Array.isArray(value)) return value.map(encodeSpecialValues);
if (isPlainObject(value)) {
return Object.entries(value).reduce(
(acc, [key, val]) => Object.assign(acc, { [key]: encodeSpecialValues(val) }),
{}
);
}
return value;
};
const QS_OPTIONS = {
encode: false, // we handle URL encoding ourselves
delimiter: ';', // we don't actually create multiple query params
allowDots: true, // encode objects using dot notation: obj.key=val
format: 'RFC1738', // encode spaces using the + sign
serializeDate: (date: Date) => `!date(${date.toISOString()})`,
};
export const buildArgsParam = (initialArgs: Args, args: Args): string => {
const update = deepDiff(initialArgs, args);
if (!update || update === DEEPLY_EQUAL) return '';
const object = Object.entries(update).reduce((acc, [key, value]) => {
if (validateArgs(key, value)) return Object.assign(acc, { [key]: value });
once.warn(dedent`
Omitted potentially unsafe URL args.
More info: https://storybook.js.org/docs/react/writing-stories/args#setting-args-through-the-url
`);
return acc;
}, {} as Args);
return qs
.stringify(encodeSpecialValues(object), QS_OPTIONS)
.replace(/ /g, '+')
.split(';')
.map((part: string) => part.replace('=', ':'))
.join(';');
};
interface Query {
[key: string]: any;
}
export const queryFromString = memoize(1000)(
(s: string): Query => qs.parse(s, { ignoreQueryPrefix: true })
);
export const queryFromLocation = (location: { search: string }) => queryFromString(location.search);
export const stringifyQuery = (query: Query) =>
qs.stringify(query, { addQueryPrefix: true, encode: false });
type Match = { path: string };
export const getMatch = memoize(1000)(
(current: string, target: string, startsWith = true): Match | null => {
const startsWithTarget = current && startsWith && current.startsWith(target);
const currentIsTarget = typeof target === 'string' && current === target;
const matchTarget = current && target && current.match(target);
if (startsWithTarget || currentIsTarget || matchTarget) {
return { path: current };
}
return null;
}
);