1 | const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_$';
|
2 | const unsafe_chars = /[<>\b\f\n\r\t\0\u2028\u2029]/g;
|
3 | const reserved =
|
4 | /^(?:do|if|in|for|int|let|new|try|var|byte|case|char|else|enum|goto|long|this|void|with|await|break|catch|class|const|final|float|short|super|throw|while|yield|delete|double|export|import|native|return|switch|throws|typeof|boolean|default|extends|finally|package|private|abstract|continue|debugger|function|volatile|interface|protected|transient|implements|instanceof|synchronized)$/;
|
5 |
|
6 |
|
7 | const escaped = {
|
8 | '<': '\\u003C',
|
9 | '>': '\\u003E',
|
10 | '/': '\\u002F',
|
11 | '\\': '\\\\',
|
12 | '\b': '\\b',
|
13 | '\f': '\\f',
|
14 | '\n': '\\n',
|
15 | '\r': '\\r',
|
16 | '\t': '\\t',
|
17 | '\0': '\\0',
|
18 | '\u2028': '\\u2028',
|
19 | '\u2029': '\\u2029'
|
20 | };
|
21 | const object_proto_names = Object.getOwnPropertyNames(Object.prototype)
|
22 | .sort()
|
23 | .join('\0');
|
24 |
|
25 | class DevalueError extends Error {
|
26 | |
27 |
|
28 |
|
29 |
|
30 | constructor(message, keys) {
|
31 | super(message);
|
32 | this.name = 'DevalueError';
|
33 | this.path = keys.join('');
|
34 | }
|
35 | }
|
36 |
|
37 |
|
38 |
|
39 |
|
40 |
|
41 | export function devalue(value) {
|
42 | const counts = new Map();
|
43 |
|
44 |
|
45 | const keys = [];
|
46 |
|
47 |
|
48 | function walk(thing) {
|
49 | if (typeof thing === 'function') {
|
50 | throw new DevalueError(`Cannot stringify a function`, keys);
|
51 | }
|
52 |
|
53 | if (counts.has(thing)) {
|
54 | counts.set(thing, counts.get(thing) + 1);
|
55 | return;
|
56 | }
|
57 |
|
58 | counts.set(thing, 1);
|
59 |
|
60 | if (!is_primitive(thing)) {
|
61 | const type = get_type(thing);
|
62 |
|
63 | switch (type) {
|
64 | case 'Number':
|
65 | case 'BigInt':
|
66 | case 'String':
|
67 | case 'Boolean':
|
68 | case 'Date':
|
69 | case 'RegExp':
|
70 | return;
|
71 |
|
72 | case 'Array':
|
73 | (thing).forEach((value, i) => {
|
74 | keys.push(`[${i}]`);
|
75 | walk(value);
|
76 | keys.pop();
|
77 | });
|
78 | break;
|
79 |
|
80 | case 'Set':
|
81 | Array.from(thing).forEach(walk);
|
82 | break;
|
83 |
|
84 | case 'Map':
|
85 | for (const [key, value] of thing) {
|
86 | keys.push(
|
87 | `.get(${is_primitive(key) ? stringify_primitive(key) : '...'})`
|
88 | );
|
89 | walk(value);
|
90 | keys.pop();
|
91 | }
|
92 | break;
|
93 |
|
94 | default:
|
95 | const proto = Object.getPrototypeOf(thing);
|
96 |
|
97 | if (
|
98 | proto !== Object.prototype &&
|
99 | proto !== null &&
|
100 | Object.getOwnPropertyNames(proto).sort().join('\0') !==
|
101 | object_proto_names
|
102 | ) {
|
103 | throw new DevalueError(
|
104 | `Cannot stringify arbitrary non-POJOs`,
|
105 | keys
|
106 | );
|
107 | }
|
108 |
|
109 | if (Object.getOwnPropertySymbols(thing).length > 0) {
|
110 | throw new DevalueError(
|
111 | `Cannot stringify POJOs with symbolic keys`,
|
112 | keys
|
113 | );
|
114 | }
|
115 |
|
116 | for (const key in thing) {
|
117 | keys.push(`.${key}`);
|
118 | walk(thing[key]);
|
119 | keys.pop();
|
120 | }
|
121 | }
|
122 | }
|
123 | }
|
124 |
|
125 | walk(value);
|
126 |
|
127 | const names = new Map();
|
128 |
|
129 | Array.from(counts)
|
130 | .filter((entry) => entry[1] > 1)
|
131 | .sort((a, b) => b[1] - a[1])
|
132 | .forEach((entry, i) => {
|
133 | names.set(entry[0], get_name(i));
|
134 | });
|
135 |
|
136 | |
137 |
|
138 |
|
139 |
|
140 | function stringify(thing) {
|
141 | if (names.has(thing)) {
|
142 | return names.get(thing);
|
143 | }
|
144 |
|
145 | if (is_primitive(thing)) {
|
146 | return stringify_primitive(thing);
|
147 | }
|
148 |
|
149 | const type = get_type(thing);
|
150 |
|
151 | switch (type) {
|
152 | case 'Number':
|
153 | case 'String':
|
154 | case 'Boolean':
|
155 | return `Object(${stringify(thing.valueOf())})`;
|
156 |
|
157 | case 'RegExp':
|
158 | return `new RegExp(${stringify_string(thing.source)}, "${
|
159 | thing.flags
|
160 | }")`;
|
161 |
|
162 | case 'Date':
|
163 | return `new Date(${thing.getTime()})`;
|
164 |
|
165 | case 'Array':
|
166 | const members = (thing).map((v, i) =>
|
167 | i in thing ? stringify(v) : ''
|
168 | );
|
169 | const tail = thing.length === 0 || thing.length - 1 in thing ? '' : ',';
|
170 | return `[${members.join(',')}${tail}]`;
|
171 |
|
172 | case 'Set':
|
173 | case 'Map':
|
174 | return `new ${type}([${Array.from(thing).map(stringify).join(',')}])`;
|
175 |
|
176 | default:
|
177 | const obj = `{${Object.keys(thing)
|
178 | .map((key) => `${safe_key(key)}:${stringify(thing[key])}`)
|
179 | .join(',')}}`;
|
180 | const proto = Object.getPrototypeOf(thing);
|
181 | if (proto === null) {
|
182 | return Object.keys(thing).length > 0
|
183 | ? `Object.assign(Object.create(null),${obj})`
|
184 | : `Object.create(null)`;
|
185 | }
|
186 |
|
187 | return obj;
|
188 | }
|
189 | }
|
190 |
|
191 | const str = stringify(value);
|
192 |
|
193 | if (names.size) {
|
194 |
|
195 | const params = [];
|
196 |
|
197 |
|
198 | const statements = [];
|
199 |
|
200 |
|
201 | const values = [];
|
202 |
|
203 | names.forEach((name, thing) => {
|
204 | params.push(name);
|
205 |
|
206 | if (is_primitive(thing)) {
|
207 | values.push(stringify_primitive(thing));
|
208 | return;
|
209 | }
|
210 |
|
211 | const type = get_type(thing);
|
212 |
|
213 | switch (type) {
|
214 | case 'Number':
|
215 | case 'String':
|
216 | case 'Boolean':
|
217 | values.push(`Object(${stringify(thing.valueOf())})`);
|
218 | break;
|
219 |
|
220 | case 'RegExp':
|
221 | values.push(thing.toString());
|
222 | break;
|
223 |
|
224 | case 'Date':
|
225 | values.push(`new Date(${thing.getTime()})`);
|
226 | break;
|
227 |
|
228 | case 'Array':
|
229 | values.push(`Array(${thing.length})`);
|
230 | (thing).forEach((v, i) => {
|
231 | statements.push(`${name}[${i}]=${stringify(v)}`);
|
232 | });
|
233 | break;
|
234 |
|
235 | case 'Set':
|
236 | values.push(`new Set`);
|
237 | statements.push(
|
238 | `${name}.${Array.from(thing)
|
239 | .map((v) => `add(${stringify(v)})`)
|
240 | .join('.')}`
|
241 | );
|
242 | break;
|
243 |
|
244 | case 'Map':
|
245 | values.push(`new Map`);
|
246 | statements.push(
|
247 | `${name}.${Array.from(thing)
|
248 | .map(([k, v]) => `set(${stringify(k)}, ${stringify(v)})`)
|
249 | .join('.')}`
|
250 | );
|
251 | break;
|
252 |
|
253 | default:
|
254 | values.push(
|
255 | Object.getPrototypeOf(thing) === null ? 'Object.create(null)' : '{}'
|
256 | );
|
257 | Object.keys(thing).forEach((key) => {
|
258 | statements.push(
|
259 | `${name}${safe_prop(key)}=${stringify(thing[key])}`
|
260 | );
|
261 | });
|
262 | }
|
263 | });
|
264 |
|
265 | statements.push(`return ${str}`);
|
266 |
|
267 | return `(function(${params.join(',')}){${statements.join(
|
268 | ';'
|
269 | )}}(${values.join(',')}))`;
|
270 | } else {
|
271 | return str;
|
272 | }
|
273 | }
|
274 |
|
275 |
|
276 | function get_name(num) {
|
277 | let name = '';
|
278 |
|
279 | do {
|
280 | name = chars[num % chars.length] + name;
|
281 | num = ~~(num / chars.length) - 1;
|
282 | } while (num >= 0);
|
283 |
|
284 | return reserved.test(name) ? `${name}0` : name;
|
285 | }
|
286 |
|
287 |
|
288 | function is_primitive(thing) {
|
289 | return Object(thing) !== thing;
|
290 | }
|
291 |
|
292 |
|
293 | function stringify_primitive(thing) {
|
294 | if (typeof thing === 'string') return stringify_string(thing);
|
295 | if (thing === void 0) return 'void 0';
|
296 | if (thing === 0 && 1 / thing < 0) return '-0';
|
297 | const str = String(thing);
|
298 | if (typeof thing === 'number') return str.replace(/^(-)?0\./, '$1.');
|
299 | if (typeof thing === 'bigint') return thing + 'n';
|
300 | return str;
|
301 | }
|
302 |
|
303 |
|
304 | function get_type(thing) {
|
305 | return Object.prototype.toString.call(thing).slice(8, -1);
|
306 | }
|
307 |
|
308 |
|
309 | function escape_unsafe_char(c) {
|
310 | return escaped[c] || c;
|
311 | }
|
312 |
|
313 |
|
314 | function escape_unsafe_chars(str) {
|
315 | return str.replace(unsafe_chars, escape_unsafe_char);
|
316 | }
|
317 |
|
318 |
|
319 | function safe_key(key) {
|
320 | return /^[_$a-zA-Z][_$a-zA-Z0-9]*$/.test(key)
|
321 | ? key
|
322 | : escape_unsafe_chars(JSON.stringify(key));
|
323 | }
|
324 |
|
325 |
|
326 | function safe_prop(key) {
|
327 | return /^[_$a-zA-Z][_$a-zA-Z0-9]*$/.test(key)
|
328 | ? `.${key}`
|
329 | : `[${escape_unsafe_chars(JSON.stringify(key))}]`;
|
330 | }
|
331 |
|
332 |
|
333 | function stringify_string(str) {
|
334 | let result = '"';
|
335 |
|
336 | for (let i = 0; i < str.length; i += 1) {
|
337 | const char = str.charAt(i);
|
338 | const code = char.charCodeAt(0);
|
339 |
|
340 | if (char === '"') {
|
341 | result += '\\"';
|
342 | } else if (char in escaped) {
|
343 | result += escaped[char];
|
344 | } else if (code >= 0xd800 && code <= 0xdfff) {
|
345 | const next = str.charCodeAt(i + 1);
|
346 |
|
347 |
|
348 |
|
349 | if (code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) {
|
350 | result += char + str[++i];
|
351 | } else {
|
352 | result += `\\u${code.toString(16).toUpperCase()}`;
|
353 | }
|
354 | } else {
|
355 | result += char;
|
356 | }
|
357 | }
|
358 |
|
359 | result += '"';
|
360 | return result;
|
361 | }
|