@zvndev/darkmown 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +61 -6
  4. package/SECURITY.md +4 -4
  5. package/docs/cli.md +8 -0
  6. package/docs/spec-alignment.md +26 -6
  7. package/grammar/wd-directives.gbnf +58 -0
  8. package/package.json +5 -1
  9. package/site/_/footer.wd +1 -1
  10. package/site/_/nav.wd +1 -0
  11. package/site/pages/app.wd +1 -1
  12. package/site/pages/blog/zero-js-by-default.md +1 -1
  13. package/site/pages/docs/index.wd +19 -2
  14. package/site/pages/index.wd +5 -3
  15. package/site/pages/playground/index.js +158 -0
  16. package/site/pages/playground/index.skin +128 -0
  17. package/site/pages/playground/index.wd +51 -0
  18. package/site/pages/showcase/index.wd +1 -1
  19. package/src/builder.js +15 -8
  20. package/src/catalog.js +586 -0
  21. package/src/cli.js +8 -0
  22. package/src/compiler/actions.js +45 -12
  23. package/src/compiler/collections.js +16 -11
  24. package/src/compiler/context.js +52 -3
  25. package/src/compiler/expr-ast.js +296 -0
  26. package/src/compiler/fetch.js +12 -4
  27. package/src/compiler/forms.js +78 -24
  28. package/src/compiler/fs-reader.js +25 -0
  29. package/src/compiler/includes.js +20 -15
  30. package/src/compiler/index.js +1 -1
  31. package/src/compiler/loops.js +26 -8
  32. package/src/compiler/media.js +21 -7
  33. package/src/compiler/page.js +68 -15
  34. package/src/compiler/predicates.js +29 -18
  35. package/src/compiler/reader.js +60 -0
  36. package/src/compiler/state.js +81 -32
  37. package/src/compiler/structure.js +48 -17
  38. package/src/compiler.js +57 -6
  39. package/src/grammar.js +131 -0
  40. package/src/headers.js +15 -13
  41. package/src/index.js +2 -0
  42. package/src/runtime.js +77 -45
  43. package/src/statics.js +6 -7
  44. package/types/compiler.d.ts +13 -0
  45. package/types/index.d.ts +1 -0
@@ -9,7 +9,7 @@
9
9
  // while keeping the message text and `Use:` hints intact.
10
10
  // ---------------------------------------------------------------------------
11
11
 
12
- import { at } from "./context.js";
12
+ import { at, lineOf, wdError } from "./context.js";
13
13
  import {
14
14
  escapeHtml,
15
15
  resolveStateKey,
@@ -23,6 +23,10 @@ import {
23
23
  * @typedef {import("./context.js").Action} Action
24
24
  */
25
25
 
26
+ // A concrete, compilable :button line for the malformed hint + directive catalog.
27
+ export const BUTTON_EXAMPLE = ':button "Add one" -> count++';
28
+ const BUTTON_USE = `Use: :button "Label" -> action — e.g. ${BUTTON_EXAMPLE}`;
29
+
26
30
  /**
27
31
  * @param {string} line
28
32
  * @param {Ctx} ctx
@@ -31,7 +35,13 @@ import {
31
35
  */
32
36
  export function handleButton(line, ctx, index) {
33
37
  const match = line.match(/^:button\s+"([^"]+)"\s*->\s*(.+)$/);
34
- if (!match) throw new Error(`Malformed :button in ${at(ctx, index)}: ${line}`);
38
+ if (!match)
39
+ throw wdError(`Malformed :button in ${at(ctx, index)}: ${line}. ${BUTTON_USE}`, {
40
+ file: ctx.file,
41
+ line: lineOf(ctx, index),
42
+ hint: BUTTON_USE.slice("Use: ".length),
43
+ example: BUTTON_EXAMPLE
44
+ });
35
45
  ctx.comp.assets.runtime = true;
36
46
  const action = parseAction(match[2], ctx);
37
47
  if (Array.isArray(action)) {
@@ -44,8 +54,11 @@ export function handleButton(line, ctx, index) {
44
54
  return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
45
55
  }
46
56
 
47
- const EFFECT_USE =
48
- "Use: :effect watchedState -> action[; action…] (actions use the :button vocabulary).";
57
+ // Concrete, compilable examples for the effect / every hint tails + catalog.
58
+ // (Effects run the :button action vocabulary — mutations, not expressions; use
59
+ // :computed for derived values. `searches++` is the canonical watch-and-act.)
60
+ export const EFFECT_EXAMPLE = ":effect query -> searches++";
61
+ const EFFECT_USE = `Use: :effect watchedState -> action[; action…] (actions use the :button vocabulary) — e.g. ${EFFECT_EXAMPLE}`;
49
62
 
50
63
  /**
51
64
  * Parse `:effect <watched> -> <actions>` into a zero-output marker the runtime
@@ -59,7 +72,13 @@ const EFFECT_USE =
59
72
  */
60
73
  export function handleEffect(line, ctx, index) {
61
74
  const match = line.match(/^:effect\s+([A-Za-z_$][\w$.]*)\s*->\s*(.+)$/);
62
- if (!match) throw new Error(`Malformed :effect in ${at(ctx, index)}: ${line}. ${EFFECT_USE}`);
75
+ if (!match)
76
+ throw wdError(`Malformed :effect in ${at(ctx, index)}: ${line}. ${EFFECT_USE}`, {
77
+ file: ctx.file,
78
+ line: lineOf(ctx, index),
79
+ hint: EFFECT_USE.slice("Use: ".length),
80
+ example: EFFECT_EXAMPLE
81
+ });
63
82
  const segs = validatePath(match[1], ctx, EFFECT_USE);
64
83
  const key = resolveStateKey(segs[0], ctx);
65
84
  if (!key)
@@ -73,8 +92,8 @@ export function handleEffect(line, ctx, index) {
73
92
  return `<script type="application/json" data-wd-effect>${safeScriptJson({ watch, actions })}</script>`;
74
93
  }
75
94
 
76
- const EVERY_USE =
77
- "Use: :every <duration> -> action[; action…] — duration like 5s, 500ms, or 2m (actions use the :button vocabulary).";
95
+ export const EVERY_EXAMPLE = ":every 5s -> seconds++";
96
+ const EVERY_USE = `Use: :every <duration> -> action[; action…] — duration like 5s, 500ms, or 2m (actions use the :button vocabulary) — e.g. ${EVERY_EXAMPLE}`;
78
97
 
79
98
  /**
80
99
  * Parse a duration token into milliseconds: `<int>[ms|s|m]`, defaulting to ms.
@@ -101,7 +120,13 @@ function parseDuration(raw) {
101
120
  */
102
121
  export function handleEvery(line, ctx, index) {
103
122
  const match = line.match(/^:every\s+(\S+)\s*->\s*(.+)$/);
104
- if (!match) throw new Error(`Malformed :every in ${at(ctx, index)}: ${line}. ${EVERY_USE}`);
123
+ if (!match)
124
+ throw wdError(`Malformed :every in ${at(ctx, index)}: ${line}. ${EVERY_USE}`, {
125
+ file: ctx.file,
126
+ line: lineOf(ctx, index),
127
+ hint: EVERY_USE.slice("Use: ".length),
128
+ example: EVERY_EXAMPLE
129
+ });
105
130
  const ms = parseDuration(match[1]);
106
131
  if (ms == null || ms <= 0) {
107
132
  throw new Error(
@@ -114,8 +139,11 @@ export function handleEvery(line, ctx, index) {
114
139
  return `<script type="application/json" data-wd-every>${safeScriptJson({ ms, actions })}</script>`;
115
140
  }
116
141
 
117
- const ACTION_USE =
118
- 'Use: name++, name--, n += k, n -= k, name = v, flag toggle, list append/prepend v, list toggle v, list remove v, x clear, obj merge other, obj delete key, name reset — chain with ";".';
142
+ // One concrete action expression, appended so a model copies `count++` rather
143
+ // than the schematic `name++`. The action vocabulary the directive catalog draws
144
+ // from lives here (the parser branches below), mirrored compactly in ACTION_USE.
145
+ export const ACTION_EXAMPLE = "count++";
146
+ export const ACTION_USE = `Use: name++, name--, n += k, n -= k, name = v, flag toggle, list append/prepend v, list toggle v, list remove v, x clear, obj merge other, obj delete key, name reset — chain with ";" — e.g. ${ACTION_EXAMPLE}`;
119
147
 
120
148
  /**
121
149
  * Parse a `:button` action expression into a validated `{ op, target, value }`,
@@ -271,8 +299,13 @@ function parseMergeOperand(raw, ctx) {
271
299
  }
272
300
  const parsed = parseActionLiteral(value, ctx);
273
301
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
274
- throw new Error(
275
- `Unsupported merge operand "${raw}" in ${ctx.file}. Use: obj merge other (a state key or an inline {…} object).`
302
+ throw wdError(
303
+ `Unsupported merge operand "${raw}" in ${ctx.file}. Use: obj merge other (a state key or an inline {…} object) — e.g. settings merge patch`,
304
+ {
305
+ file: ctx.file,
306
+ hint: "obj merge other (a state key or an inline {…} object) — e.g. settings merge patch",
307
+ example: "settings merge patch"
308
+ }
276
309
  );
277
310
  }
278
311
 
@@ -15,10 +15,10 @@
15
15
  // `scoped`/`draft`).
16
16
  // ---------------------------------------------------------------------------
17
17
 
18
- import fs from "node:fs";
19
18
  import path from "node:path";
20
19
  import { firstParagraph } from "../feeds.js";
21
20
  import { parseFrontmatter } from "./frontmatter.js";
21
+ import { fsReader } from "./fs-reader.js";
22
22
 
23
23
  /**
24
24
  * @typedef {import("./context.js").Paths} Paths
@@ -47,20 +47,22 @@ const SCHEMA_TYPES = new Set(["string", "number", "boolean", "date", "string[]"]
47
47
  *
48
48
  * @param {Route[]} routes Post-draft-filter routes from `discoverRoutes`.
49
49
  * @param {Paths} paths Resolved project paths (`routesRoot` for `_schema.wd`).
50
+ * @param {import("./reader.js").Reader} [reader] Source reader (fs-backed by
51
+ * default; pass an in-memory reader to build collections without a filesystem).
50
52
  * @returns {Map<string, CollectionRow[]>} Collection name → entry rows.
51
53
  */
52
- export function buildCollections(routes, paths) {
54
+ export function buildCollections(routes, paths, reader = fsReader()) {
53
55
  /** @type {Map<string, CollectionRow[]>} */
54
56
  const collections = new Map();
55
57
  for (const route of routes) {
56
58
  const name = collectionNameOf(route.route);
57
59
  if (!name) continue;
58
60
  const rows = collections.get(name) || [];
59
- rows.push(rowFor(route));
61
+ rows.push(rowFor(route, reader));
60
62
  collections.set(name, rows);
61
63
  }
62
64
  for (const [name, rows] of collections) {
63
- const schema = readSchema(name, paths);
65
+ const schema = readSchema(name, paths, reader);
64
66
  if (schema) validateRows(rows, schema, name);
65
67
  }
66
68
  return collections;
@@ -87,9 +89,10 @@ function collectionNameOf(route) {
87
89
  * are added AFTER the spread so a stray frontmatter `url:`/`slug:` can't shadow
88
90
  * the real route data the loop relies on.
89
91
  * @param {Route} route
92
+ * @param {import("./reader.js").Reader} reader Source reader for the excerpt body.
90
93
  * @returns {CollectionRow}
91
94
  */
92
- function rowFor(route) {
95
+ function rowFor(route, reader) {
93
96
  const slug = path.basename(route.file, path.extname(route.file));
94
97
  /** @type {Record<string, unknown>} */
95
98
  const fields = {};
@@ -98,7 +101,7 @@ function rowFor(route) {
98
101
  ...fields,
99
102
  url: route.route,
100
103
  slug: slug === "index" ? path.basename(path.dirname(route.file)) : slug,
101
- excerpt: excerptFor(route)
104
+ excerpt: excerptFor(route, reader)
102
105
  };
103
106
  }
104
107
 
@@ -126,13 +129,14 @@ function coerceScalar(value) {
126
129
  * (plain `.md` only) the first paragraph of its body, else "". Mirrors the RSS
127
130
  * description fallback so a collection card and the feed read the same snippet.
128
131
  * @param {Route} route
132
+ * @param {import("./reader.js").Reader} reader Source reader for the `.md` body.
129
133
  * @returns {string}
130
134
  */
131
- function excerptFor(route) {
135
+ function excerptFor(route, reader) {
132
136
  const fromMeta = route.meta.excerpt;
133
137
  if (typeof fromMeta === "string" && fromMeta.trim()) return fromMeta.trim();
134
138
  if (path.extname(route.file) !== ".md") return "";
135
- const { body } = parseFrontmatter(fs.readFileSync(route.file, "utf8"), route.file);
139
+ const { body } = parseFrontmatter(reader.readText(route.file), route.file);
136
140
  return firstParagraph(body);
137
141
  }
138
142
 
@@ -153,12 +157,13 @@ function excerptFor(route) {
153
157
  * schema file itself, pointing at the offending line.
154
158
  * @param {string} name Collection name.
155
159
  * @param {Paths} paths
160
+ * @param {import("./reader.js").Reader} [reader] Source reader (fs-backed by default).
156
161
  * @returns {SchemaField[] | null}
157
162
  */
158
- export function readSchema(name, paths) {
163
+ export function readSchema(name, paths, reader = fsReader()) {
159
164
  const file = path.join(paths.routesRoot, name, "_schema.wd");
160
- if (!fs.existsSync(file)) return null;
161
- const raw = fs.readFileSync(file, "utf8");
165
+ if (!reader.exists(file)) return null;
166
+ const raw = reader.readText(file);
162
167
  return parseSchema(raw, file);
163
168
  }
164
169
 
@@ -77,6 +77,10 @@
77
77
  * dependency map that drives incremental rebuilds.
78
78
  * @property {Set<string>} collectionsUsed Names of the collections this compile
79
79
  * looped, so a change to any entry of a collection rebuilds its consumers.
80
+ * @property {import("./reader.js").Reader} reader The file reader every source
81
+ * read threads through — the real filesystem by default, or an in-memory map
82
+ * for `compileFromMemory`. Set once by `createCompilation` and reached via
83
+ * `ctx.comp.reader` in the directive handlers (includes, loop data).
80
84
  */
81
85
 
82
86
  /**
@@ -148,6 +152,20 @@
148
152
  * @property {Set<string>} collectionsUsed Collection names this compile looped.
149
153
  */
150
154
 
155
+ /**
156
+ * Structured, machine-readable companion to a compile error's string message.
157
+ * The string `message` contract is unchanged; `wd` gives an AI edit-loop (or an
158
+ * editor) the same facts without re-parsing the prose: which file/line, the
159
+ * corrective `Use:` template, and one concrete compilable example line.
160
+ * @typedef {object} WdErrorInfo
161
+ * @property {string} file Absolute path of the source file the error is in.
162
+ * @property {number} [line] 1-based file line the error points at, when known
163
+ * (some errors — e.g. a bad `where` operand — are file-scoped only, matching
164
+ * the string message, which likewise omits the line there).
165
+ * @property {string} [hint] The corrective `Use:` template (without the `Use: ` prefix).
166
+ * @property {string} [example] One concrete, compilable directive line.
167
+ */
168
+
151
169
  /**
152
170
  * A compiled `@loop … where` predicate.
153
171
  * @typedef {object} Predicate
@@ -202,9 +220,14 @@ export const LOOP_META = {
202
220
  $count: "count"
203
221
  };
204
222
 
205
- /** @returns {Compilation} */
206
- export function createCompilation() {
223
+ /**
224
+ * @param {import("./reader.js").Reader} reader The source reader for this
225
+ * compile (fs-backed by default; in-memory for `compileFromMemory`).
226
+ * @returns {Compilation}
227
+ */
228
+ export function createCompilation(reader) {
207
229
  return {
230
+ reader,
208
231
  assets: {
209
232
  skins: new Set(),
210
233
  scripts: new Set(),
@@ -248,7 +271,33 @@ export function createScope(parent, vars = {}) {
248
271
  * @returns {string}
249
272
  */
250
273
  export function at(ctx, index) {
251
- return `${ctx.file}:${index + 1 + (ctx.bodyLine ?? 0) + (ctx.lineOffset ?? 0)}`;
274
+ return `${ctx.file}:${lineOf(ctx, index)}`;
275
+ }
276
+
277
+ /**
278
+ * The 1-based true file line for a body-slice `index` — the numeric half of
279
+ * {@link at}, exposed so {@link wdError} can populate the structured `wd.line`.
280
+ * @param {Pick<Ctx, "bodyLine" | "lineOffset">} ctx
281
+ * @param {number} index 0-based line index into the current body slice.
282
+ * @returns {number}
283
+ */
284
+ export function lineOf(ctx, index) {
285
+ return index + 1 + (ctx.bodyLine ?? 0) + (ctx.lineOffset ?? 0);
286
+ }
287
+
288
+ /**
289
+ * Build a compile `Error` whose string `message` is unchanged but which also
290
+ * carries a structured {@link WdErrorInfo} on `err.wd` — so an AI edit-loop (or
291
+ * an editor) gets file/line/hint/example without re-parsing the prose. The
292
+ * string contract is the source of truth; `wd` is a strictly additive mirror.
293
+ * @param {string} message The full user-facing error string (unchanged contract).
294
+ * @param {WdErrorInfo} wd The structured mirror.
295
+ * @returns {Error & { wd: WdErrorInfo }}
296
+ */
297
+ export function wdError(message, wd) {
298
+ const err = /** @type {Error & { wd: WdErrorInfo }} */ (new Error(message));
299
+ err.wd = wd;
300
+ return err;
252
301
  }
253
302
 
254
303
  /**
@@ -0,0 +1,296 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Compiled-expression → compact AST.
3
+ //
4
+ // The predicate/computed compilers in `predicates.js` still do ALL the security
5
+ // validation — they map every operand through a fixed grammar to safe JS over the
6
+ // `I()`/`S()`/`C()`/`A()` readers, and reject anything outside the whitelist. This
7
+ // module takes that ALREADY-VALIDATED JS fragment and re-parses it into a compact
8
+ // serialized AST (nested arrays), so the runtime can WALK it with a tiny closed
9
+ // evaluator instead of constructing a `new Function`. That is the whole point:
10
+ // reactive pages no longer need `script-src 'unsafe-eval'`.
11
+ //
12
+ // The parsed sub-language is closed and small: the reader calls `S(k[,r])`,
13
+ // `I(p)`, `C(a,b)`, `A(name,list[,field])`; number / JSON-string / boolean / null
14
+ // / object / array literals; the operators `! - * / % + - < > <= >= == != && ||`;
15
+ // and grouping parens. Precedence and associativity match JavaScript exactly, so
16
+ // the walked AST evaluates identically to the prior `new Function` path. Anything
17
+ // unexpected is a hard parse error — never a silent fallthrough.
18
+ //
19
+ // Node shapes (array with a tag at [0]):
20
+ // ["L", value] literal (number | string | boolean | null | obj | arr)
21
+ // ["S", key] state read → getPath(state[key])
22
+ // ["S", key, rest] dotted state read → getPath(state[key], rest)
23
+ // ["I", path] loop-item read → getPath(item, path)
24
+ // ["C", a, b] contains(a, b)
25
+ // ["A", name, list] aggregate → AGG(name, list)
26
+ // ["A", name, list, fld] aggregate w/ field → AGG(name, list, fld)
27
+ // ["!", a] | ["u-", a] unary not / negate
28
+ // [op, a, b] binary op (op ∈ the operator set above)
29
+ // ---------------------------------------------------------------------------
30
+
31
+ import { FORMATTERS } from "./format.js";
32
+ import { getPath } from "./interpolation.js";
33
+
34
+ // Binary-operator precedence (higher binds tighter), matching JS.
35
+ /** @type {Record<string, number>} */
36
+ const PREC = {
37
+ "||": 1,
38
+ "&&": 2,
39
+ "==": 3,
40
+ "!=": 3,
41
+ "<": 4,
42
+ ">": 4,
43
+ "<=": 4,
44
+ ">=": 4,
45
+ "+": 5,
46
+ "-": 5,
47
+ "*": 6,
48
+ "/": 6,
49
+ "%": 6
50
+ };
51
+
52
+ /**
53
+ * Re-parse a validated compiled expression (JS fragment over I()/S()/C()/A())
54
+ * into a compact AST. Throws on anything outside the closed grammar.
55
+ * @param {string} code
56
+ * @returns {any[]}
57
+ */
58
+ export function astOf(code) {
59
+ let i = 0;
60
+ const n = code.length;
61
+ const ws = () => {
62
+ while (i < n && /\s/.test(code[i])) i++;
63
+ };
64
+ /** Peek a binary operator at the cursor without consuming; null if none. */
65
+ const peekOp = () => {
66
+ ws();
67
+ const two = code.slice(i, i + 2);
68
+ if (
69
+ two === "&&" ||
70
+ two === "||" ||
71
+ two === "==" ||
72
+ two === "!=" ||
73
+ two === "<=" ||
74
+ two === ">="
75
+ )
76
+ return two;
77
+ const one = code[i];
78
+ if (
79
+ one === "<" ||
80
+ one === ">" ||
81
+ one === "+" ||
82
+ one === "-" ||
83
+ one === "*" ||
84
+ one === "/" ||
85
+ one === "%"
86
+ )
87
+ return one;
88
+ return null;
89
+ };
90
+ /** @param {number} minPrec @returns {any[]} */
91
+ const parseExpr = (minPrec) => {
92
+ /** @type {any[]} */
93
+ let left = parseUnary();
94
+ for (;;) {
95
+ const op = peekOp();
96
+ if (op === null) break;
97
+ const prec = PREC[op];
98
+ if (prec < minPrec) break;
99
+ i += op.length;
100
+ const right = parseExpr(prec + 1);
101
+ left = [op, left, right];
102
+ }
103
+ return left;
104
+ };
105
+ /** @returns {any[]} */
106
+ const parseUnary = () => {
107
+ ws();
108
+ if (code[i] === "!" && code[i + 1] !== "=") {
109
+ i++;
110
+ return ["!", parseUnary()];
111
+ }
112
+ if (code[i] === "-") {
113
+ i++;
114
+ return ["u-", parseUnary()];
115
+ }
116
+ return parsePrimary();
117
+ };
118
+ /** @returns {any[]} */
119
+ const parsePrimary = () => {
120
+ ws();
121
+ const c = code[i];
122
+ if (c === "(") {
123
+ i++;
124
+ /** @type {any[]} */
125
+ const e = parseExpr(1);
126
+ ws();
127
+ if (code[i] !== ")") throw new Error(`expr-ast: expected ) at ${i} in ${code}`);
128
+ i++;
129
+ return e;
130
+ }
131
+ if (c === '"') return ["L", readString()];
132
+ if (c === "{" || c === "[") return ["L", readJson()];
133
+ if (c === "." || (c >= "0" && c <= "9")) return ["L", readNumber()];
134
+ if (/[A-Za-z_$]/.test(c)) return readIdent();
135
+ throw new Error(`expr-ast: unexpected "${c ?? "<end>"}" at ${i} in ${code}`);
136
+ };
137
+ const readString = () => {
138
+ let j = i + 1;
139
+ while (j < n) {
140
+ if (code[j] === "\\") j += 2;
141
+ else if (code[j] === '"') break;
142
+ else j++;
143
+ }
144
+ const lit = code.slice(i, j + 1);
145
+ i = j + 1;
146
+ return JSON.parse(lit);
147
+ };
148
+ const readJson = () => {
149
+ let depth = 0;
150
+ let j = i;
151
+ for (; j < n; j++) {
152
+ const ch = code[j];
153
+ if (ch === '"') {
154
+ j++;
155
+ while (j < n && code[j] !== '"') j += code[j] === "\\" ? 2 : 1;
156
+ } else if (ch === "{" || ch === "[") depth++;
157
+ else if (ch === "}" || ch === "]") {
158
+ depth--;
159
+ if (depth === 0) {
160
+ j++;
161
+ break;
162
+ }
163
+ }
164
+ }
165
+ const lit = code.slice(i, j);
166
+ i = j;
167
+ return JSON.parse(lit);
168
+ };
169
+ const readNumber = () => {
170
+ let j = i;
171
+ while (j < n && /[0-9.]/.test(code[j])) j++;
172
+ const num = Number(code.slice(i, j));
173
+ i = j;
174
+ return num;
175
+ };
176
+ /** @returns {any[]} */
177
+ const readIdent = () => {
178
+ let j = i;
179
+ while (j < n && /[\w$]/.test(code[j])) j++;
180
+ const word = code.slice(i, j);
181
+ i = j;
182
+ if (word === "true") return ["L", true];
183
+ if (word === "false") return ["L", false];
184
+ if (word === "null") return ["L", null];
185
+ if (code[i] !== "(") throw new Error(`expr-ast: unexpected identifier "${word}" in ${code}`);
186
+ const args = parseArgs();
187
+ if (word === "S") return args.length > 1 ? ["S", args[0][1], args[1][1]] : ["S", args[0][1]];
188
+ if (word === "I") return ["I", args[0][1]];
189
+ if (word === "C") return ["C", args[0], args[1]];
190
+ if (word === "A")
191
+ return args.length > 2 ? ["A", args[0][1], args[1], args[2][1]] : ["A", args[0][1], args[1]];
192
+ throw new Error(`expr-ast: unknown call "${word}" in ${code}`);
193
+ };
194
+ /** @returns {any[][]} */
195
+ const parseArgs = () => {
196
+ i++; // consume "("
197
+ /** @type {any[][]} */
198
+ const args = [];
199
+ ws();
200
+ if (code[i] !== ")") {
201
+ args.push(parseExpr(1));
202
+ ws();
203
+ while (code[i] === ",") {
204
+ i++;
205
+ args.push(parseExpr(1));
206
+ ws();
207
+ }
208
+ }
209
+ ws();
210
+ if (code[i] !== ")") throw new Error(`expr-ast: expected ) closing call in ${code}`);
211
+ i++;
212
+ return args;
213
+ };
214
+
215
+ const ast = parseExpr(1);
216
+ ws();
217
+ if (i !== n) throw new Error(`expr-ast: trailing input at ${i} in ${code}`);
218
+ return ast;
219
+ }
220
+
221
+ /**
222
+ * `JSON.stringify(astOf(code))` — the attribute payload the runtime reads.
223
+ * @param {string} code
224
+ * @returns {string}
225
+ */
226
+ export const serializeExpr = (code) => JSON.stringify(astOf(code));
227
+
228
+ /** @param {unknown} a @param {unknown} b @returns {boolean} */
229
+ const contains = (a, b) =>
230
+ String(a ?? "")
231
+ .toLowerCase()
232
+ .includes(String(b ?? "").toLowerCase());
233
+
234
+ /**
235
+ * Build-time mirror of the runtime AST walker — same operators, so a `:computed`
236
+ * or `where`/`when` predicate folds to the SAME value the runtime will recompute.
237
+ * `state` reads come from the compilation's declared-state map. No eval.
238
+ * @param {any[]} node
239
+ * @param {unknown} item Loop row for I(); undefined otherwise.
240
+ * @param {{ state: Map<string, unknown> }} comp
241
+ * @returns {any}
242
+ */
243
+ export function evalAst(node, item, comp) {
244
+ /** @param {any[]} n @returns {any} */
245
+ const ev = (n) => {
246
+ const t = n[0];
247
+ switch (t) {
248
+ case "L":
249
+ return n[1];
250
+ case "S":
251
+ return getPath(comp.state.get(n[1]), n[2] ? n[2].split(".") : []);
252
+ case "I":
253
+ return getPath(item, n[1] ? n[1].split(".") : []);
254
+ case "C":
255
+ return contains(ev(n[1]), ev(n[2]));
256
+ case "A": {
257
+ const f = FORMATTERS[n[1]];
258
+ return f ? f(ev(n[2]), n[3] == null ? [] : [n[3]]) : undefined;
259
+ }
260
+ case "!":
261
+ return !ev(n[1]);
262
+ case "u-":
263
+ return -ev(n[1]);
264
+ case "&&":
265
+ return ev(n[1]) && ev(n[2]);
266
+ case "||":
267
+ return ev(n[1]) || ev(n[2]);
268
+ case "==":
269
+ // biome-ignore lint/suspicious/noDoubleEquals: loose equality preserves prior `new Function` semantics exactly.
270
+ return ev(n[1]) == ev(n[2]);
271
+ case "!=":
272
+ // biome-ignore lint/suspicious/noDoubleEquals: loose inequality preserves prior `new Function` semantics exactly.
273
+ return ev(n[1]) != ev(n[2]);
274
+ case "<":
275
+ return ev(n[1]) < ev(n[2]);
276
+ case ">":
277
+ return ev(n[1]) > ev(n[2]);
278
+ case "<=":
279
+ return ev(n[1]) <= ev(n[2]);
280
+ case ">=":
281
+ return ev(n[1]) >= ev(n[2]);
282
+ case "+":
283
+ return ev(n[1]) + ev(n[2]);
284
+ case "-":
285
+ return ev(n[1]) - ev(n[2]);
286
+ case "*":
287
+ return ev(n[1]) * ev(n[2]);
288
+ case "/":
289
+ return ev(n[1]) / ev(n[2]);
290
+ case "%":
291
+ return ev(n[1]) % ev(n[2]);
292
+ }
293
+ throw new Error(`expr-ast: unknown op "${t}"`);
294
+ };
295
+ return ev(node);
296
+ }
@@ -7,7 +7,7 @@
7
7
  // while keeping the message text and `Use:` hints intact.
8
8
  // ---------------------------------------------------------------------------
9
9
 
10
- import { at } from "./context.js";
10
+ import { at, lineOf, wdError } from "./context.js";
11
11
  import {
12
12
  escapeHtml,
13
13
  resolveStateKey,
@@ -21,8 +21,10 @@ import { declareState } from "./state.js";
21
21
  * @typedef {import("./context.js").Ctx} Ctx
22
22
  */
23
23
 
24
- const FETCH_USE =
25
- 'Use: :fetch name from "url" [method=…] [timeout=ms] [retry=N] [when=visible] [headers=key] [body=key] [refresh=url]';
24
+ // A concrete, compilable :fetch line for the hint tail + the directive catalog.
25
+ export const FETCH_EXAMPLE = ':fetch todos from "/api/todos.json" when=visible';
26
+
27
+ const FETCH_USE = `Use: :fetch name from "url" [method=…] [timeout=ms] [retry=N] [when=visible] [headers=key] [body=key] [refresh=url] — e.g. ${FETCH_EXAMPLE}`;
26
28
 
27
29
  /**
28
30
  * Parse a keyword-arg `:fetch` directive into a lifecycle-aware marker.
@@ -36,7 +38,13 @@ const FETCH_USE =
36
38
  */
37
39
  export function handleFetch(line, ctx, index) {
38
40
  const head = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+)\s*(.*)$/);
39
- if (!head) throw new Error(`Malformed :fetch in ${at(ctx, index)}: ${line}. ${FETCH_USE}`);
41
+ if (!head)
42
+ throw wdError(`Malformed :fetch in ${at(ctx, index)}: ${line}. ${FETCH_USE}`, {
43
+ file: ctx.file,
44
+ line: lineOf(ctx, index),
45
+ hint: FETCH_USE.slice("Use: ".length),
46
+ example: FETCH_EXAMPLE
47
+ });
40
48
  const name = head[1];
41
49
  const url = validateFetchUrl(stripQuotes(head[2]), ctx);
42
50
  /** @type {Record<string, string>} */