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

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,22 @@
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 TypeScript type noise.
15
+ Anything else becomes `UNRESOLVED` and is listed in `unresolved`: never a plausible wrong
16
+ value, never a silent omission.
17
+
18
+ Used by the Dogsbay VitePress importer (`.vitepress/config.*`).
19
+
20
+ ## License
21
+
22
+ MIT
@@ -0,0 +1,54 @@
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`)
22
+ * - TS noise: type annotations, `as const`, `as T`, `satisfies T`, generics
23
+ *
24
+ * Anything else evaluates to the `UNRESOLVED` sentinel — never to a plausible
25
+ * wrong value and never to a silent omission. Callers report what they could
26
+ * not read (`EvalResult.unresolved`), because a sidebar that quietly lost a
27
+ * section looks exactly like a sidebar that never had one.
28
+ *
29
+ * The parser never desyncs: an expression it does not understand is skipped
30
+ * by balancing delimiters, so the surrounding object continues to parse.
31
+ */
32
+ /** Sentinel for an expression this evaluator cannot read. */
33
+ export declare const UNRESOLVED: unique symbol;
34
+ export type LiteralValue = string | number | boolean | null | typeof UNRESOLVED | LiteralValue[] | {
35
+ [key: string]: LiteralValue;
36
+ };
37
+ export interface EvalResult {
38
+ /** The module's default export, evaluated as far as possible. */
39
+ value: LiteralValue;
40
+ /** Human-readable descriptions of expressions that could not be read. */
41
+ unresolved: string[];
42
+ }
43
+ /**
44
+ * Evaluate a module's default export as far as the literal subset allows.
45
+ *
46
+ * Falls back to the binding named by `fallbackExport` (default `config`) when
47
+ * there is no `export default`, which covers
48
+ * `const config = { … }; export { config }`.
49
+ */
50
+ export declare function evaluateModule(source: string, fallbackExport?: string): EvalResult;
51
+ /** Narrowing helper: a plain object with no UNRESOLVED identity. */
52
+ export declare function asObject(v: LiteralValue): Record<string, LiteralValue> | undefined;
53
+ export declare function asArray(v: LiteralValue): LiteralValue[] | undefined;
54
+ export declare function asString(v: LiteralValue): string | undefined;
package/dist/index.js ADDED
@@ -0,0 +1,877 @@
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`)
22
+ * - TS noise: type annotations, `as const`, `as T`, `satisfies T`, generics
23
+ *
24
+ * Anything else evaluates to the `UNRESOLVED` sentinel — never to a plausible
25
+ * wrong value and never to a silent omission. Callers report what they could
26
+ * not read (`EvalResult.unresolved`), because a sidebar that quietly lost a
27
+ * section looks exactly like a sidebar that never had one.
28
+ *
29
+ * The parser never desyncs: an expression it does not understand is skipped
30
+ * by balancing delimiters, so the surrounding object continues to parse.
31
+ */
32
+ /** Sentinel for an expression this evaluator cannot read. */
33
+ export const UNRESOLVED = Symbol("dogsbay.literal.unresolved");
34
+ const IDENT_START = /[A-Za-z_$]/;
35
+ const IDENT_PART = /[A-Za-z0-9_$]/;
36
+ /**
37
+ * Positions after which a `/` begins a regex literal rather than division.
38
+ * Without this, `/['"]/ ` would open a phantom string and swallow the rest of
39
+ * the file.
40
+ */
41
+ const REGEX_PRECEDERS = new Set([
42
+ "(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "=>", "+", "-",
43
+ "*", "%", "<", ">", "===", "!==", "==", "!=", "&&", "||", "??",
44
+ ]);
45
+ const REGEX_PRECEDING_KEYWORDS = new Set([
46
+ "return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
47
+ "case", "do", "else", "yield", "await",
48
+ ]);
49
+ function tokenize(src) {
50
+ const toks = [];
51
+ let i = 0;
52
+ let line = 1;
53
+ const n = src.length;
54
+ const prevSignificant = () => toks[toks.length - 1];
55
+ while (i < n) {
56
+ const c = src[i];
57
+ if (c === "\n") {
58
+ line++;
59
+ i++;
60
+ continue;
61
+ }
62
+ if (c === " " || c === "\t" || c === "\r") {
63
+ i++;
64
+ continue;
65
+ }
66
+ // Comments
67
+ if (c === "/" && src[i + 1] === "/") {
68
+ while (i < n && src[i] !== "\n")
69
+ i++;
70
+ continue;
71
+ }
72
+ if (c === "/" && src[i + 1] === "*") {
73
+ i += 2;
74
+ while (i < n && !(src[i] === "*" && src[i + 1] === "/")) {
75
+ if (src[i] === "\n")
76
+ line++;
77
+ i++;
78
+ }
79
+ i += 2;
80
+ continue;
81
+ }
82
+ // Regex literal (only where a value may start)
83
+ if (c === "/") {
84
+ const p = prevSignificant();
85
+ const isRegexPos = !p ||
86
+ (p.kind === "punct" && REGEX_PRECEDERS.has(p.text)) ||
87
+ (p.kind === "ident" && REGEX_PRECEDING_KEYWORDS.has(p.text));
88
+ if (isRegexPos) {
89
+ const start = i;
90
+ i++;
91
+ let inClass = false;
92
+ while (i < n) {
93
+ const d = src[i];
94
+ if (d === "\\") {
95
+ i += 2;
96
+ continue;
97
+ }
98
+ if (d === "[")
99
+ inClass = true;
100
+ else if (d === "]")
101
+ inClass = false;
102
+ else if (d === "/" && !inClass)
103
+ break;
104
+ else if (d === "\n")
105
+ break; // unterminated — bail rather than run away
106
+ i++;
107
+ }
108
+ i++; // closing slash
109
+ while (i < n && /[gimsuyd]/.test(src[i]))
110
+ i++;
111
+ toks.push({ kind: "punct", text: "/regex/", start, line });
112
+ continue;
113
+ }
114
+ }
115
+ // Strings
116
+ if (c === '"' || c === "'") {
117
+ const start = i;
118
+ const quote = c;
119
+ i++;
120
+ let out = "";
121
+ while (i < n && src[i] !== quote) {
122
+ if (src[i] === "\\") {
123
+ out += unescapeChar(src[i + 1]);
124
+ i += 2;
125
+ continue;
126
+ }
127
+ if (src[i] === "\n")
128
+ line++;
129
+ out += src[i];
130
+ i++;
131
+ }
132
+ i++;
133
+ toks.push({ kind: "str", text: out, start, line });
134
+ continue;
135
+ }
136
+ // Template literal
137
+ if (c === "`") {
138
+ const start = i;
139
+ i++;
140
+ let out = "";
141
+ let pure = true;
142
+ while (i < n && src[i] !== "`") {
143
+ if (src[i] === "\\") {
144
+ out += unescapeChar(src[i + 1]);
145
+ i += 2;
146
+ continue;
147
+ }
148
+ if (src[i] === "$" && src[i + 1] === "{") {
149
+ pure = false;
150
+ // Skip the interpolation, balancing braces (and nested templates).
151
+ i += 2;
152
+ let depth = 1;
153
+ while (i < n && depth > 0) {
154
+ if (src[i] === "{")
155
+ depth++;
156
+ else if (src[i] === "}")
157
+ depth--;
158
+ else if (src[i] === "\n")
159
+ line++;
160
+ i++;
161
+ }
162
+ continue;
163
+ }
164
+ if (src[i] === "\n")
165
+ line++;
166
+ out += src[i];
167
+ i++;
168
+ }
169
+ i++;
170
+ toks.push({ kind: "template", text: out, pure, start, line });
171
+ continue;
172
+ }
173
+ // Numbers
174
+ if (/[0-9]/.test(c) || (c === "." && /[0-9]/.test(src[i + 1] ?? ""))) {
175
+ const start = i;
176
+ while (i < n && /[0-9a-fA-FxXoObBeE._+-]/.test(src[i])) {
177
+ // `+`/`-` only continue a number inside an exponent.
178
+ if ((src[i] === "+" || src[i] === "-") && !/[eE]/.test(src[i - 1]))
179
+ break;
180
+ i++;
181
+ }
182
+ toks.push({ kind: "num", text: src.slice(start, i), start, line });
183
+ continue;
184
+ }
185
+ // Identifiers / keywords
186
+ if (IDENT_START.test(c)) {
187
+ const start = i;
188
+ while (i < n && IDENT_PART.test(src[i]))
189
+ i++;
190
+ toks.push({ kind: "ident", text: src.slice(start, i), start, line });
191
+ continue;
192
+ }
193
+ // Punctuation — longest match first so `=>` and `...` stay single tokens.
194
+ const three = src.slice(i, i + 3);
195
+ const two = src.slice(i, i + 2);
196
+ if (three === "..." || three === "===" || three === "!==") {
197
+ toks.push({ kind: "punct", text: three, start: i, line });
198
+ i += 3;
199
+ continue;
200
+ }
201
+ if (["=>", "==", "!=", "&&", "||", "??", "?.", "<=", ">=", "++", "--"].includes(two)) {
202
+ toks.push({ kind: "punct", text: two, start: i, line });
203
+ i += 2;
204
+ continue;
205
+ }
206
+ toks.push({ kind: "punct", text: c, start: i, line });
207
+ i++;
208
+ }
209
+ return toks;
210
+ }
211
+ function unescapeChar(c) {
212
+ switch (c) {
213
+ case "n":
214
+ return "\n";
215
+ case "t":
216
+ return "\t";
217
+ case "r":
218
+ return "\r";
219
+ case "0":
220
+ return "\0";
221
+ case undefined:
222
+ return "";
223
+ default:
224
+ return c;
225
+ }
226
+ }
227
+ // ── Parser / evaluator ────────────────────────────────────────────────────
228
+ const OPENERS = { "{": "}", "[": "]", "(": ")" };
229
+ /**
230
+ * Tokens that, appearing straight after a value, prove the expression is a
231
+ * computation rather than a literal. `[` is included: `nav[0]` is a computed
232
+ * index, and silently yielding `nav` returns a whole well-formed sidebar from
233
+ * the wrong node of the tree.
234
+ */
235
+ const OPERATORS = new Set([
236
+ "+", "-", "*", "/", "%", "?", ":", "|", "&", "^",
237
+ "||", "&&", "??", "==", "!=", "===", "!==", "<", ">", "<=", ">=", "[",
238
+ ]);
239
+ const CLOSERS = new Set(["}", "]", ")"]);
240
+ /**
241
+ * The most values one result may expand to. A config is someone else's code: shared consts
242
+ * (`const s1 = { items: [s0, s0] }`, eighteen levels) are cheap here, where a reference is
243
+ * reused, and exponential for every caller that walks the result (184 MB of nav from a
244
+ * 1 KB config), and spreads (`[...a, ...a]`) copy for real.
245
+ */
246
+ const MAX_VALUES = 100_000;
247
+ class Evaluator {
248
+ toks;
249
+ bindings = new Map();
250
+ /** Each array or object built here → the values it expands to, itself included. */
251
+ sizes = new WeakMap();
252
+ unresolved = [];
253
+ sizeOf(v) {
254
+ return v !== null && typeof v === "object" ? (this.sizes.get(v) ?? 1) : 1;
255
+ }
256
+ /** `out` when it expands to at most MAX_VALUES values, else UNRESOLVED and a report. */
257
+ bounded(out, size, i) {
258
+ if (size > MAX_VALUES) {
259
+ this.report(i, `a value that expands to more than ${MAX_VALUES} entries`);
260
+ return UNRESOLVED;
261
+ }
262
+ this.sizes.set(out, size);
263
+ return out;
264
+ }
265
+ constructor(src) {
266
+ this.toks = tokenize(src);
267
+ this.collectBindings();
268
+ }
269
+ tok(i) {
270
+ return this.toks[i];
271
+ }
272
+ is(i, text) {
273
+ const t = this.toks[i];
274
+ return !!t && t.text === text && (t.kind === "punct" || t.kind === "ident");
275
+ }
276
+ report(i, what) {
277
+ const t = this.toks[i];
278
+ const where = t ? ` (line ${t.line})` : "";
279
+ const msg = `${what}${where}`;
280
+ if (!this.unresolved.includes(msg))
281
+ this.unresolved.push(msg);
282
+ }
283
+ /**
284
+ * Skip a balanced expression starting at `i`, stopping at a top-level `,`
285
+ * or a closer. Returns the index just past it. This is what keeps an
286
+ * unreadable expression from desyncing the enclosing object.
287
+ */
288
+ /**
289
+ * Skip `i` forward by at least one token.
290
+ *
291
+ * `skipValue` deliberately returns `i` UNCHANGED when it is already sitting
292
+ * on a closer, so every caller that loops must force progress or it spins
293
+ * forever. A computed key like `{ [base + "/"]: [...] }` leaves `k` on the
294
+ * `]` and hung the whole import with no output — a config that never
295
+ * returns is strictly worse than one that fails.
296
+ */
297
+ advance(i) {
298
+ const next = this.skipValue(i);
299
+ return next === i ? i + 1 : next;
300
+ }
301
+ skipValue(i) {
302
+ let depth = 0;
303
+ while (i < this.toks.length) {
304
+ const t = this.toks[i];
305
+ if (depth === 0 && t.kind === "punct" && (t.text === "," || CLOSERS.has(t.text))) {
306
+ return i;
307
+ }
308
+ if (t.kind === "punct" && OPENERS[t.text])
309
+ depth++;
310
+ else if (t.kind === "punct" && CLOSERS.has(t.text))
311
+ depth--;
312
+ i++;
313
+ }
314
+ return i;
315
+ }
316
+ /** Skip a TS type annotation from `:` until `=`, `=>`, `,` or a closer. */
317
+ /**
318
+ * Skip a balanced `<…>` type-argument list, returning the index just past
319
+ * `>`. `skipTypeAnnotation` cannot do this: it only stops at depth 0, so
320
+ * after `>` closed the generics it kept going into the argument list and
321
+ * swallowed the whole call — losing the entire config of a site using
322
+ * VitePress's own documented `defineConfigWithTheme<ThemeConfig>({…})`.
323
+ */
324
+ skipAngles(i) {
325
+ if (!this.is(i, "<"))
326
+ return i;
327
+ let depth = 0;
328
+ while (i < this.toks.length) {
329
+ const t = this.toks[i];
330
+ if (t.kind === "punct" && t.text === "<")
331
+ depth++;
332
+ else if (t.kind === "punct" && t.text === ">") {
333
+ depth--;
334
+ if (depth === 0)
335
+ return i + 1;
336
+ }
337
+ else if (t.kind === "punct" && (t.text === "(" || t.text === "{")) {
338
+ // A `(`/`{` inside what we assumed were generics means it was not a
339
+ // type-argument list at all. Bail rather than run away.
340
+ return i;
341
+ }
342
+ i++;
343
+ }
344
+ return i;
345
+ }
346
+ /**
347
+ * From a `:` type annotation on a BINDING, find its `=`.
348
+ *
349
+ * A function type contains `=>` (`const s: () => Item[] = () => [...]`), and
350
+ * `skipTypeAnnotation` stops there — so the binding was never recorded and
351
+ * the sidebar it held was lost. `=>` is one token, so scanning for an exact
352
+ * `=` at depth 0 skips it naturally.
353
+ */
354
+ skipToAssign(i) {
355
+ let depth = 0;
356
+ while (i < this.toks.length) {
357
+ const t = this.toks[i];
358
+ if (depth === 0 && t.kind === "punct" && t.text === "=")
359
+ return i;
360
+ if (t.kind === "punct" && (OPENERS[t.text] || t.text === "<"))
361
+ depth++;
362
+ else if (t.kind === "punct" && (CLOSERS.has(t.text) || t.text === ">")) {
363
+ if (depth === 0)
364
+ return i;
365
+ depth--;
366
+ }
367
+ i++;
368
+ }
369
+ return i;
370
+ }
371
+ skipTypeAnnotation(i) {
372
+ let depth = 0;
373
+ while (i < this.toks.length) {
374
+ const t = this.toks[i];
375
+ if (depth === 0 && t.kind === "punct" && (t.text === "=" || t.text === "=>"))
376
+ return i;
377
+ if (depth === 0 && t.kind === "punct" && (t.text === "," || CLOSERS.has(t.text)))
378
+ return i;
379
+ if (t.kind === "punct" && (OPENERS[t.text] || t.text === "<"))
380
+ depth++;
381
+ else if (t.kind === "punct" && (CLOSERS.has(t.text) || t.text === ">"))
382
+ depth--;
383
+ i++;
384
+ }
385
+ return i;
386
+ }
387
+ /** Index the module's top-level `const`/`function`/`export default`. */
388
+ collectBindings() {
389
+ let depth = 0;
390
+ for (let i = 0; i < this.toks.length; i++) {
391
+ const t = this.toks[i];
392
+ if (t.kind === "punct" && OPENERS[t.text]) {
393
+ depth++;
394
+ continue;
395
+ }
396
+ if (t.kind === "punct" && CLOSERS.has(t.text)) {
397
+ depth--;
398
+ continue;
399
+ }
400
+ if (depth !== 0)
401
+ continue;
402
+ if (t.kind !== "ident")
403
+ continue;
404
+ // `export default <expr>` — also the wrapped `export default defineConfig({…})`.
405
+ if (t.text === "export" && this.is(i + 1, "default")) {
406
+ this.bindings.set("default", { at: i + 2 });
407
+ continue;
408
+ }
409
+ // Skip the `export` modifier so `export const x = …` is indexed too.
410
+ const j = t.text === "export" ? i + 1 : i;
411
+ const kw = this.tok(j);
412
+ if (!kw || kw.kind !== "ident")
413
+ continue;
414
+ if (kw.text === "const" || kw.text === "let" || kw.text === "var") {
415
+ const name = this.tok(j + 1);
416
+ if (!name || name.kind !== "ident")
417
+ continue;
418
+ let k = j + 2;
419
+ if (this.is(k, ":"))
420
+ k = this.skipToAssign(k + 1);
421
+ if (!this.is(k, "="))
422
+ continue;
423
+ this.bindings.set(name.text, { at: k + 1 });
424
+ continue;
425
+ }
426
+ if (kw.text === "function") {
427
+ // `function f() { … return <expr> … }`
428
+ let k = j + 1;
429
+ if (this.is(k, "*"))
430
+ k++;
431
+ const name = this.tok(k);
432
+ if (!name || name.kind !== "ident")
433
+ continue;
434
+ const bodyStart = this.findFunctionBody(k + 1);
435
+ if (bodyStart < 0)
436
+ continue;
437
+ const ret = this.findReturn(bodyStart);
438
+ if (ret >= 0)
439
+ this.bindings.set(name.text, { at: ret });
440
+ }
441
+ }
442
+ }
443
+ /** From after a function name, find the index just past `{` of its body. */
444
+ findFunctionBody(i) {
445
+ // params
446
+ if (!this.is(i, "(")) {
447
+ // Possibly a generic `<T>` first.
448
+ if (this.is(i, "<"))
449
+ i = this.skipTypeAnnotation(i);
450
+ if (!this.is(i, "("))
451
+ return -1;
452
+ }
453
+ i = this.skipBalanced(i);
454
+ if (this.is(i, ":"))
455
+ i = this.skipTypeAnnotation(i + 1);
456
+ if (!this.is(i, "{"))
457
+ return -1;
458
+ return i + 1;
459
+ }
460
+ /** Index just past the group opened at `i`. */
461
+ skipBalanced(i) {
462
+ if (!this.toks[i] || !OPENERS[this.toks[i].text])
463
+ return i;
464
+ let depth = 0;
465
+ while (i < this.toks.length) {
466
+ const t = this.toks[i];
467
+ if (t.kind === "punct" && OPENERS[t.text])
468
+ depth++;
469
+ else if (t.kind === "punct" && CLOSERS.has(t.text)) {
470
+ depth--;
471
+ if (depth === 0)
472
+ return i + 1;
473
+ }
474
+ i++;
475
+ }
476
+ return i;
477
+ }
478
+ /** First `return` at body depth 0, from `i` (already inside the body). */
479
+ findReturn(i) {
480
+ let depth = 0;
481
+ while (i < this.toks.length) {
482
+ const t = this.toks[i];
483
+ if (t.kind === "punct" && OPENERS[t.text])
484
+ depth++;
485
+ else if (t.kind === "punct" && CLOSERS.has(t.text)) {
486
+ if (depth === 0)
487
+ return -1; // end of function body
488
+ depth--;
489
+ }
490
+ else if (depth === 0 && t.kind === "ident" && t.text === "return") {
491
+ return i + 1;
492
+ }
493
+ i++;
494
+ }
495
+ return -1;
496
+ }
497
+ /** Evaluate a named module binding (memoized, cycle-safe). */
498
+ resolveBinding(name) {
499
+ const b = this.bindings.get(name);
500
+ if (!b)
501
+ return UNRESOLVED;
502
+ if (b.computing)
503
+ return UNRESOLVED;
504
+ if (b.value !== undefined)
505
+ return b.value;
506
+ b.computing = true;
507
+ const v = this.parseValue(b.at).value;
508
+ b.computing = false;
509
+ b.value = v;
510
+ return v;
511
+ }
512
+ hasBinding(name) {
513
+ return this.bindings.has(name);
514
+ }
515
+ /**
516
+ * The default export, with one extra allowance: VitePress accepts a config
517
+ * that is a FUNCTION of the build context (`export default ({ mode }) => ({…})`
518
+ * — vitest's shape). We cannot supply that context, so we read the function's
519
+ * return expression directly.
520
+ *
521
+ * This is deliberately scoped to the default export. Treating any
522
+ * parameterized arrow as its own body would evaluate `.map(x => …)`
523
+ * callbacks as data, which is how a wrong value gets into a sidebar.
524
+ */
525
+ resolveDefaultExport() {
526
+ const b = this.bindings.get("default");
527
+ if (!b)
528
+ return UNRESOLVED;
529
+ let k = b.at;
530
+ if (this.is(k, "async"))
531
+ k++;
532
+ if (this.is(k, "function")) {
533
+ let j = k + 1;
534
+ if (this.toks[j]?.kind === "ident")
535
+ j++;
536
+ const body = this.findFunctionBody(j);
537
+ const ret = body < 0 ? -1 : this.findReturn(body);
538
+ return ret < 0 ? UNRESOLVED : this.parseValue(ret).value;
539
+ }
540
+ if (this.is(k, "(")) {
541
+ let j = this.skipBalanced(k);
542
+ if (this.is(j, ":"))
543
+ j = this.skipTypeAnnotation(j + 1);
544
+ if (this.is(j, "=>")) {
545
+ const body = j + 1;
546
+ if (this.is(body, "{")) {
547
+ const ret = this.findReturn(body + 1);
548
+ return ret < 0 ? UNRESOLVED : this.parseValue(ret).value;
549
+ }
550
+ return this.parseValue(body).value;
551
+ }
552
+ }
553
+ return this.resolveBinding("default");
554
+ }
555
+ /** Parse the expression at `i`. Returns its value and the next index. */
556
+ parseValue(i) {
557
+ const t = this.toks[i];
558
+ if (!t)
559
+ return { value: UNRESOLVED, next: i };
560
+ // Unary minus on a number.
561
+ if (t.kind === "punct" && t.text === "-" && this.toks[i + 1]?.kind === "num") {
562
+ return this.postfix(-Number(this.toks[i + 1].text), i + 2);
563
+ }
564
+ if (t.kind === "str")
565
+ return this.postfix(t.text, i + 1);
566
+ if (t.kind === "num")
567
+ return this.postfix(Number(t.text), i + 1);
568
+ if (t.kind === "template") {
569
+ if (t.pure)
570
+ return this.postfix(t.text, i + 1);
571
+ this.report(i, "template literal with interpolation");
572
+ return { value: UNRESOLVED, next: this.skipValue(i) };
573
+ }
574
+ if (t.kind === "ident") {
575
+ if (t.text === "true")
576
+ return this.postfix(true, i + 1);
577
+ if (t.text === "false")
578
+ return this.postfix(false, i + 1);
579
+ if (t.text === "null")
580
+ return this.postfix(null, i + 1);
581
+ if (t.text === "undefined")
582
+ return this.postfix(UNRESOLVED, i + 1);
583
+ // Arrow with no params: `() => <expr>` is handled at `(`; a single
584
+ // bare param `x => …` is a function value we cannot call — unresolved.
585
+ if (this.is(i + 1, "=>")) {
586
+ this.report(i, `arrow function \`${t.text} => …\``);
587
+ return { value: UNRESOLVED, next: this.skipValue(i) };
588
+ }
589
+ // Call expression.
590
+ if (this.is(i + 1, "(") || this.is(i + 1, "<")) {
591
+ return this.parseCall(t.text, i);
592
+ }
593
+ // Identifier reference.
594
+ if (this.bindings.has(t.text)) {
595
+ return this.postfix(this.resolveBinding(t.text), i + 1);
596
+ }
597
+ // An imported or otherwise unknown identifier.
598
+ this.report(i, `identifier \`${t.text}\` (imported or computed)`);
599
+ return { value: UNRESOLVED, next: this.skipValue(i) };
600
+ }
601
+ if (t.kind === "punct") {
602
+ if (t.text === "[")
603
+ return this.parseArray(i);
604
+ if (t.text === "{")
605
+ return this.parseObject(i);
606
+ if (t.text === "(")
607
+ return this.parseParenOrArrow(i);
608
+ }
609
+ this.report(i, `unsupported expression starting with \`${t.text}\``);
610
+ return { value: UNRESOLVED, next: this.skipValue(i) };
611
+ }
612
+ /**
613
+ * `(…)` is either a parenthesized expression, or the parameter list of an
614
+ * arrow function. A ZERO-ARG arrow is callable by this evaluator, so it is
615
+ * treated as its own body: `const f = () => [ … ]` binds `f` to the array.
616
+ */
617
+ parseParenOrArrow(i) {
618
+ const afterParens = this.skipBalanced(i);
619
+ let k = afterParens;
620
+ if (this.is(k, ":"))
621
+ k = this.skipTypeAnnotation(k + 1);
622
+ if (this.is(k, "=>")) {
623
+ const zeroArg = this.is(i + 1, ")");
624
+ if (!zeroArg) {
625
+ this.report(i, "arrow function with parameters");
626
+ return { value: UNRESOLVED, next: this.skipValue(i) };
627
+ }
628
+ let body = k + 1;
629
+ if (this.is(body, "{")) {
630
+ const ret = this.findReturn(body + 1);
631
+ if (ret < 0) {
632
+ this.report(i, "arrow function body with no literal `return`");
633
+ return { value: UNRESOLVED, next: this.skipValue(i) };
634
+ }
635
+ const v = this.parseValue(ret).value;
636
+ return { value: v, next: this.skipBalanced(body) };
637
+ }
638
+ const { value } = this.parseValue(body);
639
+ return { value, next: this.skipValue(body) };
640
+ }
641
+ // Plain parenthesized expression.
642
+ const inner = this.parseValue(i + 1);
643
+ return this.postfix(inner.value, afterParens);
644
+ }
645
+ /**
646
+ * A call. Two shapes are readable:
647
+ * - zero args on a module-level function/arrow → its literal body
648
+ * (hono: `sidebar: { '/': sidebars() }`)
649
+ * - one literal argument on an unknown function → the argument
650
+ * (`defineConfig({…})`, `defineConfigWithTheme({…})`, `withMermaid({…})`)
651
+ */
652
+ parseCall(name, i) {
653
+ let open = i + 1;
654
+ if (this.is(open, "<"))
655
+ open = this.skipAngles(open); // generic type args
656
+ if (!this.is(open, "(")) {
657
+ this.report(i, `call to \`${name}\``);
658
+ return { value: UNRESOLVED, next: this.skipValue(i) };
659
+ }
660
+ const after = this.skipBalanced(open);
661
+ // Zero args → the callee's own literal body.
662
+ if (this.is(open + 1, ")")) {
663
+ if (this.bindings.has(name)) {
664
+ return this.postfix(this.resolveBinding(name), after);
665
+ }
666
+ this.report(i, `call to unknown function \`${name}()\``);
667
+ return { value: UNRESOLVED, next: after };
668
+ }
669
+ // A single-argument call on an unknown function is unwrapped ONLY in the
670
+ // config-wrapper shape: the argument is an object literal, or another call
671
+ // (wrappers nest — vitest's export is
672
+ // `withPwa(extendConfig(defineConfig({…})))`).
673
+ //
674
+ // It must NOT unwrap a single ARRAY argument. `vitepress-sidebar`, the most
675
+ // common sidebar plugin, is called `generateSidebar([{…}, {…}])` — and
676
+ // returning its options array as the sidebar produced a complete,
677
+ // plausible-looking nav made of plugin CONFIG, reported as fully resolved.
678
+ // A wrong sidebar that declares itself correct is the worst output this
679
+ // module can produce.
680
+ const arg = this.toks[open + 1];
681
+ const isObjectLiteral = arg?.kind === "punct" && arg.text === "{";
682
+ const isNestedCall = arg?.kind === "ident" && (this.is(open + 2, "(") || this.is(open + 2, "<"));
683
+ if (isObjectLiteral || isNestedCall) {
684
+ const { value, next } = this.parseValue(open + 1);
685
+ if (this.is(next, ")"))
686
+ return this.postfix(value, after);
687
+ }
688
+ this.report(i, `call to \`${name}(…)\``);
689
+ return { value: UNRESOLVED, next: after };
690
+ }
691
+ parseArray(i) {
692
+ const out = [];
693
+ let size = 1;
694
+ let k = i + 1;
695
+ while (k < this.toks.length && !this.is(k, "]")) {
696
+ if (this.is(k, ",")) {
697
+ k++;
698
+ continue;
699
+ }
700
+ if (this.is(k, "...")) {
701
+ const { value, next } = this.parseValue(k + 1);
702
+ // One at a time, and only while within budget: `push(...value)` overflowed the stack
703
+ if (Array.isArray(value)) {
704
+ for (const v of value) {
705
+ if (size > MAX_VALUES)
706
+ break;
707
+ out.push(v);
708
+ size += this.sizeOf(v);
709
+ }
710
+ }
711
+ else if (value !== UNRESOLVED)
712
+ this.report(k, "spread of a non-array");
713
+ k = next;
714
+ continue;
715
+ }
716
+ const { value, next } = this.parseValue(k);
717
+ out.push(value);
718
+ size += this.sizeOf(value);
719
+ k = next === k ? k + 1 : next;
720
+ }
721
+ return this.postfix(this.bounded(out, size, i), k + 1);
722
+ }
723
+ parseObject(i) {
724
+ const out = {};
725
+ let k = i + 1;
726
+ while (k < this.toks.length && !this.is(k, "}")) {
727
+ if (this.is(k, ",") || this.is(k, ";")) {
728
+ k++;
729
+ continue;
730
+ }
731
+ if (this.is(k, "...")) {
732
+ const { value, next } = this.parseValue(k + 1);
733
+ // `typeof x === "object"` already excludes the UNRESOLVED symbol.
734
+ if (value && typeof value === "object" && !Array.isArray(value)) {
735
+ Object.assign(out, value);
736
+ }
737
+ else if (value !== UNRESOLVED) {
738
+ this.report(k, "spread of a non-object");
739
+ }
740
+ k = next;
741
+ continue;
742
+ }
743
+ // Key.
744
+ const kt = this.toks[k];
745
+ if (!kt)
746
+ break;
747
+ let key = null;
748
+ if (kt.kind === "str" || kt.kind === "template")
749
+ key = kt.text;
750
+ else if (kt.kind === "ident" || kt.kind === "num")
751
+ key = kt.text;
752
+ else if (kt.kind === "punct" && kt.text === "[") {
753
+ // Computed key — read it if it is a literal, else skip the entry.
754
+ const { value, next } = this.parseValue(k + 1);
755
+ k = next;
756
+ if (this.is(k, "]"))
757
+ k++;
758
+ key = typeof value === "string" || typeof value === "number" ? String(value) : null;
759
+ if (key === null) {
760
+ this.report(kt.start, "computed object key");
761
+ k = this.is(k, ":") ? this.advance(k + 1) : this.advance(k);
762
+ continue;
763
+ }
764
+ if (this.is(k, ":")) {
765
+ const v = this.parseValue(k + 1);
766
+ out[key] = v.value;
767
+ k = v.next;
768
+ }
769
+ continue;
770
+ }
771
+ else {
772
+ // Something unexpected in key position — skip one entry.
773
+ k = this.advance(k);
774
+ continue;
775
+ }
776
+ k++;
777
+ // Method shorthand `name() { … }` — a function, not data.
778
+ if (this.is(k, "(")) {
779
+ const bodyStart = this.findFunctionBody(k);
780
+ k = bodyStart < 0 ? this.skipValue(k) : this.skipBalanced(bodyStart - 1);
781
+ continue;
782
+ }
783
+ if (!this.is(k, ":")) {
784
+ // Shorthand `{ foo }`.
785
+ out[key] = this.bindings.has(key) ? this.resolveBinding(key) : UNRESOLVED;
786
+ if (!this.bindings.has(key))
787
+ this.report(k, `shorthand property \`${key}\``);
788
+ continue;
789
+ }
790
+ const v = this.parseValue(k + 1);
791
+ out[key] = v.value;
792
+ k = v.next === k + 1 ? this.skipValue(k + 1) : v.next;
793
+ }
794
+ let size = 1;
795
+ for (const v of Object.values(out))
796
+ size += this.sizeOf(v);
797
+ return this.postfix(this.bounded(out, size, i), k + 1);
798
+ }
799
+ /**
800
+ * Postfix operators after a value: TS `as`/`satisfies`, and member access
801
+ * into a value we already have.
802
+ */
803
+ postfix(value, i) {
804
+ let v = value;
805
+ let k = i;
806
+ for (;;) {
807
+ if (this.is(k, "as") || this.is(k, "satisfies")) {
808
+ k = this.skipTypeAnnotation(k + 1);
809
+ continue;
810
+ }
811
+ if (this.is(k, "!")) {
812
+ k++;
813
+ continue;
814
+ }
815
+ if ((this.is(k, ".") || this.is(k, "?.")) && this.toks[k + 1]?.kind === "ident") {
816
+ const prop = this.toks[k + 1].text;
817
+ const obj = v && typeof v === "object" && !Array.isArray(v)
818
+ ? v
819
+ : undefined;
820
+ if (!obj || obj[prop] === undefined) {
821
+ // Includes every METHOD call — `.map(…)`, `.concat(…)`. Reporting
822
+ // matters: `config.ts` drops UNRESOLVED leaves, so an unreported
823
+ // one vanishes from the config AND from the "could not read" list,
824
+ // which is precisely the silent omission this module forbids.
825
+ this.report(k, `property access \`.${prop}\` on a value this reader could not resolve`);
826
+ v = UNRESOLVED;
827
+ }
828
+ else {
829
+ v = obj[prop];
830
+ }
831
+ k += 2;
832
+ continue;
833
+ }
834
+ break;
835
+ }
836
+ // An operator after the value means the real expression is a computation
837
+ // (`base + '/a'`, `isProd ? a : b`, `a || fallback`, `nav[0].items`).
838
+ // Returning just the LEFT operand handed back a confident, wrong answer —
839
+ // `link: base + '/a'` became `link: "/guide"`, and `sidebar: nav[0].items`
840
+ // became the whole `nav` array — with nothing recorded as unresolved.
841
+ if (OPERATORS.has(this.toks[k]?.text ?? "") && this.toks[k]?.kind === "punct") {
842
+ this.report(k, `expression using \`${this.toks[k].text}\` (computed, not a literal)`);
843
+ return { value: UNRESOLVED, next: this.skipValue(k) };
844
+ }
845
+ return { value: v, next: k };
846
+ }
847
+ }
848
+ /**
849
+ * Evaluate a module's default export as far as the literal subset allows.
850
+ *
851
+ * Falls back to the binding named by `fallbackExport` (default `config`) when
852
+ * there is no `export default`, which covers
853
+ * `const config = { … }; export { config }`.
854
+ */
855
+ export function evaluateModule(source, fallbackExport = "config") {
856
+ const ev = new Evaluator(source);
857
+ let value = UNRESOLVED;
858
+ if (ev.hasBinding("default"))
859
+ value = ev.resolveDefaultExport();
860
+ if (value === UNRESOLVED && ev.hasBinding(fallbackExport)) {
861
+ value = ev.resolveBinding(fallbackExport);
862
+ }
863
+ return { value, unresolved: ev.unresolved };
864
+ }
865
+ /** Narrowing helper: a plain object with no UNRESOLVED identity. */
866
+ export function asObject(v) {
867
+ if (v && typeof v === "object" && !Array.isArray(v)) {
868
+ return v;
869
+ }
870
+ return undefined;
871
+ }
872
+ export function asArray(v) {
873
+ return Array.isArray(v) ? v : undefined;
874
+ }
875
+ export function asString(v) {
876
+ return typeof v === "string" ? v : undefined;
877
+ }
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.132",
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
  }