@zvndev/darkmown 2.0.0 → 2.1.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 (55) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +31 -7
  4. package/SECURITY.md +4 -4
  5. package/docs/spec-alignment.md +19 -6
  6. package/package.json +5 -2
  7. package/site/_/footer.wd +1 -1
  8. package/site/_/nav.wd +1 -0
  9. package/site/pages/app.wd +1 -1
  10. package/site/pages/blog/zero-js-by-default.md +1 -1
  11. package/site/pages/docs/index.wd +18 -2
  12. package/site/pages/index.js +19 -0
  13. package/site/pages/index.skin +46 -1
  14. package/site/pages/index.wd +7 -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 +10 -8
  20. package/src/compiler/collections.js +16 -11
  21. package/src/compiler/context.js +22 -4
  22. package/src/compiler/expr-ast.js +296 -0
  23. package/src/compiler/fs-reader.js +25 -0
  24. package/src/compiler/includes.js +25 -14
  25. package/src/compiler/index.js +1 -1
  26. package/src/compiler/loops.js +44 -7
  27. package/src/compiler/page.js +97 -17
  28. package/src/compiler/predicates.js +4 -12
  29. package/src/compiler/reader.js +60 -0
  30. package/src/compiler/state.js +16 -18
  31. package/src/compiler/structure.js +40 -10
  32. package/src/compiler.js +57 -6
  33. package/src/headers.js +15 -13
  34. package/src/index.js +1 -0
  35. package/src/runtime.js +77 -45
  36. package/src/statics.js +6 -7
  37. package/src/templates/blog/site/_/nav.wd +6 -9
  38. package/src/templates/blog/site/pages/index.skin +7 -4
  39. package/src/templates/blog/site/pages/index.wd +2 -3
  40. package/src/templates/dashboard/site/_/nav.wd +6 -9
  41. package/src/templates/dashboard/site/pages/index.skin +7 -4
  42. package/src/templates/dashboard/site/pages/index.wd +2 -3
  43. package/src/templates/landing/site/_/nav.wd +6 -9
  44. package/src/templates/landing/site/pages/index.skin +13 -4
  45. package/src/templates/landing/site/pages/index.wd +22 -9
  46. package/src/templates/starter/site/_/nav.wd +7 -10
  47. package/src/templates/starter/site/pages/about.md +7 -18
  48. package/src/templates/starter/site/pages/about.skin +0 -25
  49. package/src/templates/starter/site/pages/index.skin +10 -6
  50. package/src/templates/starter/site/pages/index.wd +2 -3
  51. package/src/templates/store/site/_/nav.wd +6 -9
  52. package/src/templates/store/site/pages/index.skin +7 -4
  53. package/src/templates/store/site/pages/index.wd +2 -3
  54. package/types/compiler.d.ts +13 -0
  55. package/types/index.d.ts +1 -0
@@ -0,0 +1,128 @@
1
+ tokens
2
+ pgink #eef2e6
3
+ pgbg #10160c
4
+ pgpanel #18210f
5
+ pgline #2c3a20
6
+ pgaccent #8fd694
7
+ pgmuted #9db089
8
+ pgradius 10px
9
+
10
+ page
11
+ margin 0
12
+ font system-ui, -apple-system, "Segoe UI", Roboto, sans-serif
13
+ color $pgink
14
+ bg $pgbg
15
+ line-height 1.6
16
+
17
+ main
18
+ max-width 1100px
19
+ margin 0 auto
20
+ padding 2.5rem 1.5rem 4rem
21
+
22
+ .pg-intro h1
23
+ font-size 2.6rem
24
+ line-height 1.05
25
+ margin 0 0 .6rem
26
+ color $pgink
27
+
28
+ .pg-intro p
29
+ color $pgmuted
30
+ max-width 46rem
31
+
32
+ .pg-intro a
33
+ color $pgaccent
34
+
35
+ .pg-picker
36
+ display flex
37
+ flex-wrap wrap
38
+ gap .5rem
39
+ margin 1.4rem 0 1rem
40
+
41
+ .pg-picker button
42
+ font inherit
43
+ font-size .92rem
44
+ font-weight 560
45
+ padding .5rem 1rem
46
+ color $pgink
47
+ bg $pgpanel
48
+ border 1px solid $pgline
49
+ radius 999px
50
+ cursor pointer
51
+ transition background .14s ease, border-color .14s ease, transform .14s ease
52
+
53
+ .pg-picker button:hover
54
+ border-color $pgaccent
55
+ transform translateY(-1px)
56
+
57
+ .pg-picker button:focus-visible
58
+ outline 2px solid $pgaccent
59
+ outline-offset 2px
60
+
61
+ .pg-workbench
62
+ display grid
63
+ grid-template-columns 1fr 1fr
64
+ gap 1rem
65
+ margin-top 1.5rem
66
+ align-items stretch
67
+
68
+ .pg-pane
69
+ display flex
70
+ flex-direction column
71
+ min-width 0
72
+
73
+ .pg-label
74
+ font-size .78rem
75
+ font-weight 600
76
+ letter-spacing .04em
77
+ text-transform uppercase
78
+ color $pgmuted
79
+ margin-bottom .5rem
80
+
81
+ .pg-editor
82
+ width 100%
83
+ min-height 30rem
84
+ flex 1
85
+ resize vertical
86
+ padding 1rem 1.1rem
87
+ color $pgink
88
+ bg $pgpanel
89
+ border 1px solid $pgline
90
+ radius $pgradius
91
+ font ui-monospace, "SF Mono", Menlo, Consolas, monospace
92
+ font-size .9rem
93
+ line-height 1.55
94
+ tab-size 2
95
+
96
+ .pg-editor:focus-visible
97
+ outline none
98
+ border-color $pgaccent
99
+ box-shadow 0 0 0 3px rgba(143,214,148,.16)
100
+
101
+ .pg-preview
102
+ width 100%
103
+ min-height 30rem
104
+ flex 1
105
+ border 1px solid $pgline
106
+ radius $pgradius
107
+ bg #12190f
108
+
109
+ .pg-error
110
+ margin .75rem 0 0
111
+ padding .9rem 1.1rem
112
+ color #ffd9d0
113
+ bg #2a1712
114
+ border 1px solid #6b2c1e
115
+ radius $pgradius
116
+ font ui-monospace, "SF Mono", Menlo, Consolas, monospace
117
+ font-size .82rem
118
+ line-height 1.5
119
+ white-space pre-wrap
120
+ word-break break-word
121
+
122
+ @media (max-width: 820px)
123
+ .pg-workbench
124
+ grid-template-columns 1fr
125
+ .pg-editor
126
+ min-height 18rem
127
+ .pg-preview
128
+ min-height 22rem
@@ -0,0 +1,51 @@
1
+ ---
2
+ title: Playground — run Markdown in your browser
3
+ description: Edit a .wd file and watch it compile and run in your browser — the same compiler the Darkmown CLI uses, no install required.
4
+ html: true
5
+ transitions: true
6
+ ---
7
+
8
+ @include /nav.wd
9
+
10
+ <main>
11
+
12
+ :state activeExample = "store"
13
+
14
+ ::: section .pg-intro
15
+
16
+ # Playground
17
+
18
+ Edit the Markdown on the left. It compiles **in your browser** — the exact same
19
+ compiler the CLI ships — and runs live on the right. No install, no build step.
20
+
21
+ ::: div .pg-picker
22
+ :button "Store + cart" -> activeExample = "store"
23
+ :button "Counter" -> activeExample = "counter"
24
+ :button "Plain Markdown" -> activeExample = "markdown"
25
+ :::
26
+
27
+ Now editing the **{ activeExample }** example. Change any text and the preview
28
+ re-renders as you type. Break the syntax on purpose — the real compiler error,
29
+ with `file:line` and a fix hint, shows up in the preview pane.
30
+
31
+ :::
32
+
33
+ ::: section .pg-workbench
34
+
35
+ ::: div .pg-pane .pg-pane-source
36
+ <label class="pg-label" for="pg-editor">Source</label>
37
+
38
+ <textarea id="pg-editor" class="pg-editor" spellcheck="false" autocapitalize="off" autocomplete="off" aria-label="Markdown source"></textarea>
39
+ :::
40
+
41
+ ::: div .pg-pane .pg-pane-preview
42
+ <label class="pg-label" for="pg-preview">Preview</label>
43
+
44
+ <iframe id="pg-preview" class="pg-preview" title="Live preview" sandbox="allow-scripts allow-same-origin"></iframe>
45
+
46
+ <pre id="pg-error" class="pg-error" role="alert" hidden></pre>
47
+ :::
48
+
49
+ :::
50
+
51
+ </main>
@@ -96,7 +96,7 @@ Darkmown's whole pitch is weight. A static page ships **zero** framework JavaScr
96
96
 
97
97
  ::: card .sc-scale
98
98
  - **Static Darkmown page** — `0 KB` framework JS. Forever.
99
- - **Reactive Darkmown page** — one shared `~7.4 KB` gzipped runtime, no per-component bundles.
99
+ - **Reactive Darkmown page** — one shared `~7.7 KB` gzipped runtime, no per-component bundles.
100
100
  - **A typical SPA** — ships a framework runtime in the **~15–60 KB** gzipped range *before* your app code, on every page (Alpine ~15 KB, Vue ~18 KB runtime, React 19 ~59 KB).
101
101
  :::
102
102
 
package/src/builder.js CHANGED
@@ -352,9 +352,11 @@ function buildRoute(route, cwd, paths, collections, feedLink, warned) {
352
352
  * incremental builds keeps them consistent by construction.
353
353
  *
354
354
  * Cloudflare Pages reads dist/_headers for security headers (Vercel reads
355
- * vercel.json; the local server adds them in src/statics.js). Static routes get
356
- * the stricter CSP (no 'unsafe-eval'); reactive routes get the relaxed one. A
357
- * catch-all keeps assets and any future path covered.
355
+ * vercel.json; the local server adds them in src/statics.js). Since 2.1 both CSP
356
+ * variants are eval-free (the reactive runtime walks a validated AST, not
357
+ * `new Function`), so static and reactive routes resolve to the same policy; the
358
+ * per-route static override is kept as defense-in-depth. A catch-all keeps assets
359
+ * and any future path covered.
358
360
  * @param {RouteManifestEntry[]} manifest
359
361
  * @param {Paths} paths
360
362
  * @returns {void}
@@ -924,10 +926,10 @@ export function stripRuntimeComments(source) {
924
926
  *
925
927
  * Cloudflare applies every matching block in order; when blocks set the same
926
928
  * header, the later block wins. So we emit the catch-all `/*` first (baseline
927
- * security headers + the relaxed reactive CSP that satisfies every page), then
928
- * one block per static route that overrides `Content-Security-Policy` with the
929
- * stricter, eval-free CSP. Reactive routes need no override — the catch-all
930
- * already carries the relaxed CSP.
929
+ * security headers + the reactive CSP, which since 2.1 is eval-free and satisfies
930
+ * every page), then one block per static route that re-states the same eval-free
931
+ * CSP as defense-in-depth. Reactive routes need no override — the catch-all
932
+ * already carries the eval-free CSP.
931
933
  *
932
934
  * Clean URLs mean a route like `/docs/` is served at both `/docs` and `/docs/`,
933
935
  * so each static route emits a path glob (`/docs`, `/docs/*`) that covers both.
@@ -941,7 +943,7 @@ export function renderCloudflareHeaders(manifest) {
941
943
  const blocks = [["/*", [...baseLines, ` Content-Security-Policy: ${REACTIVE_CSP}`]]];
942
944
 
943
945
  for (const entry of manifest) {
944
- if (entry.assets.runtime) continue; // reactive routes keep the catch-all (relaxed) CSP
946
+ if (entry.assets.runtime) continue; // reactive routes keep the catch-all (eval-free) CSP
945
947
  for (const pattern of cloudflarePathPatterns(entry.route)) {
946
948
  blocks.push([pattern, [` Content-Security-Policy: ${STATIC_CSP}`]]);
947
949
  }
@@ -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
  /**
@@ -102,15 +106,24 @@
102
106
  * @property {string[]} sections Active section-id scope chain.
103
107
  * @property {string | null} loopItem Name of the current reactive loop item, if any.
104
108
  * @property {string} [loopKey] State key of the list being looped, if any.
109
+ * @property {number} [reactiveDepth] Number of enclosing REACTIVE `@loop` levels
110
+ * (0 at top level). The runtime reconciles at most two nested `data-wd-loop`
111
+ * levels — a third paints empty — so a third reactive level is rejected at
112
+ * compile time. Static (build-unrolled) loops flatten to markup and don't add
113
+ * a level, so they carry the count through without incrementing it.
114
+ * @property {{ at: string, line: string }} [loopOpener] The current `@loop`
115
+ * opener's `file:line` + raw text, set when descending into a loop body so the
116
+ * reactive-nesting-depth error can point at the offending opener.
105
117
  * @property {boolean} [loopMeta] Inside a loop body, so `$index`/`$first`/… are valid.
106
118
  * @property {import("markdown-it").default} [md] Markdown-it instance selected for this file.
107
119
  * @property {(lines: string[], ctx: Ctx) => string} compileBody The body
108
120
  * dispatcher, threaded through the context so block handlers (`:if`, `:::`,
109
121
  * `:form`, `@loop`, …) recurse into nested bodies without importing the
110
122
  * dispatcher back — the module graph stays an import-cycle-free DAG.
111
- * @property {(file: string, context: Paths, stack: string[], scope: Scope, comp: Compilation, sections: string[], loopItem: string | null) => { meta: Meta, html: string }} compileFile
123
+ * @property {(file: string, context: Paths, stack: string[], scope: Scope, comp: Compilation, sections: string[], loopItem: string | null, reactiveDepth?: number, loopOpener?: { at: string, line: string } | null) => { meta: Meta, html: string }} compileFile
112
124
  * The per-file compile, threaded for the same reason so `@include` can
113
- * compile its target file.
125
+ * compile its target file. `reactiveDepth`/`loopOpener` carry the enclosing
126
+ * reactive-loop nesting across the include boundary so the depth guard holds.
114
127
  */
115
128
 
116
129
  /**
@@ -193,9 +206,14 @@ export const LOOP_META = {
193
206
  $count: "count"
194
207
  };
195
208
 
196
- /** @returns {Compilation} */
197
- export function createCompilation() {
209
+ /**
210
+ * @param {import("./reader.js").Reader} reader The source reader for this
211
+ * compile (fs-backed by default; in-memory for `compileFromMemory`).
212
+ * @returns {Compilation}
213
+ */
214
+ export function createCompilation(reader) {
198
215
  return {
216
+ reader,
199
217
  assets: {
200
218
  skins: new Set(),
201
219
  scripts: new Set(),
@@ -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
+ }