@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 +34 -2
- package/dist/index.d.ts +67 -0
- package/dist/index.js +1060 -0
- package/package.json +30 -3
package/README.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dogsbay/literal
|
|
2
2
|
|
|
3
|
-
|
|
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
|
package/dist/index.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
}
|