@dogsbay/literal 0.2.0-beta.132 → 0.2.0-beta.134

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
@@ -11,11 +11,24 @@ const { value, unresolved } = evaluateModule(source); // the module's default ex
11
11
 
12
12
  It understands object, array, string, number, boolean and null literals; template strings
13
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.
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.
17
18
 
18
- Used by the Dogsbay VitePress importer (`.vitepress/config.*`).
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`).
19
32
 
20
33
  ## License
21
34
 
package/dist/index.d.ts CHANGED
@@ -18,8 +18,13 @@
18
18
  * - zero-arg calls to module-level `function f() { return <literal> }` and
19
19
  * `const f = () => <literal>` (hono's `sidebar: { '/': sidebars() }`)
20
20
  * - wrapper calls with a single literal argument (`defineConfig({…})`)
21
- * - member access into an already-evaluated object (`shared.themeConfig`)
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
22
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"`)
23
28
  *
24
29
  * Anything else evaluates to the `UNRESOLVED` sentinel — never to a plausible
25
30
  * wrong value and never to a silent omission. Callers report what they could
@@ -40,6 +45,14 @@ export interface EvalResult {
40
45
  /** Human-readable descriptions of expressions that could not be read. */
41
46
  unresolved: string[];
42
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
+ }
43
56
  /**
44
57
  * Evaluate a module's default export as far as the literal subset allows.
45
58
  *
@@ -47,7 +60,7 @@ export interface EvalResult {
47
60
  * there is no `export default`, which covers
48
61
  * `const config = { … }; export { config }`.
49
62
  */
50
- export declare function evaluateModule(source: string, fallbackExport?: string): EvalResult;
63
+ export declare function evaluateModule(source: string, fallbackExport?: string, opts?: EvaluateOptions): EvalResult;
51
64
  /** Narrowing helper: a plain object with no UNRESOLVED identity. */
52
65
  export declare function asObject(v: LiteralValue): Record<string, LiteralValue> | undefined;
53
66
  export declare function asArray(v: LiteralValue): LiteralValue[] | undefined;
package/dist/index.js CHANGED
@@ -18,8 +18,13 @@
18
18
  * - zero-arg calls to module-level `function f() { return <literal> }` and
19
19
  * `const f = () => <literal>` (hono's `sidebar: { '/': sidebars() }`)
20
20
  * - wrapper calls with a single literal argument (`defineConfig({…})`)
21
- * - member access into an already-evaluated object (`shared.themeConfig`)
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
22
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"`)
23
28
  *
24
29
  * Anything else evaluates to the `UNRESOLVED` sentinel — never to a plausible
25
30
  * wrong value and never to a silent omission. Callers report what they could
@@ -244,15 +249,106 @@ const CLOSERS = new Set(["}", "]", ")"]);
244
249
  * 1 KB config), and spreads (`[...a, ...a]`) copy for real.
245
250
  */
246
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
+ }
247
267
  class Evaluator {
268
+ opts;
248
269
  toks;
249
270
  bindings = new Map();
250
271
  /** Each array or object built here → the values it expands to, itself included. */
251
272
  sizes = new WeakMap();
273
+ depth = 0;
252
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. */
253
312
  sizeOf(v) {
313
+ if (typeof v === "string")
314
+ return 1 + (v.length >> 10);
254
315
  return v !== null && typeof v === "object" ? (this.sizes.get(v) ?? 1) : 1;
255
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
+ }
256
352
  /** `out` when it expands to at most MAX_VALUES values, else UNRESOLVED and a report. */
257
353
  bounded(out, size, i) {
258
354
  if (size > MAX_VALUES) {
@@ -262,10 +358,6 @@ class Evaluator {
262
358
  this.sizes.set(out, size);
263
359
  return out;
264
360
  }
265
- constructor(src) {
266
- this.toks = tokenize(src);
267
- this.collectBindings();
268
- }
269
361
  tok(i) {
270
362
  return this.toks[i];
271
363
  }
@@ -406,6 +498,23 @@ class Evaluator {
406
498
  this.bindings.set("default", { at: i + 2 });
407
499
  continue;
408
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
+ }
409
518
  // Skip the `export` modifier so `export const x = …` is indexed too.
410
519
  const j = t.text === "export" ? i + 1 : i;
411
520
  const kw = this.tok(j);
@@ -504,7 +613,7 @@ class Evaluator {
504
613
  if (b.value !== undefined)
505
614
  return b.value;
506
615
  b.computing = true;
507
- const v = this.parseValue(b.at).value;
616
+ const v = b.from !== undefined ? this.required(b.from, b.at) : this.parseValue(b.at).value;
508
617
  b.computing = false;
509
618
  b.value = v;
510
619
  return v;
@@ -554,6 +663,19 @@ class Evaluator {
554
663
  }
555
664
  /** Parse the expression at `i`. Returns its value and the next index. */
556
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) {
557
679
  const t = this.toks[i];
558
680
  if (!t)
559
681
  return { value: UNRESOLVED, next: i };
@@ -658,6 +780,14 @@ class Evaluator {
658
780
  return { value: UNRESOLVED, next: this.skipValue(i) };
659
781
  }
660
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
+ }
661
791
  // Zero args → the callee's own literal body.
662
792
  if (this.is(open + 1, ")")) {
663
793
  if (this.bindings.has(name)) {
@@ -717,6 +847,13 @@ class Evaluator {
717
847
  out.push(value);
718
848
  size += this.sizeOf(value);
719
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
+ }
720
857
  }
721
858
  return this.postfix(this.bounded(out, size, i), k + 1);
722
859
  }
@@ -732,7 +869,8 @@ class Evaluator {
732
869
  const { value, next } = this.parseValue(k + 1);
733
870
  // `typeof x === "object"` already excludes the UNRESOLVED symbol.
734
871
  if (value && typeof value === "object" && !Array.isArray(value)) {
735
- Object.assign(out, value);
872
+ for (const [key, v] of Object.entries(value))
873
+ setKey(out, key, v);
736
874
  }
737
875
  else if (value !== UNRESOLVED) {
738
876
  this.report(k, "spread of a non-object");
@@ -763,7 +901,7 @@ class Evaluator {
763
901
  }
764
902
  if (this.is(k, ":")) {
765
903
  const v = this.parseValue(k + 1);
766
- out[key] = v.value;
904
+ setKey(out, key, v.value);
767
905
  k = v.next;
768
906
  }
769
907
  continue;
@@ -782,14 +920,19 @@ class Evaluator {
782
920
  }
783
921
  if (!this.is(k, ":")) {
784
922
  // Shorthand `{ foo }`.
785
- out[key] = this.bindings.has(key) ? this.resolveBinding(key) : UNRESOLVED;
923
+ setKey(out, key, this.bindings.has(key) ? this.resolveBinding(key) : UNRESOLVED);
786
924
  if (!this.bindings.has(key))
787
925
  this.report(k, `shorthand property \`${key}\``);
788
926
  continue;
789
927
  }
790
928
  const v = this.parseValue(k + 1);
791
- out[key] = v.value;
929
+ setKey(out, key, v.value);
792
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
+ }
793
936
  }
794
937
  let size = 1;
795
938
  for (const v of Object.values(out))
@@ -812,12 +955,36 @@ class Evaluator {
812
955
  k++;
813
956
  continue;
814
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
+ }
815
982
  if ((this.is(k, ".") || this.is(k, "?.")) && this.toks[k + 1]?.kind === "ident") {
816
983
  const prop = this.toks[k + 1].text;
817
984
  const obj = v && typeof v === "object" && !Array.isArray(v)
818
985
  ? v
819
986
  : undefined;
820
- if (!obj || obj[prop] === undefined) {
987
+ if (!obj || !Object.hasOwn(obj, prop)) {
821
988
  // Includes every METHOD call — `.map(…)`, `.concat(…)`. Reporting
822
989
  // matters: `config.ts` drops UNRESOLVED leaves, so an unreported
823
990
  // one vanishes from the config AND from the "could not read" list,
@@ -833,6 +1000,22 @@ class Evaluator {
833
1000
  }
834
1001
  break;
835
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
+ }
836
1019
  // An operator after the value means the real expression is a computation
837
1020
  // (`base + '/a'`, `isProd ? a : b`, `a || fallback`, `nav[0].items`).
838
1021
  // Returning just the LEFT operand handed back a confident, wrong answer —
@@ -852,8 +1035,8 @@ class Evaluator {
852
1035
  * there is no `export default`, which covers
853
1036
  * `const config = { … }; export { config }`.
854
1037
  */
855
- export function evaluateModule(source, fallbackExport = "config") {
856
- const ev = new Evaluator(source);
1038
+ export function evaluateModule(source, fallbackExport = "config", opts = {}) {
1039
+ const ev = new Evaluator(source, opts);
857
1040
  let value = UNRESOLVED;
858
1041
  if (ev.hasBinding("default"))
859
1042
  value = ev.resolveDefaultExport();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/literal",
3
- "version": "0.2.0-beta.132",
3
+ "version": "0.2.0-beta.134",
4
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
5
  "type": "module",
6
6
  "main": "dist/index.js",