@dogsbay/literal 0.0.0-stage → 0.2.0-beta.133

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.
package/README.md CHANGED
@@ -1,3 +1,35 @@
1
- # Temporary Holding Version
1
+ # @dogsbay/literal
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Read a JavaScript or TypeScript config module as data, without running it. No Node, no
4
+ dependencies: it runs in a browser on someone else's repository.
5
+
6
+ ```ts
7
+ import { evaluateModule, UNRESOLVED } from "@dogsbay/literal";
8
+
9
+ const { value, unresolved } = evaluateModule(source); // the module's default export
10
+ ```
11
+
12
+ It understands object, array, string, number, boolean and null literals; template strings
13
+ with no `${}`; spreads; module-level `const` references; zero-argument helpers that return a
14
+ literal; wrapper calls such as `defineConfig({…})`; member access and literal indexes
15
+ (`releases[0]["x"]`); `"a" + "b"` (strings only); `module.exports =`; and TypeScript type
16
+ noise. Anything else becomes `UNRESOLVED` and is listed in `unresolved`: never a plausible
17
+ wrong value, never a silent omission.
18
+
19
+ Other modules (`require("./x")`, `import x from "./x"`) are read only through a resolver you
20
+ pass, which decides what a path means and what may be read:
21
+
22
+ ```ts
23
+ evaluateModule(source, "sidebars", { require: (spec) => readModule(spec) }); // value or undefined
24
+ ```
25
+
26
+ Someone else's code can be hostile, so there are limits: a value that expands past 100,000
27
+ entries (shared `const`s doubling, required modules included), a string past 1 MB, or nesting
28
+ past 500 levels is `UNRESOLVED`; a `__proto__` key is data, never a prototype.
29
+
30
+ Used by the Dogsbay VitePress importer (`.vitepress/config.*`) and Docusaurus importer
31
+ (`sidebars*.js`, `variables.js`).
32
+
33
+ ## License
34
+
35
+ MIT
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @dogsbay/literal — a tolerant, execution-free evaluator for the literal subset of a
3
+ * TypeScript/JavaScript module: config written as code, read as data.
4
+ *
5
+ * Why this exists: documentation tools keep their configuration in code. VitePress keeps
6
+ * the sidebar INSIDE `.vitepress/config.ts`; Docusaurus keeps it in `sidebars.js`. A real
7
+ * config imports theme packages, plugins and helpers, so *executing* it needs the source
8
+ * repo's `node_modules` installed and runs arbitrary code from that repo — on someone
9
+ * else's repo, in the CLI or in the playground's browser. No Node, no dependencies.
10
+ * (Moved out of format-vitepress on 2026-10-21 so every importer reads config one way.)
11
+ *
12
+ * So we read it instead. This module understands:
13
+ *
14
+ * - object / array / string / number / boolean / null literals
15
+ * - template literals with no `${}` interpolation
16
+ * - `...spread` of anything else this module can evaluate
17
+ * - module-level `const X = <literal>` identifier references
18
+ * - zero-arg calls to module-level `function f() { return <literal> }` and
19
+ * `const f = () => <literal>` (hono's `sidebar: { '/': sidebars() }`)
20
+ * - wrapper calls with a single literal argument (`defineConfig({…})`)
21
+ * - member access into an already-evaluated object (`shared.themeConfig`), and a LITERAL
22
+ * index (`releases[0]`, `x["key"]`); a computed index (`nav[i]`) is unresolved
23
+ * - TS noise: type annotations, `as const`, `as T`, `satisfies T`, generics
24
+ * - CommonJS: `module.exports = <expr>` is the default export
25
+ * - `require("./x")` and `import x from "./x"`, through a resolver the CALLER supplies
26
+ * (`EvaluateOptions.require`): this module reads no files and decides no paths
27
+ * - `"a" + "b"`: concatenation of two strings, nothing else (camunda's `root_dir + "x"`)
28
+ *
29
+ * Anything else evaluates to the `UNRESOLVED` sentinel — never to a plausible
30
+ * wrong value and never to a silent omission. Callers report what they could
31
+ * not read (`EvalResult.unresolved`), because a sidebar that quietly lost a
32
+ * section looks exactly like a sidebar that never had one.
33
+ *
34
+ * The parser never desyncs: an expression it does not understand is skipped
35
+ * by balancing delimiters, so the surrounding object continues to parse.
36
+ */
37
+ /** Sentinel for an expression this evaluator cannot read. */
38
+ export declare const UNRESOLVED: unique symbol;
39
+ export type LiteralValue = string | number | boolean | null | typeof UNRESOLVED | LiteralValue[] | {
40
+ [key: string]: LiteralValue;
41
+ };
42
+ export interface EvalResult {
43
+ /** The module's default export, evaluated as far as possible. */
44
+ value: LiteralValue;
45
+ /** Human-readable descriptions of expressions that could not be read. */
46
+ unresolved: string[];
47
+ }
48
+ export interface EvaluateOptions {
49
+ /**
50
+ * The default export of the module `spec` names (`require("./x")`, `import x from "./x"`),
51
+ * or undefined when there is none. The caller resolves the path, decides what may be read,
52
+ * and bounds how deep modules nest; reading a module is the caller's call, never this one's.
53
+ */
54
+ require?: (spec: string) => LiteralValue | undefined;
55
+ }
56
+ /**
57
+ * Evaluate a module's default export as far as the literal subset allows.
58
+ *
59
+ * Falls back to the binding named by `fallbackExport` (default `config`) when
60
+ * there is no `export default`, which covers
61
+ * `const config = { … }; export { config }`.
62
+ */
63
+ export declare function evaluateModule(source: string, fallbackExport?: string, opts?: EvaluateOptions): EvalResult;
64
+ /** Narrowing helper: a plain object with no UNRESOLVED identity. */
65
+ export declare function asObject(v: LiteralValue): Record<string, LiteralValue> | undefined;
66
+ export declare function asArray(v: LiteralValue): LiteralValue[] | undefined;
67
+ export declare function asString(v: LiteralValue): string | undefined;
package/dist/index.js ADDED
@@ -0,0 +1,1060 @@
1
+ /**
2
+ * @dogsbay/literal — a tolerant, execution-free evaluator for the literal subset of a
3
+ * TypeScript/JavaScript module: config written as code, read as data.
4
+ *
5
+ * Why this exists: documentation tools keep their configuration in code. VitePress keeps
6
+ * the sidebar INSIDE `.vitepress/config.ts`; Docusaurus keeps it in `sidebars.js`. A real
7
+ * config imports theme packages, plugins and helpers, so *executing* it needs the source
8
+ * repo's `node_modules` installed and runs arbitrary code from that repo — on someone
9
+ * else's repo, in the CLI or in the playground's browser. No Node, no dependencies.
10
+ * (Moved out of format-vitepress on 2026-10-21 so every importer reads config one way.)
11
+ *
12
+ * So we read it instead. This module understands:
13
+ *
14
+ * - object / array / string / number / boolean / null literals
15
+ * - template literals with no `${}` interpolation
16
+ * - `...spread` of anything else this module can evaluate
17
+ * - module-level `const X = <literal>` identifier references
18
+ * - zero-arg calls to module-level `function f() { return <literal> }` and
19
+ * `const f = () => <literal>` (hono's `sidebar: { '/': sidebars() }`)
20
+ * - wrapper calls with a single literal argument (`defineConfig({…})`)
21
+ * - member access into an already-evaluated object (`shared.themeConfig`), and a LITERAL
22
+ * index (`releases[0]`, `x["key"]`); a computed index (`nav[i]`) is unresolved
23
+ * - TS noise: type annotations, `as const`, `as T`, `satisfies T`, generics
24
+ * - CommonJS: `module.exports = <expr>` is the default export
25
+ * - `require("./x")` and `import x from "./x"`, through a resolver the CALLER supplies
26
+ * (`EvaluateOptions.require`): this module reads no files and decides no paths
27
+ * - `"a" + "b"`: concatenation of two strings, nothing else (camunda's `root_dir + "x"`)
28
+ *
29
+ * Anything else evaluates to the `UNRESOLVED` sentinel — never to a plausible
30
+ * wrong value and never to a silent omission. Callers report what they could
31
+ * not read (`EvalResult.unresolved`), because a sidebar that quietly lost a
32
+ * section looks exactly like a sidebar that never had one.
33
+ *
34
+ * The parser never desyncs: an expression it does not understand is skipped
35
+ * by balancing delimiters, so the surrounding object continues to parse.
36
+ */
37
+ /** Sentinel for an expression this evaluator cannot read. */
38
+ export const UNRESOLVED = Symbol("dogsbay.literal.unresolved");
39
+ const IDENT_START = /[A-Za-z_$]/;
40
+ const IDENT_PART = /[A-Za-z0-9_$]/;
41
+ /**
42
+ * Positions after which a `/` begins a regex literal rather than division.
43
+ * Without this, `/['"]/ ` would open a phantom string and swallow the rest of
44
+ * the file.
45
+ */
46
+ const REGEX_PRECEDERS = new Set([
47
+ "(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "=>", "+", "-",
48
+ "*", "%", "<", ">", "===", "!==", "==", "!=", "&&", "||", "??",
49
+ ]);
50
+ const REGEX_PRECEDING_KEYWORDS = new Set([
51
+ "return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
52
+ "case", "do", "else", "yield", "await",
53
+ ]);
54
+ function tokenize(src) {
55
+ const toks = [];
56
+ let i = 0;
57
+ let line = 1;
58
+ const n = src.length;
59
+ const prevSignificant = () => toks[toks.length - 1];
60
+ while (i < n) {
61
+ const c = src[i];
62
+ if (c === "\n") {
63
+ line++;
64
+ i++;
65
+ continue;
66
+ }
67
+ if (c === " " || c === "\t" || c === "\r") {
68
+ i++;
69
+ continue;
70
+ }
71
+ // Comments
72
+ if (c === "/" && src[i + 1] === "/") {
73
+ while (i < n && src[i] !== "\n")
74
+ i++;
75
+ continue;
76
+ }
77
+ if (c === "/" && src[i + 1] === "*") {
78
+ i += 2;
79
+ while (i < n && !(src[i] === "*" && src[i + 1] === "/")) {
80
+ if (src[i] === "\n")
81
+ line++;
82
+ i++;
83
+ }
84
+ i += 2;
85
+ continue;
86
+ }
87
+ // Regex literal (only where a value may start)
88
+ if (c === "/") {
89
+ const p = prevSignificant();
90
+ const isRegexPos = !p ||
91
+ (p.kind === "punct" && REGEX_PRECEDERS.has(p.text)) ||
92
+ (p.kind === "ident" && REGEX_PRECEDING_KEYWORDS.has(p.text));
93
+ if (isRegexPos) {
94
+ const start = i;
95
+ i++;
96
+ let inClass = false;
97
+ while (i < n) {
98
+ const d = src[i];
99
+ if (d === "\\") {
100
+ i += 2;
101
+ continue;
102
+ }
103
+ if (d === "[")
104
+ inClass = true;
105
+ else if (d === "]")
106
+ inClass = false;
107
+ else if (d === "/" && !inClass)
108
+ break;
109
+ else if (d === "\n")
110
+ break; // unterminated — bail rather than run away
111
+ i++;
112
+ }
113
+ i++; // closing slash
114
+ while (i < n && /[gimsuyd]/.test(src[i]))
115
+ i++;
116
+ toks.push({ kind: "punct", text: "/regex/", start, line });
117
+ continue;
118
+ }
119
+ }
120
+ // Strings
121
+ if (c === '"' || c === "'") {
122
+ const start = i;
123
+ const quote = c;
124
+ i++;
125
+ let out = "";
126
+ while (i < n && src[i] !== quote) {
127
+ if (src[i] === "\\") {
128
+ out += unescapeChar(src[i + 1]);
129
+ i += 2;
130
+ continue;
131
+ }
132
+ if (src[i] === "\n")
133
+ line++;
134
+ out += src[i];
135
+ i++;
136
+ }
137
+ i++;
138
+ toks.push({ kind: "str", text: out, start, line });
139
+ continue;
140
+ }
141
+ // Template literal
142
+ if (c === "`") {
143
+ const start = i;
144
+ i++;
145
+ let out = "";
146
+ let pure = true;
147
+ while (i < n && src[i] !== "`") {
148
+ if (src[i] === "\\") {
149
+ out += unescapeChar(src[i + 1]);
150
+ i += 2;
151
+ continue;
152
+ }
153
+ if (src[i] === "$" && src[i + 1] === "{") {
154
+ pure = false;
155
+ // Skip the interpolation, balancing braces (and nested templates).
156
+ i += 2;
157
+ let depth = 1;
158
+ while (i < n && depth > 0) {
159
+ if (src[i] === "{")
160
+ depth++;
161
+ else if (src[i] === "}")
162
+ depth--;
163
+ else if (src[i] === "\n")
164
+ line++;
165
+ i++;
166
+ }
167
+ continue;
168
+ }
169
+ if (src[i] === "\n")
170
+ line++;
171
+ out += src[i];
172
+ i++;
173
+ }
174
+ i++;
175
+ toks.push({ kind: "template", text: out, pure, start, line });
176
+ continue;
177
+ }
178
+ // Numbers
179
+ if (/[0-9]/.test(c) || (c === "." && /[0-9]/.test(src[i + 1] ?? ""))) {
180
+ const start = i;
181
+ while (i < n && /[0-9a-fA-FxXoObBeE._+-]/.test(src[i])) {
182
+ // `+`/`-` only continue a number inside an exponent.
183
+ if ((src[i] === "+" || src[i] === "-") && !/[eE]/.test(src[i - 1]))
184
+ break;
185
+ i++;
186
+ }
187
+ toks.push({ kind: "num", text: src.slice(start, i), start, line });
188
+ continue;
189
+ }
190
+ // Identifiers / keywords
191
+ if (IDENT_START.test(c)) {
192
+ const start = i;
193
+ while (i < n && IDENT_PART.test(src[i]))
194
+ i++;
195
+ toks.push({ kind: "ident", text: src.slice(start, i), start, line });
196
+ continue;
197
+ }
198
+ // Punctuation — longest match first so `=>` and `...` stay single tokens.
199
+ const three = src.slice(i, i + 3);
200
+ const two = src.slice(i, i + 2);
201
+ if (three === "..." || three === "===" || three === "!==") {
202
+ toks.push({ kind: "punct", text: three, start: i, line });
203
+ i += 3;
204
+ continue;
205
+ }
206
+ if (["=>", "==", "!=", "&&", "||", "??", "?.", "<=", ">=", "++", "--"].includes(two)) {
207
+ toks.push({ kind: "punct", text: two, start: i, line });
208
+ i += 2;
209
+ continue;
210
+ }
211
+ toks.push({ kind: "punct", text: c, start: i, line });
212
+ i++;
213
+ }
214
+ return toks;
215
+ }
216
+ function unescapeChar(c) {
217
+ switch (c) {
218
+ case "n":
219
+ return "\n";
220
+ case "t":
221
+ return "\t";
222
+ case "r":
223
+ return "\r";
224
+ case "0":
225
+ return "\0";
226
+ case undefined:
227
+ return "";
228
+ default:
229
+ return c;
230
+ }
231
+ }
232
+ // ── Parser / evaluator ────────────────────────────────────────────────────
233
+ const OPENERS = { "{": "}", "[": "]", "(": ")" };
234
+ /**
235
+ * Tokens that, appearing straight after a value, prove the expression is a
236
+ * computation rather than a literal. `[` is included: `nav[0]` is a computed
237
+ * index, and silently yielding `nav` returns a whole well-formed sidebar from
238
+ * the wrong node of the tree.
239
+ */
240
+ const OPERATORS = new Set([
241
+ "+", "-", "*", "/", "%", "?", ":", "|", "&", "^",
242
+ "||", "&&", "??", "==", "!=", "===", "!==", "<", ">", "<=", ">=", "[",
243
+ ]);
244
+ const CLOSERS = new Set(["}", "]", ")"]);
245
+ /**
246
+ * The most values one result may expand to. A config is someone else's code: shared consts
247
+ * (`const s1 = { items: [s0, s0] }`, eighteen levels) are cheap here, where a reference is
248
+ * reused, and exponential for every caller that walks the result (184 MB of nav from a
249
+ * 1 KB config), and spreads (`[...a, ...a]`) copy for real.
250
+ */
251
+ const MAX_VALUES = 100_000;
252
+ /** The longest string `+` may build: `const b = a + a` doubles, thirty times is a gigabyte. */
253
+ const MAX_STRING = 1024 * 1024;
254
+ /** How deeply values may nest: past this a parser or a consumer walking it runs out of stack. */
255
+ const MAX_DEPTH = 500;
256
+ /**
257
+ * Assign a key the way a literal does: `{ "__proto__": x }` is an own property. Plain
258
+ * assignment would set the object's prototype instead, so a sidebar item could inherit a
259
+ * `type` or `items` that no key in the source gave it.
260
+ */
261
+ function setKey(out, key, value) {
262
+ if (key === "__proto__")
263
+ Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true });
264
+ else
265
+ out[key] = value;
266
+ }
267
+ class Evaluator {
268
+ opts;
269
+ toks;
270
+ bindings = new Map();
271
+ /** Each array or object built here → the values it expands to, itself included. */
272
+ sizes = new WeakMap();
273
+ depth = 0;
274
+ unresolved = [];
275
+ constructor(src, opts = {}) {
276
+ this.opts = opts;
277
+ this.toks = tokenize(src);
278
+ this.collectBindings();
279
+ this.reportMutations();
280
+ }
281
+ /**
282
+ * A top-level statement that changes a binding after it is defined — `module.exports.y = 2`,
283
+ * `exports.y = 2`, `s.items = […]`, `s.items.push(…)` — is not read: the value is read as
284
+ * declared, so the change is REPORTED, never silently missing (review 2026-10-21).
285
+ */
286
+ reportMutations() {
287
+ let depth = 0;
288
+ for (let i = 0; i < this.toks.length; i++) {
289
+ const t = this.toks[i];
290
+ if (t.kind === "punct" && OPENERS[t.text])
291
+ depth++;
292
+ else if (t.kind === "punct" && CLOSERS.has(t.text))
293
+ depth--;
294
+ if (depth !== 0 || t.kind !== "ident" || !(this.is(i + 1, ".") || this.is(i + 1, "[")))
295
+ continue;
296
+ const prev = this.toks[i - 1];
297
+ // The start of a statement: the file's start, after `;` or `}`, or on a new line
298
+ if (prev && !(prev.kind === "punct" && (prev.text === ";" || prev.text === "}")) && prev.line === t.line)
299
+ continue;
300
+ if (prev && prev.kind === "punct" && prev.text === ".")
301
+ continue;
302
+ const name = t.text;
303
+ const known = name === "exports" || name === "module" || this.bindings.has(name);
304
+ if (!known)
305
+ continue;
306
+ if (name === "module" && this.is(i + 2, "exports") && this.is(i + 3, "="))
307
+ continue; // the export itself
308
+ this.report(i, `a statement that changes \`${name}\` after it is defined (read as declared, without the change)`);
309
+ }
310
+ }
311
+ /** What `v` costs against MAX_VALUES. A long string costs more than one value. */
312
+ sizeOf(v) {
313
+ if (typeof v === "string")
314
+ return 1 + (v.length >> 10);
315
+ return v !== null && typeof v === "object" ? (this.sizes.get(v) ?? 1) : 1;
316
+ }
317
+ /**
318
+ * The size of a value this evaluator did not build (another module's), measured once per
319
+ * object: a module required twice is shared, not copied, and counts as what it expands to.
320
+ * MAX_VALUES + 1 for anything over the budget or nested past MAX_DEPTH.
321
+ */
322
+ measure(v, depth = 0) {
323
+ if (v === null || typeof v !== "object")
324
+ return this.sizeOf(v);
325
+ const known = this.sizes.get(v);
326
+ if (known !== undefined)
327
+ return known;
328
+ if (depth > MAX_DEPTH)
329
+ return MAX_VALUES + 1;
330
+ let size = 1;
331
+ for (const c of Array.isArray(v) ? v : Object.values(v)) {
332
+ size += this.measure(c, depth + 1);
333
+ if (size > MAX_VALUES)
334
+ break;
335
+ }
336
+ this.sizes.set(v, size);
337
+ return size;
338
+ }
339
+ /** `require(spec)` / `import … from spec`, through the caller's resolver, within budget. */
340
+ required(spec, i) {
341
+ const value = this.opts.require?.(spec);
342
+ if (value === undefined || value === UNRESOLVED) {
343
+ this.report(i, `module "${spec}" ${this.opts.require ? "could not be read" : "(no module resolver)"}`);
344
+ return UNRESOLVED;
345
+ }
346
+ if (this.measure(value) > MAX_VALUES) {
347
+ this.report(i, `module "${spec}": a value that expands to more than ${MAX_VALUES} entries or nests past ${MAX_DEPTH} levels`);
348
+ return UNRESOLVED;
349
+ }
350
+ return value;
351
+ }
352
+ /** `out` when it expands to at most MAX_VALUES values, else UNRESOLVED and a report. */
353
+ bounded(out, size, i) {
354
+ if (size > MAX_VALUES) {
355
+ this.report(i, `a value that expands to more than ${MAX_VALUES} entries`);
356
+ return UNRESOLVED;
357
+ }
358
+ this.sizes.set(out, size);
359
+ return out;
360
+ }
361
+ tok(i) {
362
+ return this.toks[i];
363
+ }
364
+ is(i, text) {
365
+ const t = this.toks[i];
366
+ return !!t && t.text === text && (t.kind === "punct" || t.kind === "ident");
367
+ }
368
+ report(i, what) {
369
+ const t = this.toks[i];
370
+ const where = t ? ` (line ${t.line})` : "";
371
+ const msg = `${what}${where}`;
372
+ if (!this.unresolved.includes(msg))
373
+ this.unresolved.push(msg);
374
+ }
375
+ /**
376
+ * Skip a balanced expression starting at `i`, stopping at a top-level `,`
377
+ * or a closer. Returns the index just past it. This is what keeps an
378
+ * unreadable expression from desyncing the enclosing object.
379
+ */
380
+ /**
381
+ * Skip `i` forward by at least one token.
382
+ *
383
+ * `skipValue` deliberately returns `i` UNCHANGED when it is already sitting
384
+ * on a closer, so every caller that loops must force progress or it spins
385
+ * forever. A computed key like `{ [base + "/"]: [...] }` leaves `k` on the
386
+ * `]` and hung the whole import with no output — a config that never
387
+ * returns is strictly worse than one that fails.
388
+ */
389
+ advance(i) {
390
+ const next = this.skipValue(i);
391
+ return next === i ? i + 1 : next;
392
+ }
393
+ skipValue(i) {
394
+ let depth = 0;
395
+ while (i < this.toks.length) {
396
+ const t = this.toks[i];
397
+ if (depth === 0 && t.kind === "punct" && (t.text === "," || CLOSERS.has(t.text))) {
398
+ return i;
399
+ }
400
+ if (t.kind === "punct" && OPENERS[t.text])
401
+ depth++;
402
+ else if (t.kind === "punct" && CLOSERS.has(t.text))
403
+ depth--;
404
+ i++;
405
+ }
406
+ return i;
407
+ }
408
+ /** Skip a TS type annotation from `:` until `=`, `=>`, `,` or a closer. */
409
+ /**
410
+ * Skip a balanced `<…>` type-argument list, returning the index just past
411
+ * `>`. `skipTypeAnnotation` cannot do this: it only stops at depth 0, so
412
+ * after `>` closed the generics it kept going into the argument list and
413
+ * swallowed the whole call — losing the entire config of a site using
414
+ * VitePress's own documented `defineConfigWithTheme<ThemeConfig>({…})`.
415
+ */
416
+ skipAngles(i) {
417
+ if (!this.is(i, "<"))
418
+ return i;
419
+ let depth = 0;
420
+ while (i < this.toks.length) {
421
+ const t = this.toks[i];
422
+ if (t.kind === "punct" && t.text === "<")
423
+ depth++;
424
+ else if (t.kind === "punct" && t.text === ">") {
425
+ depth--;
426
+ if (depth === 0)
427
+ return i + 1;
428
+ }
429
+ else if (t.kind === "punct" && (t.text === "(" || t.text === "{")) {
430
+ // A `(`/`{` inside what we assumed were generics means it was not a
431
+ // type-argument list at all. Bail rather than run away.
432
+ return i;
433
+ }
434
+ i++;
435
+ }
436
+ return i;
437
+ }
438
+ /**
439
+ * From a `:` type annotation on a BINDING, find its `=`.
440
+ *
441
+ * A function type contains `=>` (`const s: () => Item[] = () => [...]`), and
442
+ * `skipTypeAnnotation` stops there — so the binding was never recorded and
443
+ * the sidebar it held was lost. `=>` is one token, so scanning for an exact
444
+ * `=` at depth 0 skips it naturally.
445
+ */
446
+ skipToAssign(i) {
447
+ let depth = 0;
448
+ while (i < this.toks.length) {
449
+ const t = this.toks[i];
450
+ if (depth === 0 && t.kind === "punct" && t.text === "=")
451
+ return i;
452
+ if (t.kind === "punct" && (OPENERS[t.text] || t.text === "<"))
453
+ depth++;
454
+ else if (t.kind === "punct" && (CLOSERS.has(t.text) || t.text === ">")) {
455
+ if (depth === 0)
456
+ return i;
457
+ depth--;
458
+ }
459
+ i++;
460
+ }
461
+ return i;
462
+ }
463
+ skipTypeAnnotation(i) {
464
+ let depth = 0;
465
+ while (i < this.toks.length) {
466
+ const t = this.toks[i];
467
+ if (depth === 0 && t.kind === "punct" && (t.text === "=" || t.text === "=>"))
468
+ return i;
469
+ if (depth === 0 && t.kind === "punct" && (t.text === "," || CLOSERS.has(t.text)))
470
+ return i;
471
+ if (t.kind === "punct" && (OPENERS[t.text] || t.text === "<"))
472
+ depth++;
473
+ else if (t.kind === "punct" && (CLOSERS.has(t.text) || t.text === ">"))
474
+ depth--;
475
+ i++;
476
+ }
477
+ return i;
478
+ }
479
+ /** Index the module's top-level `const`/`function`/`export default`. */
480
+ collectBindings() {
481
+ let depth = 0;
482
+ for (let i = 0; i < this.toks.length; i++) {
483
+ const t = this.toks[i];
484
+ if (t.kind === "punct" && OPENERS[t.text]) {
485
+ depth++;
486
+ continue;
487
+ }
488
+ if (t.kind === "punct" && CLOSERS.has(t.text)) {
489
+ depth--;
490
+ continue;
491
+ }
492
+ if (depth !== 0)
493
+ continue;
494
+ if (t.kind !== "ident")
495
+ continue;
496
+ // `export default <expr>` — also the wrapped `export default defineConfig({…})`.
497
+ if (t.text === "export" && this.is(i + 1, "default")) {
498
+ this.bindings.set("default", { at: i + 2 });
499
+ continue;
500
+ }
501
+ // CommonJS `module.exports = <expr>` is the default export (Docusaurus sidebars).
502
+ if (t.text === "module" && this.is(i + 1, ".") && this.is(i + 2, "exports") && this.is(i + 3, "=")) {
503
+ this.bindings.set("default", { at: i + 4 });
504
+ continue;
505
+ }
506
+ // `import x from "spec"` (and the default of `import x, { … } from "spec"`). Named and
507
+ // namespace imports are not read: an identifier from one is reported where it is used.
508
+ if (t.text === "import" && this.toks[i + 1]?.kind === "ident" && !this.is(i + 1, "type")) {
509
+ const name = this.toks[i + 1].text;
510
+ let k = i + 2;
511
+ if (this.is(k, ","))
512
+ k = this.skipBalanced(k + 1);
513
+ const spec = this.toks[k + 1];
514
+ if (this.is(k, "from") && spec?.kind === "str")
515
+ this.bindings.set(name, { at: k + 1, from: spec.text });
516
+ continue;
517
+ }
518
+ // Skip the `export` modifier so `export const x = …` is indexed too.
519
+ const j = t.text === "export" ? i + 1 : i;
520
+ const kw = this.tok(j);
521
+ if (!kw || kw.kind !== "ident")
522
+ continue;
523
+ if (kw.text === "const" || kw.text === "let" || kw.text === "var") {
524
+ const name = this.tok(j + 1);
525
+ if (!name || name.kind !== "ident")
526
+ continue;
527
+ let k = j + 2;
528
+ if (this.is(k, ":"))
529
+ k = this.skipToAssign(k + 1);
530
+ if (!this.is(k, "="))
531
+ continue;
532
+ this.bindings.set(name.text, { at: k + 1 });
533
+ continue;
534
+ }
535
+ if (kw.text === "function") {
536
+ // `function f() { … return <expr> … }`
537
+ let k = j + 1;
538
+ if (this.is(k, "*"))
539
+ k++;
540
+ const name = this.tok(k);
541
+ if (!name || name.kind !== "ident")
542
+ continue;
543
+ const bodyStart = this.findFunctionBody(k + 1);
544
+ if (bodyStart < 0)
545
+ continue;
546
+ const ret = this.findReturn(bodyStart);
547
+ if (ret >= 0)
548
+ this.bindings.set(name.text, { at: ret });
549
+ }
550
+ }
551
+ }
552
+ /** From after a function name, find the index just past `{` of its body. */
553
+ findFunctionBody(i) {
554
+ // params
555
+ if (!this.is(i, "(")) {
556
+ // Possibly a generic `<T>` first.
557
+ if (this.is(i, "<"))
558
+ i = this.skipTypeAnnotation(i);
559
+ if (!this.is(i, "("))
560
+ return -1;
561
+ }
562
+ i = this.skipBalanced(i);
563
+ if (this.is(i, ":"))
564
+ i = this.skipTypeAnnotation(i + 1);
565
+ if (!this.is(i, "{"))
566
+ return -1;
567
+ return i + 1;
568
+ }
569
+ /** Index just past the group opened at `i`. */
570
+ skipBalanced(i) {
571
+ if (!this.toks[i] || !OPENERS[this.toks[i].text])
572
+ return i;
573
+ let depth = 0;
574
+ while (i < this.toks.length) {
575
+ const t = this.toks[i];
576
+ if (t.kind === "punct" && OPENERS[t.text])
577
+ depth++;
578
+ else if (t.kind === "punct" && CLOSERS.has(t.text)) {
579
+ depth--;
580
+ if (depth === 0)
581
+ return i + 1;
582
+ }
583
+ i++;
584
+ }
585
+ return i;
586
+ }
587
+ /** First `return` at body depth 0, from `i` (already inside the body). */
588
+ findReturn(i) {
589
+ let depth = 0;
590
+ while (i < this.toks.length) {
591
+ const t = this.toks[i];
592
+ if (t.kind === "punct" && OPENERS[t.text])
593
+ depth++;
594
+ else if (t.kind === "punct" && CLOSERS.has(t.text)) {
595
+ if (depth === 0)
596
+ return -1; // end of function body
597
+ depth--;
598
+ }
599
+ else if (depth === 0 && t.kind === "ident" && t.text === "return") {
600
+ return i + 1;
601
+ }
602
+ i++;
603
+ }
604
+ return -1;
605
+ }
606
+ /** Evaluate a named module binding (memoized, cycle-safe). */
607
+ resolveBinding(name) {
608
+ const b = this.bindings.get(name);
609
+ if (!b)
610
+ return UNRESOLVED;
611
+ if (b.computing)
612
+ return UNRESOLVED;
613
+ if (b.value !== undefined)
614
+ return b.value;
615
+ b.computing = true;
616
+ const v = b.from !== undefined ? this.required(b.from, b.at) : this.parseValue(b.at).value;
617
+ b.computing = false;
618
+ b.value = v;
619
+ return v;
620
+ }
621
+ hasBinding(name) {
622
+ return this.bindings.has(name);
623
+ }
624
+ /**
625
+ * The default export, with one extra allowance: VitePress accepts a config
626
+ * that is a FUNCTION of the build context (`export default ({ mode }) => ({…})`
627
+ * — vitest's shape). We cannot supply that context, so we read the function's
628
+ * return expression directly.
629
+ *
630
+ * This is deliberately scoped to the default export. Treating any
631
+ * parameterized arrow as its own body would evaluate `.map(x => …)`
632
+ * callbacks as data, which is how a wrong value gets into a sidebar.
633
+ */
634
+ resolveDefaultExport() {
635
+ const b = this.bindings.get("default");
636
+ if (!b)
637
+ return UNRESOLVED;
638
+ let k = b.at;
639
+ if (this.is(k, "async"))
640
+ k++;
641
+ if (this.is(k, "function")) {
642
+ let j = k + 1;
643
+ if (this.toks[j]?.kind === "ident")
644
+ j++;
645
+ const body = this.findFunctionBody(j);
646
+ const ret = body < 0 ? -1 : this.findReturn(body);
647
+ return ret < 0 ? UNRESOLVED : this.parseValue(ret).value;
648
+ }
649
+ if (this.is(k, "(")) {
650
+ let j = this.skipBalanced(k);
651
+ if (this.is(j, ":"))
652
+ j = this.skipTypeAnnotation(j + 1);
653
+ if (this.is(j, "=>")) {
654
+ const body = j + 1;
655
+ if (this.is(body, "{")) {
656
+ const ret = this.findReturn(body + 1);
657
+ return ret < 0 ? UNRESOLVED : this.parseValue(ret).value;
658
+ }
659
+ return this.parseValue(body).value;
660
+ }
661
+ }
662
+ return this.resolveBinding("default");
663
+ }
664
+ /** Parse the expression at `i`. Returns its value and the next index. */
665
+ parseValue(i) {
666
+ if (this.depth >= MAX_DEPTH) {
667
+ this.report(i, `a value nested more than ${MAX_DEPTH} levels deep`);
668
+ return { value: UNRESOLVED, next: this.skipValue(i) };
669
+ }
670
+ this.depth++;
671
+ try {
672
+ return this.parseValueAt(i);
673
+ }
674
+ finally {
675
+ this.depth--;
676
+ }
677
+ }
678
+ parseValueAt(i) {
679
+ const t = this.toks[i];
680
+ if (!t)
681
+ return { value: UNRESOLVED, next: i };
682
+ // Unary minus on a number.
683
+ if (t.kind === "punct" && t.text === "-" && this.toks[i + 1]?.kind === "num") {
684
+ return this.postfix(-Number(this.toks[i + 1].text), i + 2);
685
+ }
686
+ if (t.kind === "str")
687
+ return this.postfix(t.text, i + 1);
688
+ if (t.kind === "num")
689
+ return this.postfix(Number(t.text), i + 1);
690
+ if (t.kind === "template") {
691
+ if (t.pure)
692
+ return this.postfix(t.text, i + 1);
693
+ this.report(i, "template literal with interpolation");
694
+ return { value: UNRESOLVED, next: this.skipValue(i) };
695
+ }
696
+ if (t.kind === "ident") {
697
+ if (t.text === "true")
698
+ return this.postfix(true, i + 1);
699
+ if (t.text === "false")
700
+ return this.postfix(false, i + 1);
701
+ if (t.text === "null")
702
+ return this.postfix(null, i + 1);
703
+ if (t.text === "undefined")
704
+ return this.postfix(UNRESOLVED, i + 1);
705
+ // Arrow with no params: `() => <expr>` is handled at `(`; a single
706
+ // bare param `x => …` is a function value we cannot call — unresolved.
707
+ if (this.is(i + 1, "=>")) {
708
+ this.report(i, `arrow function \`${t.text} => …\``);
709
+ return { value: UNRESOLVED, next: this.skipValue(i) };
710
+ }
711
+ // Call expression.
712
+ if (this.is(i + 1, "(") || this.is(i + 1, "<")) {
713
+ return this.parseCall(t.text, i);
714
+ }
715
+ // Identifier reference.
716
+ if (this.bindings.has(t.text)) {
717
+ return this.postfix(this.resolveBinding(t.text), i + 1);
718
+ }
719
+ // An imported or otherwise unknown identifier.
720
+ this.report(i, `identifier \`${t.text}\` (imported or computed)`);
721
+ return { value: UNRESOLVED, next: this.skipValue(i) };
722
+ }
723
+ if (t.kind === "punct") {
724
+ if (t.text === "[")
725
+ return this.parseArray(i);
726
+ if (t.text === "{")
727
+ return this.parseObject(i);
728
+ if (t.text === "(")
729
+ return this.parseParenOrArrow(i);
730
+ }
731
+ this.report(i, `unsupported expression starting with \`${t.text}\``);
732
+ return { value: UNRESOLVED, next: this.skipValue(i) };
733
+ }
734
+ /**
735
+ * `(…)` is either a parenthesized expression, or the parameter list of an
736
+ * arrow function. A ZERO-ARG arrow is callable by this evaluator, so it is
737
+ * treated as its own body: `const f = () => [ … ]` binds `f` to the array.
738
+ */
739
+ parseParenOrArrow(i) {
740
+ const afterParens = this.skipBalanced(i);
741
+ let k = afterParens;
742
+ if (this.is(k, ":"))
743
+ k = this.skipTypeAnnotation(k + 1);
744
+ if (this.is(k, "=>")) {
745
+ const zeroArg = this.is(i + 1, ")");
746
+ if (!zeroArg) {
747
+ this.report(i, "arrow function with parameters");
748
+ return { value: UNRESOLVED, next: this.skipValue(i) };
749
+ }
750
+ let body = k + 1;
751
+ if (this.is(body, "{")) {
752
+ const ret = this.findReturn(body + 1);
753
+ if (ret < 0) {
754
+ this.report(i, "arrow function body with no literal `return`");
755
+ return { value: UNRESOLVED, next: this.skipValue(i) };
756
+ }
757
+ const v = this.parseValue(ret).value;
758
+ return { value: v, next: this.skipBalanced(body) };
759
+ }
760
+ const { value } = this.parseValue(body);
761
+ return { value, next: this.skipValue(body) };
762
+ }
763
+ // Plain parenthesized expression.
764
+ const inner = this.parseValue(i + 1);
765
+ return this.postfix(inner.value, afterParens);
766
+ }
767
+ /**
768
+ * A call. Two shapes are readable:
769
+ * - zero args on a module-level function/arrow → its literal body
770
+ * (hono: `sidebar: { '/': sidebars() }`)
771
+ * - one literal argument on an unknown function → the argument
772
+ * (`defineConfig({…})`, `defineConfigWithTheme({…})`, `withMermaid({…})`)
773
+ */
774
+ parseCall(name, i) {
775
+ let open = i + 1;
776
+ if (this.is(open, "<"))
777
+ open = this.skipAngles(open); // generic type args
778
+ if (!this.is(open, "(")) {
779
+ this.report(i, `call to \`${name}\``);
780
+ return { value: UNRESOLVED, next: this.skipValue(i) };
781
+ }
782
+ const after = this.skipBalanced(open);
783
+ // `require("./x")`: one string argument, nothing else
784
+ if (name === "require" && !this.bindings.has("require")) {
785
+ const spec = this.toks[open + 1];
786
+ if (spec?.kind === "str" && this.is(open + 2, ")"))
787
+ return this.postfix(this.required(spec.text, i), after);
788
+ this.report(i, "`require` of something other than one string");
789
+ return { value: UNRESOLVED, next: after };
790
+ }
791
+ // Zero args → the callee's own literal body.
792
+ if (this.is(open + 1, ")")) {
793
+ if (this.bindings.has(name)) {
794
+ return this.postfix(this.resolveBinding(name), after);
795
+ }
796
+ this.report(i, `call to unknown function \`${name}()\``);
797
+ return { value: UNRESOLVED, next: after };
798
+ }
799
+ // A single-argument call on an unknown function is unwrapped ONLY in the
800
+ // config-wrapper shape: the argument is an object literal, or another call
801
+ // (wrappers nest — vitest's export is
802
+ // `withPwa(extendConfig(defineConfig({…})))`).
803
+ //
804
+ // It must NOT unwrap a single ARRAY argument. `vitepress-sidebar`, the most
805
+ // common sidebar plugin, is called `generateSidebar([{…}, {…}])` — and
806
+ // returning its options array as the sidebar produced a complete,
807
+ // plausible-looking nav made of plugin CONFIG, reported as fully resolved.
808
+ // A wrong sidebar that declares itself correct is the worst output this
809
+ // module can produce.
810
+ const arg = this.toks[open + 1];
811
+ const isObjectLiteral = arg?.kind === "punct" && arg.text === "{";
812
+ const isNestedCall = arg?.kind === "ident" && (this.is(open + 2, "(") || this.is(open + 2, "<"));
813
+ if (isObjectLiteral || isNestedCall) {
814
+ const { value, next } = this.parseValue(open + 1);
815
+ if (this.is(next, ")"))
816
+ return this.postfix(value, after);
817
+ }
818
+ this.report(i, `call to \`${name}(…)\``);
819
+ return { value: UNRESOLVED, next: after };
820
+ }
821
+ parseArray(i) {
822
+ const out = [];
823
+ let size = 1;
824
+ let k = i + 1;
825
+ while (k < this.toks.length && !this.is(k, "]")) {
826
+ if (this.is(k, ",")) {
827
+ k++;
828
+ continue;
829
+ }
830
+ if (this.is(k, "...")) {
831
+ const { value, next } = this.parseValue(k + 1);
832
+ // One at a time, and only while within budget: `push(...value)` overflowed the stack
833
+ if (Array.isArray(value)) {
834
+ for (const v of value) {
835
+ if (size > MAX_VALUES)
836
+ break;
837
+ out.push(v);
838
+ size += this.sizeOf(v);
839
+ }
840
+ }
841
+ else if (value !== UNRESOLVED)
842
+ this.report(k, "spread of a non-array");
843
+ k = next;
844
+ continue;
845
+ }
846
+ const { value, next } = this.parseValue(k);
847
+ out.push(value);
848
+ size += this.sizeOf(value);
849
+ k = next === k ? k + 1 : next;
850
+ // Elements are separated by commas. Whatever else follows a value was not read as part
851
+ // of it (review 2026-10-21: `.concat("x")`'s argument became an element of its own)
852
+ if (k < this.toks.length && !this.is(k, ",") && !this.is(k, "]")) {
853
+ this.report(k, `unexpected \`${this.toks[k].text}\` after an array element`);
854
+ out[out.length - 1] = UNRESOLVED;
855
+ k = this.advance(k);
856
+ }
857
+ }
858
+ return this.postfix(this.bounded(out, size, i), k + 1);
859
+ }
860
+ parseObject(i) {
861
+ const out = {};
862
+ let k = i + 1;
863
+ while (k < this.toks.length && !this.is(k, "}")) {
864
+ if (this.is(k, ",") || this.is(k, ";")) {
865
+ k++;
866
+ continue;
867
+ }
868
+ if (this.is(k, "...")) {
869
+ const { value, next } = this.parseValue(k + 1);
870
+ // `typeof x === "object"` already excludes the UNRESOLVED symbol.
871
+ if (value && typeof value === "object" && !Array.isArray(value)) {
872
+ for (const [key, v] of Object.entries(value))
873
+ setKey(out, key, v);
874
+ }
875
+ else if (value !== UNRESOLVED) {
876
+ this.report(k, "spread of a non-object");
877
+ }
878
+ k = next;
879
+ continue;
880
+ }
881
+ // Key.
882
+ const kt = this.toks[k];
883
+ if (!kt)
884
+ break;
885
+ let key = null;
886
+ if (kt.kind === "str" || kt.kind === "template")
887
+ key = kt.text;
888
+ else if (kt.kind === "ident" || kt.kind === "num")
889
+ key = kt.text;
890
+ else if (kt.kind === "punct" && kt.text === "[") {
891
+ // Computed key — read it if it is a literal, else skip the entry.
892
+ const { value, next } = this.parseValue(k + 1);
893
+ k = next;
894
+ if (this.is(k, "]"))
895
+ k++;
896
+ key = typeof value === "string" || typeof value === "number" ? String(value) : null;
897
+ if (key === null) {
898
+ this.report(kt.start, "computed object key");
899
+ k = this.is(k, ":") ? this.advance(k + 1) : this.advance(k);
900
+ continue;
901
+ }
902
+ if (this.is(k, ":")) {
903
+ const v = this.parseValue(k + 1);
904
+ setKey(out, key, v.value);
905
+ k = v.next;
906
+ }
907
+ continue;
908
+ }
909
+ else {
910
+ // Something unexpected in key position — skip one entry.
911
+ k = this.advance(k);
912
+ continue;
913
+ }
914
+ k++;
915
+ // Method shorthand `name() { … }` — a function, not data.
916
+ if (this.is(k, "(")) {
917
+ const bodyStart = this.findFunctionBody(k);
918
+ k = bodyStart < 0 ? this.skipValue(k) : this.skipBalanced(bodyStart - 1);
919
+ continue;
920
+ }
921
+ if (!this.is(k, ":")) {
922
+ // Shorthand `{ foo }`.
923
+ setKey(out, key, this.bindings.has(key) ? this.resolveBinding(key) : UNRESOLVED);
924
+ if (!this.bindings.has(key))
925
+ this.report(k, `shorthand property \`${key}\``);
926
+ continue;
927
+ }
928
+ const v = this.parseValue(k + 1);
929
+ setKey(out, key, v.value);
930
+ k = v.next === k + 1 ? this.skipValue(k + 1) : v.next;
931
+ if (k < this.toks.length && !this.is(k, ",") && !this.is(k, "}") && !this.is(k, ";")) {
932
+ this.report(k, `unexpected \`${this.toks[k].text}\` after property \`${key}\``);
933
+ setKey(out, key, UNRESOLVED);
934
+ k = this.advance(k);
935
+ }
936
+ }
937
+ let size = 1;
938
+ for (const v of Object.values(out))
939
+ size += this.sizeOf(v);
940
+ return this.postfix(this.bounded(out, size, i), k + 1);
941
+ }
942
+ /**
943
+ * Postfix operators after a value: TS `as`/`satisfies`, and member access
944
+ * into a value we already have.
945
+ */
946
+ postfix(value, i) {
947
+ let v = value;
948
+ let k = i;
949
+ for (;;) {
950
+ if (this.is(k, "as") || this.is(k, "satisfies")) {
951
+ k = this.skipTypeAnnotation(k + 1);
952
+ continue;
953
+ }
954
+ if (this.is(k, "!")) {
955
+ k++;
956
+ continue;
957
+ }
958
+ // A call on a value (`a.concat(…)`, `"ab"("y")`): its result is computed. The arguments
959
+ // are skipped with it, or they were read as the next element of the enclosing list.
960
+ if (this.is(k, "(")) {
961
+ this.report(k, "a call on a value (computed, not a literal)");
962
+ v = UNRESOLVED;
963
+ k = this.skipBalanced(k);
964
+ continue;
965
+ }
966
+ // `x[0]`, `x["key"]`: a literal index into a value we have. The index is read, never
967
+ // computed: `nav[i]` stays unresolved (below), and so does a key the value does not own.
968
+ if (this.is(k, "[") && (this.toks[k + 1]?.kind === "num" || this.toks[k + 1]?.kind === "str") && this.is(k + 2, "]")) {
969
+ const index = this.toks[k + 1].kind === "num" ? Number(this.toks[k + 1].text) : this.toks[k + 1].text;
970
+ const own = Array.isArray(v) ? (typeof index === "number" && Number.isInteger(index) && index >= 0 && index < v.length ? v[index] : undefined)
971
+ : v !== null && typeof v === "object" && Object.hasOwn(v, String(index)) ? v[String(index)]
972
+ : undefined;
973
+ if (own === undefined) {
974
+ this.report(k, `index \`[${this.toks[k + 1].text}]\` into a value that does not have it`);
975
+ v = UNRESOLVED;
976
+ }
977
+ else
978
+ v = own;
979
+ k += 3;
980
+ continue;
981
+ }
982
+ if ((this.is(k, ".") || this.is(k, "?.")) && this.toks[k + 1]?.kind === "ident") {
983
+ const prop = this.toks[k + 1].text;
984
+ const obj = v && typeof v === "object" && !Array.isArray(v)
985
+ ? v
986
+ : undefined;
987
+ if (!obj || !Object.hasOwn(obj, prop)) {
988
+ // Includes every METHOD call — `.map(…)`, `.concat(…)`. Reporting
989
+ // matters: `config.ts` drops UNRESOLVED leaves, so an unreported
990
+ // one vanishes from the config AND from the "could not read" list,
991
+ // which is precisely the silent omission this module forbids.
992
+ this.report(k, `property access \`.${prop}\` on a value this reader could not resolve`);
993
+ v = UNRESOLVED;
994
+ }
995
+ else {
996
+ v = obj[prop];
997
+ }
998
+ k += 2;
999
+ continue;
1000
+ }
1001
+ break;
1002
+ }
1003
+ // `"a" + "b"`: two strings and nothing else. The right side is read as a value, so
1004
+ // `a + b + c` folds from the right, which for strings is the same thing; a number on either
1005
+ // side (`1 + 2 + "x"` is "3x", not "12x") or any other operator after it is unresolved.
1006
+ if (typeof v === "string" && this.is(k, "+") && this.toks[k]?.kind === "punct") {
1007
+ const right = this.parseValue(k + 1);
1008
+ if (typeof right.value !== "string") {
1009
+ if (right.value !== UNRESOLVED)
1010
+ this.report(k, "`+` with something other than a string");
1011
+ return { value: UNRESOLVED, next: right.next };
1012
+ }
1013
+ if (v.length + right.value.length > MAX_STRING) {
1014
+ this.report(k, `a string longer than ${MAX_STRING} characters`);
1015
+ return { value: UNRESOLVED, next: right.next };
1016
+ }
1017
+ return { value: v + right.value, next: right.next };
1018
+ }
1019
+ // An operator after the value means the real expression is a computation
1020
+ // (`base + '/a'`, `isProd ? a : b`, `a || fallback`, `nav[0].items`).
1021
+ // Returning just the LEFT operand handed back a confident, wrong answer —
1022
+ // `link: base + '/a'` became `link: "/guide"`, and `sidebar: nav[0].items`
1023
+ // became the whole `nav` array — with nothing recorded as unresolved.
1024
+ if (OPERATORS.has(this.toks[k]?.text ?? "") && this.toks[k]?.kind === "punct") {
1025
+ this.report(k, `expression using \`${this.toks[k].text}\` (computed, not a literal)`);
1026
+ return { value: UNRESOLVED, next: this.skipValue(k) };
1027
+ }
1028
+ return { value: v, next: k };
1029
+ }
1030
+ }
1031
+ /**
1032
+ * Evaluate a module's default export as far as the literal subset allows.
1033
+ *
1034
+ * Falls back to the binding named by `fallbackExport` (default `config`) when
1035
+ * there is no `export default`, which covers
1036
+ * `const config = { … }; export { config }`.
1037
+ */
1038
+ export function evaluateModule(source, fallbackExport = "config", opts = {}) {
1039
+ const ev = new Evaluator(source, opts);
1040
+ let value = UNRESOLVED;
1041
+ if (ev.hasBinding("default"))
1042
+ value = ev.resolveDefaultExport();
1043
+ if (value === UNRESOLVED && ev.hasBinding(fallbackExport)) {
1044
+ value = ev.resolveBinding(fallbackExport);
1045
+ }
1046
+ return { value, unresolved: ev.unresolved };
1047
+ }
1048
+ /** Narrowing helper: a plain object with no UNRESOLVED identity. */
1049
+ export function asObject(v) {
1050
+ if (v && typeof v === "object" && !Array.isArray(v)) {
1051
+ return v;
1052
+ }
1053
+ return undefined;
1054
+ }
1055
+ export function asArray(v) {
1056
+ return Array.isArray(v) ? v : undefined;
1057
+ }
1058
+ export function asString(v) {
1059
+ return typeof v === "string" ? v : undefined;
1060
+ }
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@dogsbay/literal",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0-beta.133",
4
+ "description": "Read a JavaScript/TypeScript config module as data without running it: object and array literals, spreads, module-level constants, zero-argument helpers and wrapper calls; anything else is reported, never guessed. No Node, no dependencies",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "README.md"
17
+ ],
18
+ "devDependencies": {
19
+ "typescript": "^5.9.3",
20
+ "vitest": "^4.1.10"
21
+ },
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/dogsbay/dogsbay.git",
26
+ "directory": "packages/literal"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc",
30
+ "test": "vitest run",
31
+ "test:watch": "vitest"
32
+ }
6
33
  }