crawlforge-extractors 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,323 @@
1
+ /**
2
+ * frameworkData.js — decode the framework payloads a JSON parse cannot read.
3
+ *
4
+ * Nuxt 3 ships its state as a devalue `stringify` payload in
5
+ * <script type="application/json" id="__NUXT_DATA__" data-nuxt-data="…">: a
6
+ * flat array where every value is an index into the array, with Vue
7
+ * reactivity wrappers tagged as ["Reactive", i], ["ShallowRef", i], …. Parsed
8
+ * as plain JSON it is a list of numbers; decoded, it is the page's state.
9
+ *
10
+ * SvelteKit ships its load() data inline in the boot script as devalue
11
+ * `uneval` output — a JavaScript literal, read here with jsLiteral.js.
12
+ *
13
+ * Both readers return JSON-safe trees and never throw: anything they cannot
14
+ * decode comes back as a warning.
15
+ */
16
+
17
+ import { unflatten } from 'devalue';
18
+ import { parseExpressionAt } from 'acorn';
19
+
20
+ import { evaluateLiteralNode } from './jsLiteral.js';
21
+
22
+ // Nuxt's EmptyRef/EmptyShallowRef payload: '_' is undefined, '0n' is a zero
23
+ // bigint, anything else is JSON (Nuxt reads it with destr, a forgiving
24
+ // JSON.parse that falls back to the string).
25
+ function reviveEmptyRef(data) {
26
+ if (data === '_') return undefined;
27
+ if (data === '0n') return 0;
28
+ if (typeof data !== 'string') return data;
29
+ try {
30
+ return JSON.parse(data);
31
+ } catch {
32
+ return data;
33
+ }
34
+ }
35
+
36
+ const identity = (data) => data;
37
+
38
+ /**
39
+ * The revivers Nuxt 3 registers (app/plugins/revive-payload.client), minus
40
+ * Vue: a ref or reactive wrapper is just its value once it is data. Only
41
+ * Reactive, ShallowReactive and the devalue built-in Set were seen live on
42
+ * nuxt.com (2026-10-03); the rest follow Nuxt's source.
43
+ */
44
+ const NUXT_REVIVERS = {
45
+ NuxtError: identity,
46
+ EmptyShallowRef: reviveEmptyRef,
47
+ EmptyRef: reviveEmptyRef,
48
+ ShallowRef: identity,
49
+ ShallowReactive: identity,
50
+ Ref: identity,
51
+ Reactive: identity
52
+ };
53
+
54
+ // Type tags devalue 6 decodes itself. Any other tag throws "Unknown type" in
55
+ // unflatten, so it gets an identity reviver and a warning instead.
56
+ const DEVALUE_BUILTIN_TYPES = new Set([
57
+ 'Date', 'Set', 'Map', 'RegExp', 'Object', 'BigInt', 'null',
58
+ 'Int8Array', 'Uint8Array', 'Uint8ClampedArray', 'Int16Array', 'Uint16Array',
59
+ 'Float16Array', 'Int32Array', 'Uint32Array', 'Float32Array', 'Float64Array',
60
+ 'BigInt64Array', 'BigUint64Array', 'DataView', 'ArrayBuffer',
61
+ 'URL', 'URLSearchParams', 'Temporal.Duration', 'Temporal.Instant',
62
+ 'Temporal.PlainDate', 'Temporal.PlainTime', 'Temporal.PlainDateTime',
63
+ 'Temporal.PlainMonthDay', 'Temporal.PlainYearMonth', 'Temporal.ZonedDateTime'
64
+ ]);
65
+
66
+ // Construction overrides that build JSON-safe leaves directly: no Date, URL,
67
+ // Temporal or RegExp object is ever constructed from page input, and
68
+ // Temporal — absent from Node — cannot fail the decode.
69
+ const PARSE_OPERATIONS = {
70
+ fromPrimitive: (value) => {
71
+ if (typeof value === 'bigint') return value.toString();
72
+ if (typeof value === 'number' && !Number.isFinite(value)) return null;
73
+ return value === undefined ? null : value;
74
+ },
75
+ fromISOString: (iso) => (typeof iso === 'string' ? iso : null),
76
+ fromStringValue: (tag, text) => (typeof text === 'string' ? text : null),
77
+ fromRegExpInfo: (source, flags) => `/${source}/${flags ?? ''}`,
78
+ box: (value) => value
79
+ };
80
+
81
+ // The JSON-safe conversion is a recursive walk. Real Nuxt state is a few
82
+ // dozen levels deep; past this, a branch is cut and reported.
83
+ const MAX_DEPTH = 512;
84
+ // Output cost: one unit per value plus one per 16 characters of string. The
85
+ // devalue format shares every repeated value by index, so a small payload can
86
+ // reference one large object or string a million times; written out, that
87
+ // would expand without bound. Past this cost a repeated object is written as
88
+ // a $ref and anything else as null.
89
+ const MAX_OUTPUT_COST = 5_000_000;
90
+ const CHARS_PER_COST_UNIT = 16;
91
+
92
+ /** Append a key to a jsonPath.js-style path ("state.items[0]"). */
93
+ const childPath = (path, key) =>
94
+ typeof key === 'number' ? `${path}[${key}]` : path === '' ? key : `${path}.${key}`;
95
+
96
+ /** Copy entries onto a fresh object as own data properties ("__proto__" too). */
97
+ function dataObject(entries) {
98
+ const out = {};
99
+ for (const [key, value] of entries) {
100
+ Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true });
101
+ }
102
+ return out;
103
+ }
104
+
105
+ /**
106
+ * Turn a decoded devalue graph into a JSON-safe tree.
107
+ *
108
+ * Map → plain object when every key is a string, else an array of [key,
109
+ * value] pairs; Set → array; typed arrays and ArrayBuffer → arrays of numbers;
110
+ * undefined / NaN / ±Infinity → null; bigint → string.
111
+ *
112
+ * Cycles cannot be represented in JSON, so a value reached again while it is
113
+ * still being written (an ancestor of itself) becomes { $ref: "<path>" },
114
+ * where <path> is its first occurrence in jsonPath.js syntax ("" is the
115
+ * root). A shared object reached a second time from elsewhere is written out
116
+ * again — until MAX_OUTPUT_COST is spent, after which it too is a $ref.
117
+ *
118
+ * @param {unknown} root
119
+ * @param {string[]} warnings
120
+ * @returns {unknown}
121
+ */
122
+ function toJsonSafe(root, warnings) {
123
+ const firstPath = new Map();
124
+ const onStack = new Set();
125
+ let cost = 0;
126
+ let depthCut = false;
127
+ let overBudget = false;
128
+
129
+ function convert(value, path, depth) {
130
+ cost += typeof value === 'string' ? 1 + Math.floor(value.length / CHARS_PER_COST_UNIT) : 1;
131
+ if (value === undefined || value === null) return null;
132
+ const isObject = typeof value === 'object';
133
+ if (isObject && onStack.has(value)) return { $ref: firstPath.get(value) };
134
+ if (cost > MAX_OUTPUT_COST) {
135
+ overBudget = true;
136
+ return isObject && firstPath.has(value) ? { $ref: firstPath.get(value) } : null;
137
+ }
138
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
139
+ if (typeof value === 'bigint') return value.toString();
140
+ if (!isObject) return typeof value === 'string' || typeof value === 'boolean' ? value : null;
141
+ if (depth > MAX_DEPTH) {
142
+ depthCut = true;
143
+ return null;
144
+ }
145
+ if (!firstPath.has(value)) firstPath.set(value, path);
146
+
147
+ onStack.add(value);
148
+ const next = depth + 1;
149
+ let out;
150
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
151
+ const view = value instanceof ArrayBuffer || value instanceof DataView
152
+ ? new Uint8Array(value instanceof ArrayBuffer ? value : value.buffer,
153
+ value.byteOffset ?? 0, value.byteLength)
154
+ : value;
155
+ out = Array.from(view, (n) => (typeof n === 'bigint' ? n.toString() : n));
156
+ cost += out.length;
157
+ } else if (value instanceof Set) {
158
+ out = [];
159
+ for (const item of value) out.push(convert(item, childPath(path, out.length), next));
160
+ } else if (value instanceof Map) {
161
+ const entries = [...value];
162
+ out = entries.every(([key]) => typeof key === 'string')
163
+ ? dataObject(entries.map(([key, item]) => [key, convert(item, childPath(path, key), next)]))
164
+ : entries.map(([key, item], i) => [
165
+ convert(key, childPath(childPath(path, i), 0), next),
166
+ convert(item, childPath(childPath(path, i), 1), next)
167
+ ]);
168
+ } else if (Array.isArray(value) && value.length > MAX_OUTPUT_COST) {
169
+ // A sparse array can declare a length of 2^32-1 around two entries;
170
+ // walking every slot would hang, so only populated indices are kept.
171
+ warnings.push(`A sparse array of declared length ${value.length} at "${path}" was read as an object of its populated indices.`);
172
+ out = dataObject(Object.keys(value).map((key) => [key, convert(value[key], childPath(path, key), next)]));
173
+ } else if (Array.isArray(value)) {
174
+ out = new Array(value.length);
175
+ for (let i = 0; i < value.length; i++) out[i] = convert(value[i], childPath(path, i), next);
176
+ } else {
177
+ // Plain or null-prototype object.
178
+ out = dataObject(Object.keys(value).map((key) => [key, convert(value[key], childPath(path, key), next)]));
179
+ }
180
+ onStack.delete(value);
181
+ return out;
182
+ }
183
+
184
+ const result = convert(root, '', 0);
185
+ if (depthCut) warnings.push(`Branches nested deeper than ${MAX_DEPTH} levels were cut and read as null.`);
186
+ if (overBudget) {
187
+ warnings.push(`The decoded payload passed its output budget (${MAX_OUTPUT_COST} units); past it, repeated objects are written as { $ref } and other values as null.`);
188
+ }
189
+ return result;
190
+ }
191
+
192
+ /**
193
+ * Decode a parsed __NUXT_DATA__ block (Nuxt 3/4, devalue `stringify` format).
194
+ *
195
+ * @param {unknown} parsed the block's JSON.parse output
196
+ * @returns {{ value: unknown, warnings: string[] }} value is null when the
197
+ * payload cannot be decoded; the reason is in warnings
198
+ */
199
+ export function decodeNuxtData(parsed) {
200
+ const warnings = [];
201
+ if (!Array.isArray(parsed) || parsed.length === 0) {
202
+ return { value: null, warnings: ['__NUXT_DATA__ is not a devalue payload (expected a non-empty array); not decoded.'] };
203
+ }
204
+
205
+ const revivers = { ...NUXT_REVIVERS };
206
+ const unknown = new Set();
207
+ for (const entry of parsed) {
208
+ if (Array.isArray(entry) && typeof entry[0] === 'string'
209
+ && !DEVALUE_BUILTIN_TYPES.has(entry[0]) && !Object.hasOwn(revivers, entry[0])) {
210
+ unknown.add(entry[0]);
211
+ }
212
+ }
213
+ for (const type of unknown) revivers[type] = identity;
214
+ if (unknown.size > 0) {
215
+ warnings.push(`__NUXT_DATA__ uses reviver type(s) this reader does not know (${[...unknown].join(', ')}); each was read as its plain value.`);
216
+ }
217
+
218
+ let decoded;
219
+ try {
220
+ // A copy: unflatten appends to the array it is given when a custom
221
+ // reviver wraps a non-index payload, and the caller may still report the
222
+ // raw block.
223
+ decoded = unflatten(parsed.slice(), revivers, { operations: PARSE_OPERATIONS });
224
+ } catch (error) {
225
+ // Malformed indexes, a __proto__ key, a stack overflow on a pathological
226
+ // chain — all hostile-input failures, all reported rather than thrown.
227
+ warnings.push(`__NUXT_DATA__ could not be decoded: ${error instanceof Error ? error.message : String(error)}.`);
228
+ return { value: null, warnings };
229
+ }
230
+
231
+ try {
232
+ return { value: toJsonSafe(decoded, warnings), warnings };
233
+ } catch (error) {
234
+ warnings.push(`__NUXT_DATA__ decoded but could not be converted to JSON: ${error instanceof Error ? error.message : String(error)}.`);
235
+ return { value: null, warnings };
236
+ }
237
+ }
238
+
239
+ const SCRIPT_RE = /<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi;
240
+ const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
241
+ const SVELTEKIT_GLOBAL_RE = /\b__sveltekit_[a-z0-9]+\s*=\s*\{/i;
242
+ const KIT_START_RE = /\bkit\s*\.\s*start\s*\(/g;
243
+
244
+ /**
245
+ * Find the `data` property of the options object in
246
+ * `kit.start(app, element, { node_ids, data, form, error })`.
247
+ * @param {string} body the boot script's text
248
+ * @returns {object|null} the acorn node of the data value
249
+ */
250
+ function findStartData(body) {
251
+ KIT_START_RE.lastIndex = 0;
252
+ let match;
253
+ while ((match = KIT_START_RE.exec(body)) !== null) {
254
+ let call;
255
+ try {
256
+ call = parseExpressionAt(body, match.index, { ecmaVersion: 'latest' });
257
+ } catch {
258
+ continue;
259
+ }
260
+ const options = call.type === 'CallExpression' ? call.arguments[2] : null;
261
+ if (!options || options.type !== 'ObjectExpression') continue;
262
+ const prop = options.properties.find((p) => p.type === 'Property' && !p.computed
263
+ && (p.key.type === 'Identifier' ? p.key.name : p.key.value) === 'data');
264
+ if (prop) return prop.value;
265
+ }
266
+ return null;
267
+ }
268
+
269
+ /**
270
+ * Read SvelteKit's hydration data out of its inline boot script:
271
+ *
272
+ * __sveltekit_8abmph = { base: …, version: "…" };
273
+ * const element = document.currentScript.parentElement;
274
+ * import("…/start.js").then(async (kit) => {
275
+ * kit.init(__sveltekit_8abmph);
276
+ * const app = await import("…/app.js");
277
+ * kit.start(app, element, { node_ids: [0, 5], data: [ … ], form: null, error: null });
278
+ * });
279
+ *
280
+ * (svelte.dev, 2026-10-03; older SvelteKit uses Promise.all([...]).then(
281
+ * ([kit, app]) => kit.start(…)), which reads the same.) Only `data` is
282
+ * evaluated — the other arguments are identifiers. Each data entry is one
283
+ * route node's load() output; an entry the literal evaluator refuses is null,
284
+ * with a warning naming its index.
285
+ *
286
+ * @param {string} html raw HTML
287
+ * @returns {{ value: unknown, warnings: string[] } | null} null when the page
288
+ * has no SvelteKit boot script
289
+ */
290
+ export function readSvelteKitData(html) {
291
+ const source = html.replace(HTML_COMMENT_RE, '');
292
+ SCRIPT_RE.lastIndex = 0;
293
+ let script;
294
+ while ((script = SCRIPT_RE.exec(source)) !== null) {
295
+ const body = script[1];
296
+ if (!SVELTEKIT_GLOBAL_RE.test(body)) continue;
297
+
298
+ const warnings = [];
299
+ const data = findStartData(body);
300
+ if (data === null) {
301
+ return { value: null, warnings: ['A SvelteKit boot script is present but its kit.start(…) data could not be located.'] };
302
+ }
303
+
304
+ // One budget across entries, so a page cannot multiply the evaluator's
305
+ // cap by splitting its payload into many entries.
306
+ const budget = { cost: 0 };
307
+ if (data.type !== 'ArrayExpression') {
308
+ const result = evaluateLiteralNode(data, budget);
309
+ if (!result.ok) warnings.push(`SvelteKit data was not evaluated (${result.reason}).`);
310
+ return { value: result.ok ? result.value : null, warnings };
311
+ }
312
+
313
+ const value = data.elements.map((element, i) => {
314
+ if (element === null) return null;
315
+ const result = evaluateLiteralNode(element, budget);
316
+ if (result.ok) return result.value;
317
+ warnings.push(`SvelteKit data[${i}] was not evaluated (${result.reason}); read as null.`);
318
+ return null;
319
+ });
320
+ return { value, warnings };
321
+ }
322
+ return null;
323
+ }
@@ -0,0 +1,266 @@
1
+ /**
2
+ * jsLiteral.js — read a JavaScript literal without running it.
3
+ *
4
+ * Some pages ship their state as a JS expression rather than JSON: unquoted
5
+ * keys, `void 0`, `!0`, and the IIFE wrapper that Nuxt 2 and devalue.uneval
6
+ * (SvelteKit) emit to share repeated values:
7
+ *
8
+ * window.__NUXT__=(function(a,b){return {x:a,y:[b]}}(1,"z"))
9
+ *
10
+ * This is static evaluation over an acorn AST. No code executes: there is no
11
+ * eval, no vm, no Function, and no property or global lookup. The IIFE form is
12
+ * read by binding each parameter name to its argument's literal value — a
13
+ * name → literal environment — and evaluating the single `return` expression
14
+ * against it. An identifier resolves only to a bound parameter (or the
15
+ * literal names undefined/NaN/Infinity); everything else is refused.
16
+ *
17
+ * Allowed, and nothing more:
18
+ * - string/number/boolean/null literals; a bigint becomes its decimal string
19
+ * - arrays (holes read as null) and objects with plain, non-computed keys
20
+ * - unary - and + on numbers, !0 / !1, and void <literal> (undefined)
21
+ * - template literals with no ${} expressions
22
+ * - string + string
23
+ * - (function(a,b){return <expr>}(<args>)) with simple identifier params
24
+ * - new Date(<number|string>) and new RegExp(<string>[, <string>]), the
25
+ * two constructors devalue.uneval emits for SvelteKit load data
26
+ * (svelte.dev, 2026-10-03). Read as an ISO string and a "/src/flags"
27
+ * string — the same JSON-safe forms frameworkData.js gives them.
28
+ *
29
+ * Output is JSON-safe: undefined, NaN and ±Infinity become null wherever they
30
+ * land in an array or object, and at the top level.
31
+ *
32
+ * The source is attacker-controlled, so the walk is bounded: nesting depth is
33
+ * capped, and a cost budget caps total work. A reference to an IIFE parameter
34
+ * is charged the full cost of its argument, because that is what the reader
35
+ * receives once the result is serialized — `(function(a){return [a,a,a]}(…))`
36
+ * nested a few levels deep would otherwise expand exponentially.
37
+ */
38
+
39
+ import { parseExpressionAt } from 'acorn';
40
+
41
+ const MAX_DEPTH = 512;
42
+
43
+ // One unit per AST node, plus one per 16 characters of every string produced.
44
+ // Generous for real pages (a 20 MB state blob costs ~2.5M), and it bounds the
45
+ // output an IIFE can amplify a small source into.
46
+ const MAX_COST = 5_000_000;
47
+ const CHARS_PER_COST_UNIT = 16;
48
+
49
+ class Refusal extends Error {}
50
+
51
+ /**
52
+ * @param {{ cost: number }} budget
53
+ * @param {number} units
54
+ */
55
+ function charge(budget, units) {
56
+ budget.cost += units;
57
+ if (budget.cost > MAX_COST) {
58
+ throw new Refusal(`the literal exceeds the evaluation budget of ${MAX_COST} units`);
59
+ }
60
+ }
61
+
62
+ /** @param {{ cost: number }} budget @param {string} text */
63
+ function chargeString(budget, text) {
64
+ charge(budget, Math.ceil(text.length / CHARS_PER_COST_UNIT));
65
+ return text;
66
+ }
67
+
68
+ /** undefined, NaN and ±Infinity are not JSON; store them as null. */
69
+ function jsonSafe(value) {
70
+ if (value === undefined) return null;
71
+ if (typeof value === 'number' && !Number.isFinite(value)) return null;
72
+ return value;
73
+ }
74
+
75
+ /**
76
+ * Look a name up in the IIFE parameter scope chain.
77
+ * @param {{ vars: Map<string, { value: unknown, cost: number }>, parent: object|null }|null} scope
78
+ * @param {string} name
79
+ */
80
+ function lookup(scope, name) {
81
+ for (let s = scope; s !== null; s = s.parent) {
82
+ if (s.vars.has(name)) return s.vars.get(name);
83
+ }
84
+ return undefined;
85
+ }
86
+
87
+ /**
88
+ * The narrow IIFE shape: a plain function expression, simple identifier
89
+ * params, a body that is exactly one `return <expr>`.
90
+ * @param {any} callee
91
+ */
92
+ function isLiteralIife(callee) {
93
+ return callee.type === 'FunctionExpression'
94
+ && !callee.async
95
+ && !callee.generator
96
+ && callee.params.every((param) => param.type === 'Identifier')
97
+ && callee.body.body.length === 1
98
+ && callee.body.body[0].type === 'ReturnStatement'
99
+ && callee.body.body[0].argument !== null;
100
+ }
101
+
102
+ /**
103
+ * Evaluate one node. Values are raw JS here (undefined, NaN allowed) so that
104
+ * `-Infinity` and `void 0` compose; containers store them JSON-safe.
105
+ * @returns {unknown}
106
+ */
107
+ function evaluate(node, scope, depth, budget) {
108
+ if (depth > MAX_DEPTH) throw new Refusal(`nesting deeper than ${MAX_DEPTH} levels`);
109
+ charge(budget, 1);
110
+
111
+ switch (node.type) {
112
+ case 'Literal': {
113
+ if (node.regex) throw new Refusal('a regular expression literal');
114
+ if (typeof node.value === 'bigint') return node.value.toString();
115
+ if (typeof node.value === 'string') return chargeString(budget, node.value);
116
+ return node.value;
117
+ }
118
+
119
+ case 'ArrayExpression':
120
+ return node.elements.map((element) => {
121
+ if (element === null) return null;
122
+ if (element.type === 'SpreadElement') throw new Refusal('a spread element');
123
+ return jsonSafe(evaluate(element, scope, depth + 1, budget));
124
+ });
125
+
126
+ case 'ObjectExpression': {
127
+ const object = {};
128
+ for (const prop of node.properties) {
129
+ if (prop.type !== 'Property') throw new Refusal('a spread property');
130
+ if (prop.computed) throw new Refusal('a computed key');
131
+ if (prop.kind !== 'init' || prop.method) throw new Refusal('a method, getter or setter');
132
+ if (prop.shorthand) throw new Refusal(`a shorthand property "${prop.key.name}"`);
133
+ const key = prop.key.type === 'Identifier' ? prop.key.name : String(prop.key.value);
134
+ // defineProperty, not assignment: a "__proto__" key becomes an own
135
+ // data property instead of replacing the object's prototype. (In JS
136
+ // the literal would set the prototype; as data it is safer kept as a
137
+ // plain key.)
138
+ Object.defineProperty(object, key, {
139
+ value: jsonSafe(evaluate(prop.value, scope, depth + 1, budget)),
140
+ enumerable: true,
141
+ writable: true,
142
+ configurable: true
143
+ });
144
+ }
145
+ return object;
146
+ }
147
+
148
+ case 'UnaryExpression': {
149
+ const value = evaluate(node.argument, scope, depth + 1, budget);
150
+ if (node.operator === 'void') return undefined;
151
+ if ((node.operator === '-' || node.operator === '+') && typeof value === 'number') {
152
+ return node.operator === '-' ? -value : value;
153
+ }
154
+ if (node.operator === '!' && (typeof value === 'number' || typeof value === 'boolean')) {
155
+ return !value;
156
+ }
157
+ throw new Refusal(`the unary operator "${node.operator}" on a ${typeof value}`);
158
+ }
159
+
160
+ case 'Identifier': {
161
+ const binding = lookup(scope, node.name);
162
+ if (binding !== undefined) {
163
+ charge(budget, binding.cost);
164
+ return binding.value;
165
+ }
166
+ if (node.name === 'undefined') return undefined;
167
+ if (node.name === 'NaN') return NaN;
168
+ if (node.name === 'Infinity') return Infinity;
169
+ throw new Refusal(`the identifier "${node.name}"`);
170
+ }
171
+
172
+ case 'TemplateLiteral': {
173
+ const cooked = node.expressions.length === 0 ? node.quasis[0].value.cooked : null;
174
+ if (typeof cooked !== 'string') throw new Refusal('a template literal with expressions');
175
+ return chargeString(budget, cooked);
176
+ }
177
+
178
+ case 'BinaryExpression': {
179
+ if (node.operator !== '+') throw new Refusal(`the binary operator "${node.operator}"`);
180
+ const left = evaluate(node.left, scope, depth + 1, budget);
181
+ const right = evaluate(node.right, scope, depth + 1, budget);
182
+ if (typeof left !== 'string' || typeof right !== 'string') {
183
+ throw new Refusal('a "+" that is not string concatenation');
184
+ }
185
+ return chargeString(budget, left + right);
186
+ }
187
+
188
+ case 'NewExpression': {
189
+ const name = node.callee.type === 'Identifier' ? node.callee.name : null;
190
+ if ((name === 'Date' || name === 'RegExp') && lookup(scope, name) === undefined) {
191
+ const args = node.arguments.map((arg) => {
192
+ if (arg.type === 'SpreadElement') throw new Refusal('a spread argument');
193
+ return evaluate(arg, scope, depth + 1, budget);
194
+ });
195
+ if (name === 'Date' && args.length === 1
196
+ && (typeof args[0] === 'number' || typeof args[0] === 'string')) {
197
+ const date = new Date(args[0]);
198
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
199
+ }
200
+ if (name === 'RegExp' && args.length >= 1 && args.length <= 2
201
+ && args.every((arg) => typeof arg === 'string')) {
202
+ return chargeString(budget, `/${args[0]}/${args[1] ?? ''}`);
203
+ }
204
+ }
205
+ throw new Refusal('a constructor call');
206
+ }
207
+
208
+ case 'CallExpression': {
209
+ if (node.optional || !isLiteralIife(node.callee)) throw new Refusal('a function call');
210
+ // Arguments are evaluated in the caller's scope; each records what it
211
+ // cost so every later reference to it is charged the same.
212
+ const args = node.arguments.map((arg) => {
213
+ if (arg.type === 'SpreadElement') throw new Refusal('a spread argument');
214
+ const before = budget.cost;
215
+ const value = evaluate(arg, scope, depth + 1, budget);
216
+ return { value, cost: budget.cost - before };
217
+ });
218
+ const vars = new Map();
219
+ node.callee.params.forEach((param, i) => {
220
+ vars.set(param.name, args[i] ?? { value: undefined, cost: 0 });
221
+ });
222
+ return evaluate(node.callee.body.body[0].argument, { vars, parent: scope }, depth + 1, budget);
223
+ }
224
+
225
+ default:
226
+ throw new Refusal(`a ${node.type}`);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Statically evaluate an acorn ESTree expression node made only of literals.
232
+ *
233
+ * @param {object} node an acorn expression node
234
+ * @param {{ cost: number }} [budget] pass one object to several calls to make
235
+ * them share one cost cap (readSvelteKitData evaluates entry by entry)
236
+ * @returns {{ ok: true, value: unknown } | { ok: false, reason: string }}
237
+ */
238
+ export function evaluateLiteralNode(node, budget = { cost: 0 }) {
239
+ try {
240
+ return { ok: true, value: jsonSafe(evaluate(node, null, 0, budget)) };
241
+ } catch (error) {
242
+ if (error instanceof Refusal) return { ok: false, reason: `not a literal: ${error.message}` };
243
+ // A stack overflow before MAX_DEPTH (a small stack) is still a refusal.
244
+ return { ok: false, reason: `not evaluated: ${error instanceof Error ? error.message : String(error)}` };
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Parse the expression that starts at `start` in `text` and evaluate it.
250
+ *
251
+ * @param {string} text
252
+ * @param {number} [start]
253
+ * @returns {{ ok: true, value: unknown, end: number } | { ok: false, reason: string }}
254
+ */
255
+ export function evaluateLiteralSource(text, start = 0) {
256
+ let node;
257
+ try {
258
+ node = parseExpressionAt(text, start, { ecmaVersion: 'latest' });
259
+ } catch (error) {
260
+ // acorn reports pathological nesting as a SyntaxError ("Not enough stack
261
+ // space"); a RangeError is possible too. Either way: refuse, never throw.
262
+ return { ok: false, reason: `not parseable: ${error instanceof Error ? error.message : String(error)}` };
263
+ }
264
+ const result = evaluateLiteralNode(node);
265
+ return result.ok ? { ok: true, value: result.value, end: node.end } : result;
266
+ }