@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
@@ -5,6 +5,8 @@
5
5
  // readers — no identifier survives un-mapped, so no raw user content is eval'd.
6
6
  // ---------------------------------------------------------------------------
7
7
 
8
+ import { wdError } from "./context.js";
9
+ import { astOf, evalAst } from "./expr-ast.js";
8
10
  import { getPath, lookupVar, resolveStateKey } from "./interpolation.js";
9
11
 
10
12
  /**
@@ -12,6 +14,20 @@ import { getPath, lookupVar, resolveStateKey } from "./interpolation.js";
12
14
  * @typedef {import("./context.js").Predicate} Predicate
13
15
  */
14
16
 
17
+ // The comparison operators a `where` / `.class when` / `:if` / `:computed`
18
+ // condition may use, longest-match first so `>=` wins over `>`. The single
19
+ // source of truth: the operator regex is built from this list, and the directive
20
+ // catalog + GBNF grammar enumerate the same array (drift-guarded in tests).
21
+ export const PREDICATE_OPS = ["contains", "==", "!=", ">=", "<=", ">", "<"];
22
+
23
+ // The logical joiners between conditions (`and`/`or`), plus a leading `not` the
24
+ // `.class when` / `:if` paths accept. Catalogued alongside the operators.
25
+ export const PREDICATE_JOINERS = ["and", "or", "not"];
26
+
27
+ // `left <op> right` where <op> is one of PREDICATE_OPS. Built from the array so
28
+ // adding an operator in one place updates the parser and the catalog together.
29
+ const CONDITION_RE = new RegExp(`^(.+?)\\s+(${PREDICATE_OPS.join("|")})\\s+(.+)$`, "i");
30
+
15
31
  // Compile a `where` predicate to a safe JS boolean expression over I()/S()/C().
16
32
  // Conditions (operand <op> operand) join with `and`/`or`; operands are loop-item
17
33
  // paths, declared :state, numbers, or strings. No identifiers survive un-mapped.
@@ -45,11 +61,15 @@ export function compilePredicate(raw, itemName, ctx) {
45
61
  * @returns {{ expr: string, usesState: boolean }}
46
62
  */
47
63
  function compileCondition(cond, itemName, ctx) {
48
- const m = cond.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
49
- if (!m)
50
- throw new Error(
51
- `Malformed where-condition "${cond}" in ${ctx.file}. Use: ${itemName}.field contains state, or ${itemName}.field <op> value.`
52
- );
64
+ const m = cond.match(CONDITION_RE);
65
+ if (!m) {
66
+ const hint = `${itemName}.field contains state, or ${itemName}.field <op> value — e.g. ${itemName}.price < 50`;
67
+ throw wdError(`Malformed where-condition "${cond}" in ${ctx.file}. Use: ${hint}`, {
68
+ file: ctx.file,
69
+ hint,
70
+ example: `${itemName}.price < 50`
71
+ });
72
+ }
53
73
  const left = compileOperand(m[1].trim(), itemName, ctx);
54
74
  const right = compileOperand(m[3].trim(), itemName, ctx);
55
75
  const usesState = left.usesState || right.usesState;
@@ -93,14 +113,9 @@ function compileOperand(tok, itemName, ctx) {
93
113
  };
94
114
  }
95
115
 
96
- /** @param {unknown} a @param {unknown} b @returns {boolean} */
97
- const containsHelper = (a, b) =>
98
- String(a ?? "")
99
- .toLowerCase()
100
- .includes(String(b ?? "").toLowerCase());
101
-
102
116
  /**
103
- * Evaluate a compiled predicate against a row at build time.
117
+ * Evaluate a compiled predicate against a row at build time by walking its AST —
118
+ * the same closed evaluator the runtime uses, so the fold matches. No eval.
104
119
  * @param {string} body
105
120
  * @param {unknown} item
106
121
  * @param {Ctx} ctx
@@ -108,11 +123,7 @@ const containsHelper = (a, b) =>
108
123
  */
109
124
  export function evalPredicate(body, item, ctx) {
110
125
  try {
111
- /** @param {string} [p] */
112
- const I = (p) => getPath(item, p ? p.split(".") : []);
113
- /** @param {string} k @param {string} [r] */
114
- const S = (k, r) => getPath(ctx.comp.state.get(k), r ? r.split(".") : []);
115
- return Boolean(new Function("I", "S", "C", `return (${body});`)(I, S, containsHelper));
126
+ return Boolean(evalAst(astOf(body), item, ctx.comp));
116
127
  } catch {
117
128
  console.warn(
118
129
  `@loop where predicate "${body}" in ${ctx.file} could not be evaluated at build time; treating the row as excluded. Check the condition.`
@@ -197,7 +208,7 @@ export function compileWhen(raw, ctx, what = '"::: … when"') {
197
208
  negate = true;
198
209
  seg = seg.replace(/^not\s+/i, "").trim();
199
210
  }
200
- const opMatch = seg.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
211
+ const opMatch = seg.match(CONDITION_RE);
201
212
  let expr;
202
213
  if (opMatch) {
203
214
  const left = compileWhenOperand(opMatch[1].trim(), ctx, what);
@@ -0,0 +1,60 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The file-reader abstraction the compiler reads every source through. The
3
+ // compiler never touches `node:fs` directly; instead a `Reader` is threaded
4
+ // through the `Compilation` (and the page-shell helpers), so the exact same
5
+ // compile can run against the real filesystem (`fsReader`, in `fs-reader.js`)
6
+ // or against an in-memory map of project-relative path → content
7
+ // (`memoryReader`, here). The memory path is what `compileFromMemory` — and a
8
+ // planned mobile/browser host — needs: a fully fs-free compile.
9
+ //
10
+ // This module imports NOTHING from `node:fs`, so it (and everything reachable
11
+ // from `compileFromMemory`) bundles cleanly for the browser. The Node reader
12
+ // lives in its own `fs-reader.js` that no browser-reachable module imports.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import path from "node:path";
16
+
17
+ /**
18
+ * The narrow filesystem surface the compiler needs. Every method takes an
19
+ * ABSOLUTE path (the compiler resolves includes/assets to absolute paths before
20
+ * reading), so a reader only has to map absolute paths to bytes/existence.
21
+ * @typedef {object} Reader
22
+ * @property {(absPath: string) => string} readText Read a UTF-8 text file.
23
+ * @property {(absPath: string) => Uint8Array} readBinary Read raw bytes (images).
24
+ * @property {(absPath: string) => boolean} exists Whether the path exists.
25
+ * @property {(absPath: string) => string} realpath Canonical path (symlinks
26
+ * resolved on a real fs; a no-op normalize in memory) — used for cycle keys.
27
+ */
28
+
29
+ /**
30
+ * A reader backed by an in-memory map of PROJECT-RELATIVE path → content. Keys
31
+ * are POSIX, project-relative (e.g. `site/pages/index.wd`, `site/_/nav.wd`);
32
+ * lookups translate an absolute compile path back to its key via `cwd`. There
33
+ * are no symlinks in memory, so `realpath` just normalizes. Binary reads are
34
+ * unsupported (the playground has no image files, and image measuring degrades
35
+ * to "no dimensions" on a throw), so `readBinary` throws — callers already
36
+ * try/catch it.
37
+ * @param {Record<string, string> | Map<string, string>} files
38
+ * @param {string} [cwd] Virtual project root the keys are relative to.
39
+ * @returns {Reader}
40
+ */
41
+ export function memoryReader(files, cwd = "/") {
42
+ const map = files instanceof Map ? files : new Map(Object.entries(files));
43
+ const keyOf = (/** @type {string} */ absPath) =>
44
+ path.relative(cwd, path.resolve(absPath)).split(path.sep).join("/");
45
+ return {
46
+ readText: (absPath) => {
47
+ const key = keyOf(absPath);
48
+ const value = map.get(key);
49
+ if (value === undefined) {
50
+ throw new Error(`ENOENT: no in-memory file "${key}" (from ${absPath})`);
51
+ }
52
+ return value;
53
+ },
54
+ readBinary: () => {
55
+ throw new Error("memoryReader: binary reads are unsupported");
56
+ },
57
+ exists: (absPath) => map.has(keyOf(absPath)),
58
+ realpath: (absPath) => path.resolve(absPath)
59
+ };
60
+ }
@@ -8,15 +8,22 @@
8
8
  // while keeping the message text and `Use:` hints intact.
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
- import { at } from "./context.js";
12
- import { FORMATTERS } from "./format.js";
13
- import { escapeHtml, getPath, parseStateValue, safeScriptJson } from "./interpolation.js";
11
+ import { at, lineOf, wdError } from "./context.js";
12
+ import { astOf, evalAst } from "./expr-ast.js";
13
+ import { escapeHtml, parseStateValue, safeScriptJson } from "./interpolation.js";
14
14
  import { compileComputedExpr } from "./predicates.js";
15
15
 
16
16
  /**
17
17
  * @typedef {import("./context.js").Ctx} Ctx
18
18
  */
19
19
 
20
+ // Concrete, compilable one-liners for the state-family hint tails + the catalog.
21
+ export const STATE_EXAMPLE = ":state count = 0";
22
+ export const STORE_EXAMPLE = ":store cart = []";
23
+ export const COMPUTED_EXAMPLE = ":computed total = items.length * 4";
24
+ export const THEME_EXAMPLE = ":theme";
25
+ const STATE_USE = `Use: :state name = value [persist] — e.g. ${STATE_EXAMPLE}`;
26
+
20
27
  /**
21
28
  * @param {string} line
22
29
  * @param {Ctx} ctx
@@ -25,7 +32,13 @@ import { compileComputedExpr } from "./predicates.js";
25
32
  */
26
33
  export function handleState(line, ctx, index) {
27
34
  const match = line.match(/^:state\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+persist)?$/);
28
- if (!match) throw new Error(`Malformed :state in ${at(ctx, index)}: ${line}`);
35
+ if (!match)
36
+ throw wdError(`Malformed :state in ${at(ctx, index)}: ${line}. ${STATE_USE}`, {
37
+ file: ctx.file,
38
+ line: lineOf(ctx, index),
39
+ hint: STATE_USE.slice("Use: ".length),
40
+ example: STATE_EXAMPLE
41
+ });
29
42
  const value = parseStateValue(match[2], at(ctx, index));
30
43
  const key = declareState(match[1], value, ctx);
31
44
  const persistAttr = match[3] ? ` data-wd-persist="${key}"` : "";
@@ -43,8 +56,13 @@ export function declareState(name, value, ctx) {
43
56
  if (ctx.loopItem)
44
57
  throw new Error(`State cannot be declared inside a reactive @loop body (${ctx.file})`);
45
58
  if (ctx.comp.stores.has(name))
46
- throw new Error(
47
- `State "${name}" collides with a :store of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one.`
59
+ throw wdError(
60
+ `State "${name}" collides with a :store of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one — e.g. ${STORE_EXAMPLE}`,
61
+ {
62
+ file: ctx.file,
63
+ hint: `:store name = value for the global, or rename one — e.g. ${STORE_EXAMPLE}`,
64
+ example: STORE_EXAMPLE
65
+ }
48
66
  );
49
67
  const key = ctx.sections.length ? `${ctx.sections.at(-1)}:${name}` : name;
50
68
  if (ctx.comp.state.has(key))
@@ -63,8 +81,14 @@ export function declareState(name, value, ctx) {
63
81
  export function handleStore(line, ctx, index) {
64
82
  const match = line.match(/^:store\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+ephemeral)?$/);
65
83
  if (!match)
66
- throw new Error(
67
- `Malformed :store in ${at(ctx, index)}: ${line}. Use: :store name = value [ephemeral]`
84
+ throw wdError(
85
+ `Malformed :store in ${at(ctx, index)}: ${line}. Use: :store name = value [ephemeral] — e.g. ${STORE_EXAMPLE}`,
86
+ {
87
+ file: ctx.file,
88
+ line: lineOf(ctx, index),
89
+ hint: `:store name = value [ephemeral] — e.g. ${STORE_EXAMPLE}`,
90
+ example: STORE_EXAMPLE
91
+ }
68
92
  );
69
93
  const value = parseStateValue(match[2], at(ctx, index));
70
94
  const name = declareStore(match[1], value, ctx);
@@ -83,10 +107,22 @@ export function handleStore(line, ctx, index) {
83
107
  */
84
108
  export function declareStore(name, value, ctx) {
85
109
  if (ctx.comp.stores.has(name))
86
- throw new Error(`Store "${name}" is declared twice in ${ctx.file}. Use: :store name = value`);
110
+ throw wdError(
111
+ `Store "${name}" is declared twice in ${ctx.file}. Use: :store name = value — e.g. ${STORE_EXAMPLE}`,
112
+ {
113
+ file: ctx.file,
114
+ hint: `:store name = value — e.g. ${STORE_EXAMPLE}`,
115
+ example: STORE_EXAMPLE
116
+ }
117
+ );
87
118
  if (ctx.comp.state.has(name))
88
- throw new Error(
89
- `Store "${name}" collides with a :state of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one.`
119
+ throw wdError(
120
+ `Store "${name}" collides with a :state of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one — e.g. ${STORE_EXAMPLE}`,
121
+ {
122
+ file: ctx.file,
123
+ hint: `:store name = value for the global, or rename one — e.g. ${STORE_EXAMPLE}`,
124
+ example: STORE_EXAMPLE
125
+ }
90
126
  );
91
127
  ctx.comp.stores.add(name);
92
128
  ctx.comp.state.set(name, value);
@@ -115,31 +151,38 @@ export function declareErrorState(key, ctx) {
115
151
  export function handleComputed(line, ctx, index) {
116
152
  const match = line.match(/^:computed\s+([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
117
153
  if (!match)
118
- throw new Error(
119
- `Malformed :computed in ${at(ctx, index)}: ${line}. Use: :computed total = items.length * 4`
120
- );
154
+ throw wdError(`Malformed :computed in ${at(ctx, index)}: ${line}. Use: ${COMPUTED_EXAMPLE}`, {
155
+ file: ctx.file,
156
+ line: lineOf(ctx, index),
157
+ hint: COMPUTED_EXAMPLE,
158
+ example: COMPUTED_EXAMPLE
159
+ });
121
160
  const expr = compileComputedExpr(match[2].trim(), ctx);
122
- /** @type {unknown} */
123
- let initial;
161
+ // Parse the validated expression to the compact AST the runtime walks. A parse
162
+ // failure means the RHS is syntactically malformed (e.g. `|| <`) — a compile
163
+ // error, not a silently-broken binding.
164
+ /** @type {any[]} */
165
+ let ast;
124
166
  try {
125
- /** @param {string} key @param {string} [path] */
126
- const read = (key, path) => getPath(ctx.comp.state.get(key), path ? path.split(".") : []);
127
- // Build-time mirror of the runtime's AGG helper, so a `:computed total = sum(...)`
128
- // has the same initial value the runtime will recompute to.
129
- const agg = (
130
- /** @type {string} */ name,
131
- /** @type {any} */ list,
132
- /** @type {string} */ field
133
- ) => (FORMATTERS[name] ? FORMATTERS[name](list, field == null ? [] : [field]) : undefined);
134
- initial = new Function("S", "A", `return (${expr});`)(read, agg);
167
+ ast = astOf(expr);
135
168
  } catch {
136
- console.warn(
137
- `:computed "${match[2].trim()}" in ${ctx.file} could not be evaluated at build time; falling back to null. Check the expression.`
169
+ throw wdError(
170
+ `Malformed :computed expression in ${at(ctx, index)}: ${line}. Use: ${COMPUTED_EXAMPLE}`,
171
+ {
172
+ file: ctx.file,
173
+ line: lineOf(ctx, index),
174
+ hint: COMPUTED_EXAMPLE,
175
+ example: COMPUTED_EXAMPLE
176
+ }
138
177
  );
139
- initial = null;
140
178
  }
179
+ // Build-time mirror of the runtime: walk the AST (no eval) so `:computed total =
180
+ // sum(...)` seeds the value the runtime recomputes. The walker is total over a
181
+ // validated AST — state that isn't known at build time resolves to undefined and
182
+ // folds to null, never throws — so parse-failure above is the only error path.
183
+ const initial = evalAst(ast, undefined, ctx.comp);
141
184
  const key = declareState(match[1], initial ?? null, ctx);
142
- return `<span data-wd-computed data-wd-computed-key="${key}" data-wd-computed-expr="${escapeHtml(expr)}"></span><script type="application/json" data-wd-state>${safeScriptJson({ [key]: initial ?? null })}</script>`;
185
+ return `<span data-wd-computed data-wd-computed-key="${key}" data-wd-computed-expr="${escapeHtml(JSON.stringify(ast))}"></span><script type="application/json" data-wd-state>${safeScriptJson({ [key]: initial ?? null })}</script>`;
143
186
  }
144
187
 
145
188
  /**
@@ -157,8 +200,14 @@ export function handleComputed(line, ctx, index) {
157
200
  export function handleTheme(line, ctx, index) {
158
201
  const match = line.match(/^:theme(?:\s+([A-Za-z_$][\w$]*))?(?:\s*=\s*(.+))?$/);
159
202
  if (!match) {
160
- throw new Error(
161
- `Malformed :theme in ${at(ctx, index)}: ${line}. Use: :theme (or :theme name = "auto")`
203
+ throw wdError(
204
+ `Malformed :theme in ${at(ctx, index)}: ${line}. Use: :theme (or :theme name = "auto") — e.g. ${THEME_EXAMPLE}`,
205
+ {
206
+ file: ctx.file,
207
+ line: lineOf(ctx, index),
208
+ hint: `:theme (or :theme name = "auto") — e.g. ${THEME_EXAMPLE}`,
209
+ example: THEME_EXAMPLE
210
+ }
162
211
  );
163
212
  }
164
213
  const name = match[1] || "theme";
@@ -11,7 +11,8 @@
11
11
  // while keeping the message text and `Use:` hints intact.
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
- import { at, createScope, LOOP_META, nestedCtx } from "./context.js";
14
+ import { at, createScope, LOOP_META, lineOf, nestedCtx, wdError } from "./context.js";
15
+ import { astOf, serializeExpr } from "./expr-ast.js";
15
16
  import { resolveInclude, scopedSkinFor, stampScope } from "./includes.js";
16
17
  import {
17
18
  escapeHtml,
@@ -27,6 +28,13 @@ import { compileWhen, evalPredicate } from "./predicates.js";
27
28
  * @typedef {import("./context.js").Ctx} Ctx
28
29
  */
29
30
 
31
+ // Concrete, compilable structure examples for the hint tails + the catalog.
32
+ export const INCLUDE_EXAMPLE = "@include /header.wd";
33
+ export const CONTAINER_EXAMPLE = "::: card .featured";
34
+ export const IF_EXAMPLE = ":if count > 0";
35
+ export const CAROUSEL_EXAMPLE = ":carousel autoplay=3000";
36
+ const INCLUDE_USE = `Use: @include /partial.wd [with key="value"] — e.g. ${INCLUDE_EXAMPLE}`;
37
+
30
38
  // Container names that emit their own semantic landmark element instead of a
31
39
  // `<div>`, so scaffolds get real `<nav>`/`<main>` landmarks (accessibility: the
32
40
  // skip link targets a genuine main-content region and navigation chrome sits in
@@ -43,8 +51,21 @@ const SEMANTIC_CONTAINER_TAGS = new Set(["nav", "main"]);
43
51
  */
44
52
  export function handleInclude(line, ctx, index) {
45
53
  const match = line.match(/^@include\s+(\S+)(?:\s+with\s+(.+))?$/);
46
- if (!match) throw new Error(`Malformed @include in ${at(ctx, index)}: ${line}`);
47
- const target = resolveInclude(match[1], ctx.file, ctx.context, false, at(ctx, index));
54
+ if (!match)
55
+ throw wdError(`Malformed @include in ${at(ctx, index)}: ${line}. ${INCLUDE_USE}`, {
56
+ file: ctx.file,
57
+ line: lineOf(ctx, index),
58
+ hint: INCLUDE_USE.slice("Use: ".length),
59
+ example: INCLUDE_EXAMPLE
60
+ });
61
+ const target = resolveInclude(
62
+ match[1],
63
+ ctx.file,
64
+ ctx.context,
65
+ false,
66
+ at(ctx, index),
67
+ ctx.comp.reader
68
+ );
48
69
  const args = parseIncludeArgs(match[2] || "", ctx);
49
70
  const childScope = createScope(ctx.scope, args);
50
71
  // Thread the reactive-nesting depth (and the enclosing loop opener, so a depth
@@ -67,7 +88,7 @@ export function handleInclude(line, ctx, index) {
67
88
  // anywhere else on the page, and the scope never leaks to the include's
68
89
  // siblings (only this child HTML is stamped). The CSS rewrite (builder) uses
69
90
  // the same path-derived id, so attribute and stylesheet line up.
70
- const scopedSkin = scopedSkinFor(target, ctx.context);
91
+ const scopedSkin = scopedSkinFor(target, ctx.context, ctx.comp.reader);
71
92
  return scopedSkin && scopedSkin.scoped ? stampScope(child.html, scopedSkin.scopeId) : child.html;
72
93
  }
73
94
 
@@ -110,9 +131,9 @@ export function handleContainer(header, bodyLines, ctx, index) {
110
131
  let tag = "section";
111
132
  /** @type {string[]} Static classes baked into class="". */
112
133
  const extraClass = [];
113
- /** @type {[string, string][]} Reactive loop-item class bindings (data-wd-each-class). */
134
+ /** @type {[string, any[]][]} Reactive loop-item class bindings (data-wd-each-class): [class, exprAST]. */
114
135
  const eachClasses = [];
115
- /** @type {[string, string][]} Global state-driven class bindings (data-wd-class). */
136
+ /** @type {[string, any[]][]} Global state-driven class bindings (data-wd-class): [class, exprAST]. */
116
137
  const stateClasses = [];
117
138
  let id = "";
118
139
  // Leading tag/name token (anything not starting with . or #). "section" keeps
@@ -157,8 +178,8 @@ export function handleContainer(header, bodyLines, ctx, index) {
157
178
  const compiled = compileWhen(whenMatch[1].trim(), ctx);
158
179
  if (compiled.static) {
159
180
  if (compiled.value) extraClass.push(cls);
160
- } else if (compiled.item) eachClasses.push([cls, compiled.body]);
161
- else stateClasses.push([cls, compiled.body]);
181
+ } else if (compiled.item) eachClasses.push([cls, astOf(compiled.body)]);
182
+ else stateClasses.push([cls, astOf(compiled.body)]);
162
183
  } else {
163
184
  extraClass.push(cls);
164
185
  }
@@ -207,10 +228,15 @@ export function handleCarousel(line, bodyLines, ctx, index) {
207
228
  let autoplayAttr = "";
208
229
  if (rest) {
209
230
  const auto = rest.match(/^autoplay=(\d+)$/);
210
- if (!auto)
211
- throw new Error(
212
- `Malformed :carousel in ${at(ctx, index)}: ${line}. Use: :carousel [autoplay=3000] … :endcarousel`
213
- );
231
+ if (!auto) {
232
+ const hint = `:carousel [autoplay=3000] … :endcarousel — e.g. ${CAROUSEL_EXAMPLE}`;
233
+ throw wdError(`Malformed :carousel in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
234
+ file: ctx.file,
235
+ line: lineOf(ctx, index),
236
+ hint,
237
+ example: CAROUSEL_EXAMPLE
238
+ });
239
+ }
214
240
  autoplayAttr = ` data-wd-carousel-autoplay="${auto[1]}"`;
215
241
  }
216
242
  ctx.comp.assets.behaviors.add("carousel");
@@ -315,10 +341,15 @@ export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart =
315
341
  // whitelist as `@loop … where` / `.class when` (with `not`), so it folds at
316
342
  // build when static, drives a per-row each-if when it reads the loop item, and
317
343
  // a global if-region (evaluated each render) when it reads state. No raw eval.
318
- if (!condition)
319
- throw new Error(
320
- `Malformed :if in ${at(ctx, index)}: ${line}. Use ":if name" or ":if a <op> b [and|or|not …]".`
321
- );
344
+ if (!condition) {
345
+ const hint = `":if name" or ":if a <op> b [and|or|not …]" — e.g. ${IF_EXAMPLE}`;
346
+ throw wdError(`Malformed :if in ${at(ctx, index)}: ${line}. Use ${hint}`, {
347
+ file: ctx.file,
348
+ line: lineOf(ctx, index),
349
+ hint,
350
+ example: IF_EXAMPLE
351
+ });
352
+ }
322
353
  const compiled = compileWhen(condition, ctx, '":if"');
323
354
  if (compiled.static)
324
355
  return ctx.compileBody(
@@ -328,7 +359,7 @@ export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart =
328
359
  ctx.comp.assets.runtime = true;
329
360
  const truthy = ctx.compileBody(truthyLines, truthyCtx).trim();
330
361
  const falsy = ctx.compileBody(falsyLines, falsyCtx).trim();
331
- const exprAttr = ` data-wd-if-expr="${escapeHtml(compiled.body)}"`;
362
+ const exprAttr = ` data-wd-if-expr="${escapeHtml(serializeExpr(compiled.body))}"`;
332
363
  if (compiled.item) {
333
364
  return `<span data-wd-each-if${exprAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
334
365
  }
package/src/compiler.js CHANGED
@@ -1,7 +1,13 @@
1
1
  // Darkmown's compiler, split into cohesive modules under `src/compiler/`. This
2
- // file is a thin barrel: it re-exports the public API from `src/compiler/index.js`
3
- // so every existing `import … from "./compiler.js"` (builder, tests) keeps
4
- // working unchanged. See `src/compiler/index.js` for the module map.
2
+ // file is the Node-facing barrel: it re-exports the public API from
3
+ // `src/compiler/index.js` so every existing `import … from "./compiler.js"`
4
+ // (builder, tests) keeps working unchanged. It is ALSO the single place the
5
+ // default filesystem reader is injected — `compilePage`/`compileDocument`/
6
+ // `enhanceImages` default their reader to `fsReader()` here, so callers keep
7
+ // their old signatures and the compile stays byte-identical. The fs-free entry
8
+ // point (`compileFromMemory`) needs no default and passes through. Because this
9
+ // module imports `fs-reader.js` (and thus `node:fs`), it is Node-only; the
10
+ // browser bundles `compileFromMemory` from `src/compiler/page.js` directly.
5
11
 
6
12
  // Re-export the public typedefs so existing `import("./compiler.js").Type`
7
13
  // references (e.g. `Assets` in src/builder.js) keep resolving after the split.
@@ -16,12 +22,57 @@
16
22
  * @typedef {import("./compiler/context.js").CompiledPage} CompiledPage
17
23
  */
18
24
 
25
+ import { fsReader } from "./compiler/fs-reader.js";
26
+ import {
27
+ compileDocument as compileDocumentImpl,
28
+ compilePage as compilePageImpl,
29
+ enhanceImages as enhanceImagesImpl
30
+ } from "./compiler/index.js";
31
+
19
32
  export {
20
- compileDocument,
21
- compilePage,
22
- enhanceImages,
33
+ compileFromMemory,
23
34
  escapeHtml,
24
35
  loopKeyOf,
25
36
  parseFrontmatter,
26
37
  stampScope
27
38
  } from "./compiler/index.js";
39
+
40
+ /**
41
+ * Compile a page source file into a full HTML document plus its assets, reading
42
+ * from the real filesystem by default. Pass `options.reader` to compile against
43
+ * a different source (see `compileFromMemory`).
44
+ * @param {string} file Absolute path to the source `.md`/`.wd` file.
45
+ * @param {import("./config.js").Paths} context Resolved project paths.
46
+ * @param {{ feed?: { href: string, title: string }, collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./compiler/reader.js").Reader }} [options]
47
+ * @returns {CompiledPage}
48
+ */
49
+ export function compilePage(file, context, options = {}) {
50
+ return compilePageImpl(file, context, { reader: fsReader(), ...options });
51
+ }
52
+
53
+ /**
54
+ * Compile a source file into its body HTML, frontmatter, and assets (no page
55
+ * shell), reading from the real filesystem by default.
56
+ * @param {string} file Absolute path to the source file.
57
+ * @param {import("./config.js").Paths} context Resolved project paths.
58
+ * @param {string[]} [stack] Include stack for cycle detection.
59
+ * @param {Record<string, unknown>} [vars] Initial static scope variables.
60
+ * @param {Map<string, import("./compiler/collections.js").CollectionRow[]>} [collections]
61
+ * @param {import("./compiler/reader.js").Reader} [reader] Source reader.
62
+ * @returns {CompiledDocument}
63
+ */
64
+ export function compileDocument(file, context, stack, vars, collections, reader = fsReader()) {
65
+ return compileDocumentImpl(file, context, stack, vars, collections, reader);
66
+ }
67
+
68
+ /**
69
+ * Harden every `<img>` in an assembled page body, reading intrinsic dimensions
70
+ * from the real filesystem by default.
71
+ * @param {string} html Assembled page body HTML.
72
+ * @param {import("./config.js").Paths} paths Resolved project paths.
73
+ * @param {import("./compiler/reader.js").Reader} [reader] Source reader for image bytes.
74
+ * @returns {string}
75
+ */
76
+ export function enhanceImages(html, paths, reader = fsReader()) {
77
+ return enhanceImagesImpl(html, paths, reader);
78
+ }
package/src/grammar.js ADDED
@@ -0,0 +1,131 @@
1
+ // ---------------------------------------------------------------------------
2
+ // GBNF grammar generator for `.wd` DIRECTIVE LINES.
3
+ //
4
+ // `generateGrammar()` emits a llama.cpp-compatible GBNF grammar that constrains
5
+ // a small model's decoding to well-formed Darkmown directive lines (plus the
6
+ // `{ path | pipe }` interpolation token). It is generated from the SAME catalog
7
+ // the cheatsheet uses, so the keyword set, `@loop` clauses, comparison operators,
8
+ // and format-pipe names can never drift from what compiles.
9
+ //
10
+ // Scope (stated deliberately): this grammar constrains the SHAPE of a directive
11
+ // line — the leading keyword must be a real directive, `@loop` must be
12
+ // `@loop <src> into <item> …clauses`, a `where`/`:if` condition must be
13
+ // `operand <op> operand`, a `:button` must be `"…" -> <action-chain>`, and an
14
+ // interpolation must pipe through a known formatter. It does NOT try to grammar-
15
+ // check free-form values (arbitrary JSON seeds, URLs, prose) — those are `text`.
16
+ // That is enough to delete the on-device failure class (JS/HTML muscle-memory
17
+ // openers like `::: card class=` or `cart.filter(...)` become unrepresentable)
18
+ // while staying small and maintainable.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ import { directiveCatalog } from "./catalog.js";
22
+
23
+ /** Slugify a directive token into a GBNF rule name (`@loop` → `dir-loop`). */
24
+ /** @type {Record<string, string>} */
25
+ const SLUG = {
26
+ "@include": "dir-include",
27
+ "@loop": "dir-loop",
28
+ ":::": "dir-container",
29
+ ":if": "dir-if",
30
+ ":state": "dir-state",
31
+ ":store": "dir-store",
32
+ ":computed": "dir-computed",
33
+ ":fetch": "dir-fetch",
34
+ ":effect": "dir-effect",
35
+ ":every": "dir-every",
36
+ ":theme": "dir-theme",
37
+ ":button": "dir-button",
38
+ ":form": "dir-form",
39
+ ":input": "dir-input",
40
+ ":textarea": "dir-textarea",
41
+ ":select": "dir-select",
42
+ ":checkbox": "dir-checkbox",
43
+ ":radio": "dir-radio",
44
+ ":submit": "dir-submit",
45
+ ":bind": "dir-bind",
46
+ ":slider": "dir-slider",
47
+ ":video": "dir-video",
48
+ ":audio": "dir-audio",
49
+ ":embed": "dir-embed",
50
+ ":carousel": "dir-carousel"
51
+ };
52
+
53
+ // The directives given a structured RHS below; every other directive gets the
54
+ // generic `"<keyword>" restofline` rule (keyword pinned, argument tail free).
55
+ const STRUCTURED = new Set(["@loop", ":if", ":button", ":::"]);
56
+
57
+ /** A GBNF alternation literal list, e.g. `"a" | "b"`. */
58
+ const alt = (/** @type {string[]} */ items) => items.map((s) => `"${s}"`).join(" | ");
59
+
60
+ /**
61
+ * Generate the GBNF grammar for `.wd` directive lines from the directive catalog.
62
+ * @returns {string}
63
+ */
64
+ export function generateGrammar() {
65
+ const cat = directiveCatalog();
66
+ const dirRules = cat.directives.map((d) => SLUG[d.name]).filter(Boolean);
67
+
68
+ /** @type {string[]} */
69
+ const lines = [];
70
+ lines.push("# Darkmown .wd directive-line grammar — generated by src/grammar.js");
71
+ lines.push(
72
+ "# Constrains a directive line (or a { path | pipe } interpolation). Do not edit by hand."
73
+ );
74
+ lines.push("");
75
+ lines.push("root ::= directive | binding");
76
+ lines.push(`directive ::= ${dirRules.join(" | ")}`);
77
+ lines.push("");
78
+
79
+ // Structured directives.
80
+ lines.push('dir-loop ::= "@loop" sp operand sp "into" sp ident loop-clauses');
81
+ lines.push('dir-if ::= ":if" sp ( condition (sp joiner sp condition)* | path )');
82
+ lines.push('dir-button ::= ":button" sp dqstring sp "->" sp action-chain');
83
+ lines.push('dir-container ::= ":::" restofline');
84
+ lines.push("");
85
+
86
+ // Generic directives: keyword pinned, argument tail free to end of line.
87
+ for (const d of cat.directives) {
88
+ if (STRUCTURED.has(d.name)) continue;
89
+ lines.push(`${SLUG[d.name]} ::= "${d.name}" restofline`);
90
+ }
91
+ lines.push("");
92
+
93
+ // @loop clause vocabulary (mirrors the fixed clause order in the catalog).
94
+ lines.push("loop-clauses ::= ( sp loop-clause )*");
95
+ lines.push(
96
+ 'loop-clause ::= where-clause | "sort by" sp path ( sp ( "asc" | "desc" ) )? | "reverse" | "offset" sp number | "limit" sp number | "paginate" sp number | "sortable"'
97
+ );
98
+ lines.push('where-clause ::= "where" sp condition ( sp joiner sp condition )*');
99
+ lines.push("condition ::= operand sp op sp operand");
100
+ lines.push(`op ::= ${alt(cat.predicateOps.map((o) => o.name))}`);
101
+ lines.push(`joiner ::= ${alt(cat.predicateJoiners.filter((j) => j !== "not"))}`);
102
+ lines.push("");
103
+
104
+ // Button-action vocabulary. Each operator token below is asserted present
105
+ // against the catalog's action tokens in tests/grammar.test.js.
106
+ lines.push('action-chain ::= action ( wsopt ";" wsopt action )*');
107
+ lines.push("action ::= ident action-op");
108
+ lines.push(
109
+ 'action-op ::= "++" | "--" | sp "+=" sp value | sp "-=" sp value | sp "=" sp value | sp "toggle" ( sp value )? | sp "append" sp value | sp "prepend" sp value | sp "remove" sp value | sp "clear" | sp "merge" sp ident | sp "delete" sp value | sp "reset" | sp "refetch"'
110
+ );
111
+ lines.push("");
112
+
113
+ // Interpolation binding with format pipes.
114
+ lines.push('binding ::= "{" wsopt path pipe-chain wsopt "}"');
115
+ lines.push('pipe-chain ::= ( wsopt "|" wsopt pipe ( ":" value )* )*');
116
+ lines.push(`pipe ::= ${alt(cat.formatPipes.map((p) => p.name))}`);
117
+ lines.push("");
118
+
119
+ // Terminals.
120
+ lines.push("operand ::= path | dqstring | number");
121
+ lines.push("value ::= dqstring | number | path");
122
+ lines.push("ident ::= [a-zA-Z_$] [a-zA-Z0-9_$]*");
123
+ lines.push("path ::= [a-zA-Z0-9_$/.~-]+");
124
+ lines.push("number ::= [0-9]+");
125
+ lines.push('dqstring ::= "\\"" [^"]* "\\""');
126
+ lines.push("restofline ::= [^\\n]*");
127
+ lines.push('sp ::= " "+');
128
+ lines.push('wsopt ::= " "*');
129
+ lines.push("");
130
+ return lines.join("\n");
131
+ }