@zvndev/darkmown 2.6.1-next.11.g82bb8d7 → 2.7.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 (73) hide show
  1. package/AGENTS.md +392 -77
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +316 -49
  4. package/docs/cli.md +51 -17
  5. package/docs/spec-alignment.md +25 -4
  6. package/grammar/wd-directives.gbnf +13 -5
  7. package/package.json +7 -3
  8. package/site/_/base.skin +1 -1
  9. package/site/_/footer.wd +1 -1
  10. package/site/pages/api-errors/index.wd +81 -0
  11. package/site/pages/app.wd +1 -1
  12. package/site/pages/attr-binding/index.wd +84 -0
  13. package/site/pages/blog/darkmown-2-7-0.md +176 -0
  14. package/site/pages/blog/darkmown-2-7-0.skin +127 -0
  15. package/site/pages/blog/no-unsafe-eval.md +66 -0
  16. package/site/pages/blog/no-unsafe-eval.skin +127 -0
  17. package/site/pages/blog/zero-js-by-default.md +1 -1
  18. package/site/pages/branch-state/index.wd +68 -0
  19. package/site/pages/docs/index.wd +320 -19
  20. package/site/pages/index.wd +3 -1
  21. package/site/pages/playground/index.js +63 -56
  22. package/site/pages/pulse/index.wd +1 -1
  23. package/site/pages/settings/index.wd +97 -0
  24. package/site/pages/showcase/index.wd +37 -1
  25. package/site/pages/upload/index.wd +63 -0
  26. package/site/pages/url-state/index.wd +70 -0
  27. package/src/builder.js +222 -34
  28. package/src/catalog.js +217 -21
  29. package/src/cli.js +6 -3
  30. package/src/compiler/actions.js +80 -12
  31. package/src/compiler/body.js +104 -28
  32. package/src/compiler/collections.js +29 -13
  33. package/src/compiler/context.js +9 -2
  34. package/src/compiler/expr-ast.js +14 -0
  35. package/src/compiler/fetch.js +8 -4
  36. package/src/compiler/forms.js +133 -14
  37. package/src/compiler/frontmatter.js +28 -7
  38. package/src/compiler/includes.js +46 -6
  39. package/src/compiler/interpolation.js +56 -1
  40. package/src/compiler/loops.js +489 -35
  41. package/src/compiler/markdown.js +457 -44
  42. package/src/compiler/page.js +8 -3
  43. package/src/compiler/predicates.js +84 -14
  44. package/src/compiler/state.js +167 -18
  45. package/src/compiler/structure.js +159 -29
  46. package/src/errors.js +111 -3
  47. package/src/feeds.js +140 -7
  48. package/src/grammar.js +53 -10
  49. package/src/runtime.js +509 -130
  50. package/src/runtime.min.js +2 -0
  51. package/src/runtime.min.js.map +1 -0
  52. package/src/statics.js +46 -2
  53. package/src/templates/blog/README.md +2 -0
  54. package/src/templates/dashboard/README.md +2 -0
  55. package/src/templates/landing/README.md +2 -0
  56. package/src/templates/starter/README.md +1 -1
  57. package/src/templates/store/README.md +2 -0
  58. package/src/tools/validate.js +14 -4
  59. package/types/builder.d.ts +6 -2
  60. package/types/catalog.d.ts +12 -0
  61. package/types/compiler/actions.d.ts +1 -1
  62. package/types/compiler/body.d.ts +0 -3
  63. package/types/compiler/forms.d.ts +12 -1
  64. package/types/compiler/frontmatter.d.ts +8 -1
  65. package/types/compiler/includes.d.ts +16 -2
  66. package/types/compiler/interpolation.d.ts +23 -0
  67. package/types/compiler/loops.d.ts +47 -0
  68. package/types/compiler/markdown.d.ts +63 -1
  69. package/types/compiler/predicates.d.ts +7 -1
  70. package/types/compiler/state.d.ts +8 -2
  71. package/types/compiler/structure.d.ts +32 -1
  72. package/types/feeds.d.ts +68 -2
  73. package/types/statics.d.ts +25 -0
@@ -7,7 +7,7 @@
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
9
  import { at, createScope, lineOf, nestedCtx, recordSymbol, wdError } from "./context.js";
10
- import { serializeExpr } from "./expr-ast.js";
10
+ import { astOf, evalAst, serializeExpr } from "./expr-ast.js";
11
11
  import { applyPipeline, stagesFromAttr } from "./format.js";
12
12
  import { resolveInclude } from "./includes.js";
13
13
  import {
@@ -19,10 +19,12 @@ import {
19
19
  stripQuotes,
20
20
  unescapeHtml
21
21
  } from "./interpolation.js";
22
+ import { encodeDest, unsafeUrlValue } from "./markdown.js";
22
23
  import { compilePredicate, evalPredicate } from "./predicates.js";
23
24
 
24
25
  /**
25
26
  * @typedef {import("./context.js").Ctx} Ctx
27
+ * @typedef {import("./context.js").Compilation} Compilation
26
28
  * @typedef {import("./context.js").Predicate} Predicate
27
29
  * @typedef {import("./context.js").NumArg} NumArg
28
30
  * @typedef {import("./context.js").LoopOpts} LoopOpts
@@ -37,6 +39,121 @@ export const LOOP_EXAMPLE = "@loop /products.json into p where p.price < 50 sort
37
39
  // The fixed corrective suggestion shown for any malformed @loop header.
38
40
  const LOOP_USAGE = `Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N] [paginate N] [sortable] — e.g. ${LOOP_EXAMPLE}`;
39
41
 
42
+ // The corrective suggestion for an expression the AST re-parser cannot read.
43
+ const EXPR_USAGE =
44
+ 'Use simpler operands — a field path, a declared :state, a plain number, or a "string".';
45
+
46
+ /**
47
+ * Serialize an already-validated expression fragment into the compact AST
48
+ * attribute payload the runtime reads, turning a residual parse failure into a
49
+ * CODED compile error with `file:line` instead of the AST layer's raw internal
50
+ * `Error`.
51
+ *
52
+ * `expr-ast.js` re-parses the compiler's own output, so a failure there is not
53
+ * an author mistake and it throws a plain `Error` by design. But the operand
54
+ * folds in `predicates.js` splice BUILD-TIME VALUES into that output, and a
55
+ * value the fold cannot render as a readable literal escapes as an uncoded
56
+ * `expr-ast: …` error with no file, no line, and no suggestion — the one gap in
57
+ * the "every author-facing error carries a code" invariant. This is the seam
58
+ * where that becomes an ordinary Darkmown compile error again.
59
+ *
60
+ * Lives here rather than beside its `structure.js` callers because the error-code
61
+ * registry pins each source file to a subsystem block (`compiler/loops.js` owns
62
+ * WD1xx), and this guard's reserved code is WD190.
63
+ * @param {string} code Validated JS fragment over `I()`/`S()`/`C()`/`A()`.
64
+ * @param {Ctx} ctx
65
+ * @param {number} index 0-based line index of the directive that owns it.
66
+ * @param {string} what Directive label for the message, e.g. `":if"`.
67
+ * @returns {any[]} The parsed AST.
68
+ */
69
+ export function astAt(code, ctx, index, what) {
70
+ try {
71
+ return astOf(code);
72
+ } catch {
73
+ throw wdError(
74
+ `${what} expression in ${at(ctx, index)} could not be compiled: "${code}". ${EXPR_USAGE}`,
75
+ {
76
+ code: "WD190",
77
+ file: ctx.file,
78
+ line: lineOf(ctx, index),
79
+ hint: EXPR_USAGE.slice("Use ".length)
80
+ }
81
+ );
82
+ }
83
+ }
84
+
85
+ /**
86
+ * `JSON.stringify(astAt(…))` — the attribute payload, with the same coded error.
87
+ * @param {string} code
88
+ * @param {Ctx} ctx
89
+ * @param {number} index
90
+ * @param {string} what
91
+ * @returns {string}
92
+ */
93
+ export const serializeExprAt = (code, ctx, index, what) =>
94
+ JSON.stringify(astAt(code, ctx, index, what));
95
+
96
+ // The clause patterns, each opening with `(^|\s)` so the keyword's own start is
97
+ // `m.index + m[1].length`. `g`-flagged so {@link matchClause} can keep scanning
98
+ // past an occurrence that turned out to be inside a quoted `where` operand.
99
+ const SORTABLE_RE = /(^|\s)sortable(?=\s|$)/g;
100
+ const PAGINATE_RE = /(^|\s)paginate\s+(\d+)(?=\s|$)/g;
101
+ const LIMIT_RE = /(^|\s)limit\s+(\d+|[A-Za-z_$][\w$]*)$/g;
102
+ const OFFSET_RE = /(^|\s)offset\s+(\d+|[A-Za-z_$][\w$]*)$/g;
103
+ const REVERSE_RE = /(^|\s)reverse$/g;
104
+ const SORT_RE =
105
+ /(^|\s)sort\s+by\s+(\{\s*[A-Za-z_$][\w$]*\s*\}|[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)(?:\s+(\{\s*[A-Za-z_$][\w$]*\s*\}|asc|desc))?$/g;
106
+ // A clause keyword left INSIDE the `where` remainder — the out-of-order signal.
107
+ const CLAUSE_IN_WHERE_RE = /(^|\s)(sort\s+by\s|reverse(\s|$))/g;
108
+
109
+ /**
110
+ * Which characters of a `@loop` clause tail sit inside a quoted operand.
111
+ *
112
+ * The clause peels are plain regexes over the whole tail, so they could not tell
113
+ * a real clause from the same word inside a `where` string literal: `where
114
+ * p.note contains "a sortable b"` peeled a `sortable` flag out of the middle of
115
+ * the string (then failed as "sortable cannot combine with where"), and `where
116
+ * p.t contains "we sort by hand"` was rejected outright as out-of-order clauses.
117
+ * Same defect, and the same remedy, as the quote-aware `and`/`or` split in
118
+ * `predicates.js`. Opening and closing quotes count as inside, so a keyword can
119
+ * never be read out of a literal.
120
+ * @param {string} s
121
+ * @returns {boolean[]}
122
+ */
123
+ function quotedMask(s) {
124
+ const mask = new Array(s.length).fill(false);
125
+ let quote = "";
126
+ for (let i = 0; i < s.length; i++) {
127
+ if (quote) {
128
+ mask[i] = true;
129
+ if (s[i] === quote) quote = "";
130
+ } else if (s[i] === '"' || s[i] === "'") {
131
+ quote = s[i];
132
+ mask[i] = true;
133
+ }
134
+ }
135
+ return mask;
136
+ }
137
+
138
+ /**
139
+ * The first match of a clause pattern whose KEYWORD begins outside a quoted
140
+ * operand, or null when the only occurrences are quoted.
141
+ * @param {string} s
142
+ * @param {RegExp} re One of the `g`-flagged clause patterns above.
143
+ * @returns {RegExpExecArray | null}
144
+ */
145
+ function matchClause(s, re) {
146
+ const mask = quotedMask(s);
147
+ re.lastIndex = 0;
148
+ for (let m = re.exec(s); m; m = re.exec(s)) {
149
+ if (!mask[m.index + m[1].length]) return m;
150
+ // Resume one character in, not past the whole match: a real clause can begin
151
+ // inside the span a quoted occurrence just consumed.
152
+ re.lastIndex = m.index + 1;
153
+ }
154
+ return null;
155
+ }
156
+
40
157
  /**
41
158
  * Parse the optional clause tail of a `@loop` header in FIXED order:
42
159
  * `[where P] [sort by key [asc|desc]] [reverse] [offset N] [limit N] [paginate N] [sortable]`.
@@ -48,9 +165,11 @@ const LOOP_USAGE = `Use: @loop src into item [where …] [sort by …] [reverse]
48
165
  function parseLoopClauses(tail, itemName, ctx) {
49
166
  // Peel clauses off the END in reverse fixed-order (limit, offset, reverse,
50
167
  // sort by). Parsing from the tail avoids mistaking a state operand named
51
- // `limit`/`offset` inside the `where` predicate for a clause keyword. Whatever
52
- // remains must be `where …` (or empty); a clause keyword surviving in the
53
- // remainder means the author wrote the clauses out of order.
168
+ // `limit`/`offset` inside the `where` predicate for a clause keyword, and
169
+ // every peel goes through `matchClause`, which ignores a keyword sitting
170
+ // inside a quoted `where` operand. Whatever remains must be `where …` (or
171
+ // empty); a clause keyword surviving UNQUOTED in the remainder means the
172
+ // author wrote the clauses out of order.
54
173
  let s = tail.trim();
55
174
  /** @type {import("./context.js").LoopOpts["sort"]} */ let sort = null;
56
175
  let reverse = false;
@@ -70,7 +189,7 @@ function parseLoopClauses(tail, itemName, ctx) {
70
189
  // `sortable` is a position-independent flag (drag-reorder); peel it wherever it
71
190
  // sits so the rest of the fixed-order parse is unaffected. handleLoop rejects it
72
191
  // when combined with where/sort/reverse/offset/limit.
73
- const sm = s.match(/(^|\s)sortable(?=\s|$)/);
192
+ const sm = matchClause(s, SORTABLE_RE);
74
193
  if (sm) {
75
194
  sortable = true;
76
195
  s = (s.slice(0, sm.index) + s.slice((sm.index ?? 0) + sm[0].length))
@@ -83,7 +202,7 @@ function parseLoopClauses(tail, itemName, ctx) {
83
202
  // can reject the combination with a precise message rather than failing the
84
203
  // fixed-order parse. The count is a positive integer literal — paginating "by a
85
204
  // state value" makes no sense for a build-time route split.
86
- const pm = s.match(/(^|\s)paginate\s+(\d+)(?=\s|$)/);
205
+ const pm = matchClause(s, PAGINATE_RE);
87
206
  if (pm) {
88
207
  paginate = Number(pm[2]);
89
208
  if (paginate < 1) bad();
@@ -92,21 +211,21 @@ function parseLoopClauses(tail, itemName, ctx) {
92
211
  .trim();
93
212
  }
94
213
 
95
- let m = s.match(/(^|\s)limit\s+(\d+|[A-Za-z_$][\w$]*)$/);
214
+ let m = matchClause(s, LIMIT_RE);
96
215
  if (m) {
97
216
  limit = parseNumArg(m[2], ctx);
98
217
  refsState = refsState || limit.kind === "key";
99
218
  s = s.slice(0, s.length - m[0].length + m[1].length).trim();
100
219
  }
101
220
 
102
- m = s.match(/(^|\s)offset\s+(\d+|[A-Za-z_$][\w$]*)$/);
221
+ m = matchClause(s, OFFSET_RE);
103
222
  if (m) {
104
223
  offset = parseNumArg(m[2], ctx);
105
224
  refsState = refsState || offset.kind === "key";
106
225
  s = s.slice(0, s.length - m[0].length + m[1].length).trim();
107
226
  }
108
227
 
109
- m = s.match(/(^|\s)reverse$/);
228
+ m = matchClause(s, REVERSE_RE);
110
229
  if (m) {
111
230
  reverse = true;
112
231
  s = s.slice(0, s.length - m[0].length + m[1].length).trim();
@@ -114,9 +233,7 @@ function parseLoopClauses(tail, itemName, ctx) {
114
233
 
115
234
  // The field and direction may each be a literal (`post.date` / `asc|desc`) or a
116
235
  // `{ state }` reference, which makes the sort reactive (clickable-header tables).
117
- m = s.match(
118
- /(^|\s)sort\s+by\s+(\{\s*[A-Za-z_$][\w$]*\s*\}|[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)(?:\s+(\{\s*[A-Za-z_$][\w$]*\s*\}|asc|desc))?$/
119
- );
236
+ m = matchClause(s, SORT_RE);
120
237
  if (m) {
121
238
  sort = parseSortClause(m[2], m[3], itemName, ctx);
122
239
  if (sort.keyKind === "key" || sort.dirKind === "key") refsState = true;
@@ -125,12 +242,14 @@ function parseLoopClauses(tail, itemName, ctx) {
125
242
 
126
243
  /** @type {string|null} */ let where = null;
127
244
  if (s.length) {
128
- m = s.match(/^where\s+(.+)$/);
129
- if (!m) return bad(); // leftover non-where text → clauses written out of order
130
- where = m[1].trim();
245
+ const head = s.match(/^where\s+(.+)$/);
246
+ if (!head) return bad(); // leftover non-where text → clauses written out of order
247
+ where = head[1].trim();
131
248
  // `sort by`/`reverse` are never valid where operands; their presence in the
132
249
  // predicate means a clause was written before `where` finished (wrong order).
133
- if (/(^|\s)(sort\s+by\s|reverse(\s|$))/.test(where)) bad();
250
+ // Quoted text is not a clause, so `where p.t contains "we sort by hand"` is
251
+ // an ordinary string comparison, not a misplaced `sort by`.
252
+ if (matchClause(where, CLAUSE_IN_WHERE_RE)) bad();
134
253
  }
135
254
  return { where, sort, reverse, offset, limit, refsState, sortable, paginate };
136
255
  }
@@ -541,7 +660,10 @@ function pipelineRows(rows, where, opts, ctx) {
541
660
  typeof av === "number" && typeof bv === "number"
542
661
  ? av - bv
543
662
  : String(av).localeCompare(String(bv));
544
- return (c || a.index - b.index) * dir;
663
+ // The index tiebreaker keeps EQUAL rows in source order, so it must not
664
+ // be flipped with the comparator: `(c || tie) * dir` negated the tie too
665
+ // and made `desc` unstable, reversing runs of equal keys.
666
+ return c ? c * dir : a.index - b.index;
545
667
  })
546
668
  .map((w) => w.value);
547
669
  }
@@ -593,6 +715,18 @@ function loopOverData(rows, itemName, bodyLines, ctx, opts) {
593
715
  function staticUnroll(rows, itemName, bodyLines, ctx, empty = null, emptyStart = 0) {
594
716
  if (rows.length === 0 && empty)
595
717
  return ctx.compileBody(empty, { ...nestedCtx(ctx, emptyStart), loopMeta: true });
718
+ // A body of BARE table rows (`| { p.name } | { p.n } |`, no `|---|` of its
719
+ // own) is the natural way to write the rows of a table whose header sits in
720
+ // the prose above the loop. Markdown needs a header to see a table at all, so
721
+ // each row compiled to a `<p>| Alpha | 3 |</p>` paragraph. Compile every row
722
+ // under a SYNTHESIZED header instead — the row seam then splices the one-row
723
+ // tables into one, and the synthetic `<thead>` is stripped off the result, so
724
+ // the loop emits a body-only `<table><tbody>…</tbody></table>` that the
725
+ // body-level seam splices onto the author's header table.
726
+ const pipe = pipeRowShape(bodyLines);
727
+ const lines = pipe
728
+ ? [pipeHeader(pipe.columns), pipeSeparator(pipe.columns), ...bodyLines]
729
+ : bodyLines;
596
730
  const out = [];
597
731
  const count = rows.length;
598
732
  for (let i = 0; i < count; i++) {
@@ -603,14 +737,219 @@ function staticUnroll(rows, itemName, bodyLines, ctx, empty = null, emptyStart =
603
737
  $last: i === count - 1,
604
738
  $count: count
605
739
  };
740
+ /** @type {Ctx} */
606
741
  const rowCtx = {
607
- ...ctx,
742
+ // The two synthesized lines shift every real body line by 2, so shift the
743
+ // slice offset back by 2 — a warning raised inside a row still reports the
744
+ // line the author actually wrote.
745
+ ...(pipe ? nestedCtx(ctx, -2) : ctx),
608
746
  loopMeta: true,
609
747
  scope: createScope(ctx.scope, { [itemName]: rows[i], ...meta })
610
748
  };
611
- out.push(ctx.compileBody(bodyLines, rowCtx));
749
+ out.push(ctx.compileBody(lines, rowCtx));
612
750
  }
613
- return out.join("\n");
751
+ const html = joinRows(out);
752
+ // GLOBAL: one synthesized header per compiled row body, and the row seam does
753
+ // not always merge them into one table (a `@loop` whose rows are split by a
754
+ // structure the splice declines). Stripping only the first left the rest on
755
+ // the page as `<th>c</th>`.
756
+ return pipe ? html.replace(/<thead>[\s\S]*?<\/thead>\n?/g, "") : html;
757
+ }
758
+
759
+ // One synthesized markdown table header + separator, wide enough for the body
760
+ // rows underneath. The cells are never rendered (the `<thead>` is stripped back
761
+ // off), they exist only so markdown-it recognises the rows as a table.
762
+ /** @param {number} columns @returns {string} */
763
+ const pipeHeader = (columns) => `|${" c |".repeat(columns)}`;
764
+ /** @param {number} columns @returns {string} */
765
+ const pipeSeparator = (columns) => `|${" --- |".repeat(columns)}`;
766
+
767
+ /**
768
+ * Whether a loop body is a run of BARE markdown table rows — every non-blank
769
+ * line a `|…|` row, and no `|---|` separator of its own — and how many columns
770
+ * the first row has.
771
+ *
772
+ * A body that carries its own separator is a COMPLETE table per row; that shape
773
+ * already works (each row compiles to a one-row table and the row seam splices
774
+ * their `<tbody>`s), so it is deliberately not claimed here.
775
+ * @param {string[]} bodyLines
776
+ * @returns {{ columns: number } | null}
777
+ */
778
+ function pipeRowShape(bodyLines) {
779
+ // TRAILING blank lines are harmless — nothing follows them to re-open a table —
780
+ // so they are trimmed off the shape test. A LEADING or INTERIOR blank line is
781
+ // not: a blank line ENDS a markdown table, so the synthesized header would only
782
+ // cover the run of rows before it and every later run would form its own table
783
+ // with a visible `| c | c |` header. The `^ {0,3}\|` test rejects both (a blank
784
+ // line has no pipe) and also rejects a 4-space-indented row, which markdown
785
+ // reads as an indented code block, not a table row. Filtering blanks out and
786
+ // trimming each line — what this used to do — hid all three cases and shipped
787
+ // `<th>c</th>` to the page.
788
+ let end = bodyLines.length;
789
+ while (end > 0 && !bodyLines[end - 1].trim()) end--;
790
+ const rows = bodyLines.slice(0, end);
791
+ if (!rows.length || !rows.every((line) => /^ {0,3}\|/.test(line))) return null;
792
+ if (rows.some((line) => /^ {0,3}\|[\s:|-]+\|\s*$/.test(line))) return null;
793
+ return { columns: cellCount(rows[0]) };
794
+ }
795
+
796
+ /**
797
+ * Cells in one markdown table row. Counts `|` exactly the way markdown-it does
798
+ * — a backslash escapes the next character — so the synthesized header has the
799
+ * same column count markdown-it will read off the body rows.
800
+ * @param {string} row
801
+ * @returns {number}
802
+ */
803
+ function cellCount(row) {
804
+ const inner = row.trim().replace(/^\|/, "").replace(/\|$/, "");
805
+ let cells = 1;
806
+ for (let i = 0; i < inner.length; i++) {
807
+ if (inner[i] === "\\") i++;
808
+ else if (inner[i] === "|") cells++;
809
+ }
810
+ return cells;
811
+ }
812
+
813
+ /**
814
+ * The compile error for a REACTIVE `@loop` over bare table rows. The runtime
815
+ * clones each row out of a `<template>` into a `<div data-wd-loop-piece>`, and a
816
+ * `<div>` cannot live inside a `<table>`, so there is no correct HTML to emit —
817
+ * it used to ship silent `<p>| … |</p>` paragraphs instead.
818
+ * @param {Ctx} ctx
819
+ * @param {Partial<LoopOpts> & { data?: unknown[] } | null | undefined} opts
820
+ * @param {string | null} sourceKey State key/path of the list, or null when the
821
+ * source is a build-time list and a CLAUSE is what made the loop reactive.
822
+ * @returns {Error}
823
+ */
824
+ function reactiveTableRows(ctx, opts, sourceKey) {
825
+ const opener = ctx.loopOpener ?? { at: ctx.file, line: "" };
826
+ // A loop whose SOURCE is a static list (`sourceKey === null` — a JSON file, a
827
+ // frontmatter list, a collection) is reactive only because a CLAUSE reads
828
+ // `:state`. Telling that author to "use a static source" points at the one
829
+ // thing already correct; the fix is in the clause, so name the state it reads.
830
+ const names = sourceKey === null ? clauseStateNames(opts) : [];
831
+ const quoted = names.map((name) => `"${name}"`).join(", ");
832
+ const why = names.length
833
+ ? ` The source is static; the clauses read :state ${quoted}, which is what makes the loop reactive.`
834
+ : "";
835
+ const hint = names.length
836
+ ? `drop ${quoted} from the clauses so the rows unroll at build time, or build reactive rows ` +
837
+ "from containers (`::: trow` / `::: td`) instead of `|` cells"
838
+ : "a static source (a JSON file, a frontmatter list, or a collection) for a markdown table, " +
839
+ "or build reactive rows from containers (`::: trow` / `::: td`) instead of `|` cells";
840
+ return wdError(
841
+ `A reactive @loop cannot build markdown table rows in ${opener.at}: "${opener.line}". ` +
842
+ `A reactive row is cloned into a <div>, which cannot live inside a <table>.${why} Use: ${hint}.`,
843
+ { code: "WD191", file: ctx.file, hint }
844
+ );
845
+ }
846
+
847
+ // The `S("name"…)` readers a compiled `where` predicate emits.
848
+ const WHERE_STATE_RE = /\bS\("((?:[^"\\]|\\.)*)"/g;
849
+
850
+ /**
851
+ * The declared-state names a loop's CLAUSES read: the `where` predicate's `S()`
852
+ * readers plus any `limit`/`offset`/`sort by` argument that is a state key.
853
+ * @param {Partial<LoopOpts> & { data?: unknown[] } | null | undefined} opts
854
+ * @returns {string[]}
855
+ */
856
+ function clauseStateNames(opts) {
857
+ /** @type {Set<string>} */
858
+ const names = new Set();
859
+ for (const m of (opts?.where?.body ?? "").matchAll(WHERE_STATE_RE))
860
+ names.add(JSON.parse(`"${m[1]}"`));
861
+ for (const arg of [opts?.limit, opts?.offset]) if (arg?.kind === "key") names.add(arg.value);
862
+ if (opts?.sort?.keyKind === "key") names.add(opts.sort.key);
863
+ if (opts?.sort?.dirKind === "key") names.add(opts.sort.dir);
864
+ return [...names];
865
+ }
866
+
867
+ // The block tags a static unroll stitches back together across a row seam. Each
868
+ // row body is compiled as its OWN markdown document, so a row whose body is a
869
+ // list item closes its own `<ul>`/`<ol>` and a row whose body is a table closes
870
+ // its own `<table>`: `- { p.name }` over three rows produced three separate
871
+ // lists, `1. { p.name }` produced three lists each numbered 1, and a table body
872
+ // produced three tables. Splicing at the seam gives the ONE list / ONE correctly
873
+ // numbered list / ONE table an author wrote.
874
+ const SEAM_TAGS = ["ul", "ol"];
875
+
876
+ /**
877
+ * Join the compiled row bodies of a static unroll, splicing a block that a row
878
+ * seam split in two back into one element. A seam that does not match any
879
+ * mergeable shape falls back to the plain `"\n"` join the unroll always used, so
880
+ * every other body shape emits byte-identical HTML.
881
+ * @param {string[]} parts Compiled HTML, one entry per row.
882
+ * @returns {string}
883
+ */
884
+ function joinRows(parts) {
885
+ if (!parts.length) return "";
886
+ let html = parts[0];
887
+ for (let i = 1; i < parts.length; i++) html = spliceRow(html, parts[i]);
888
+ return html;
889
+ }
890
+
891
+ /**
892
+ * Splice one row's HTML onto the accumulated output at their shared seam.
893
+ * @param {string} prev Everything emitted so far.
894
+ * @param {string} next The next row's compiled HTML.
895
+ * @returns {string}
896
+ */
897
+ function spliceRow(prev, next) {
898
+ const left = prev.trimEnd();
899
+ const right = next.trimStart();
900
+ for (const tag of SEAM_TAGS) {
901
+ if (left.endsWith(`</${tag}>`) && right.startsWith(`<${tag}>`)) {
902
+ return `${left.slice(0, -(tag.length + 3)).trimEnd()}\n${right.slice(tag.length + 2).trimStart()}`;
903
+ }
904
+ }
905
+ return spliceTables(prev, next) ?? `${prev}\n${next}`;
906
+ }
907
+
908
+ /**
909
+ * Splice two adjacent tables into one, in the two shapes a `.wd` body produces:
910
+ *
911
+ * 1. Two tables that carry the SAME markup before `<tbody>` — one compiled row
912
+ * body per loop row. Their `<tbody>` contents concatenate. Requiring the
913
+ * prefixes to match is what keeps a header that interpolates the row (a
914
+ * genuinely different table per row) from losing every header but the first.
915
+ * 2. A HEADER-ONLY table (a `<thead>` and no `<tbody>`: what `| Name | N |` +
916
+ * `|---|---|` in the prose above a loop compiles to) followed by a BODY-ONLY
917
+ * table (a `<tbody>` and no `<thead>`: what a loop over bare `|…|` rows now
918
+ * emits). The body moves inside the header's table.
919
+ *
920
+ * Exported because both seams matter: the loop's own row seam (case 1) and the
921
+ * body-level seam between flushed prose and a handler's output (case 2), which
922
+ * `compileBody` owns.
923
+ * @param {string} prev
924
+ * @param {string} next
925
+ * @returns {string | null} The spliced HTML, or null when they do not merge.
926
+ */
927
+ export function spliceTables(prev, next) {
928
+ const left = prev.trimEnd();
929
+ const right = next.trimStart();
930
+ if (!left.endsWith("</table>") || !right.startsWith("<table>")) return null;
931
+ const rightBody = right.indexOf("<tbody>");
932
+ if (rightBody === -1) return null;
933
+ const leftBody = left.lastIndexOf("</tbody>");
934
+ const tail = right.slice(rightBody + "<tbody>".length).trimStart();
935
+ if (leftBody === -1) {
936
+ // Case 2: a header-only table absorbs the body-only table's rows. Both
937
+ // halves must be exactly that — a right side with its own `<thead>` is a
938
+ // complete table of its own and stays separate.
939
+ if (!left.includes("<thead>") || right.includes("<thead>")) return null;
940
+ return `${left.slice(0, left.lastIndexOf("</table>")).trimEnd()}\n<tbody>\n${tail}`;
941
+ }
942
+ // Case 2b: the left table already has a body — the author wrote a fixed row
943
+ // under the header before the loop. A right side with NO `<thead>` is body-only
944
+ // output (a loop over bare `|…|` rows, whose synthesized header was stripped),
945
+ // so its rows belong in that body whatever the header above them says. Without
946
+ // this the fixed row forced two tables.
947
+ if (!right.includes("<thead>")) return `${left.slice(0, leftBody).trimEnd()}\n${tail}`;
948
+ // Case 1: same header, concatenated bodies. A header that INTERPOLATES the row
949
+ // is genuinely a table per row, and merging would drop every header but the
950
+ // first — so two headered tables only merge when their headers are identical.
951
+ if (left.slice(0, left.indexOf("<tbody>")) !== right.slice(0, rightBody)) return null;
952
+ return `${left.slice(0, leftBody).trimEnd()}\n${tail}`;
614
953
  }
615
954
 
616
955
  /**
@@ -626,6 +965,7 @@ function staticUnroll(rows, itemName, bodyLines, ctx, empty = null, emptyStart =
626
965
  */
627
966
  function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
628
967
  assertReactiveDepth(ctx);
968
+ if (pipeRowShape(bodyLines)) throw reactiveTableRows(ctx, opts, key);
629
969
  ctx.comp.assets.runtime = true;
630
970
  /** @type {Ctx} */
631
971
  const templateCtx = {
@@ -657,12 +997,26 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
657
997
  /** @param {NumArg|null} a */
658
998
  const num = (a) =>
659
999
  a ? (a.kind === "literal" ? a.value : Number(ctx.comp.state.get(a.value) ?? 0)) : null;
1000
+ // A reactive `sort by { field }` / `{ dir }` carries the resolved STATE KEY in
1001
+ // `sort.key`/`sort.dir`, not a field path and not `asc`/`desc` — the runtime
1002
+ // reads the current value per render. The initial paint has to resolve it the
1003
+ // same way (exactly as `num()` already does for a state-keyed offset/limit),
1004
+ // or the key is used as a literal field path (which no row has), the direction
1005
+ // never equals "desc", and the pre-hydration HTML ships in source order and
1006
+ // visibly reorders the moment the runtime boots.
1007
+ const initialSort = sort
1008
+ ? {
1009
+ ...sort,
1010
+ key: sort.keyKind === "key" ? String(ctx.comp.state.get(sort.key) ?? "") : sort.key,
1011
+ dir: sort.dirKind === "key" ? String(ctx.comp.state.get(sort.dir) ?? "asc") : sort.dir
1012
+ }
1013
+ : null;
660
1014
  const rows = pipelineRows(
661
1015
  allRows,
662
1016
  where,
663
1017
  {
664
1018
  where,
665
- sort,
1019
+ sort: initialSort,
666
1020
  reverse,
667
1021
  offset:
668
1022
  offset && offset.kind === "key" ? { kind: "literal", value: num(offset) || 0 } : offset,
@@ -688,7 +1042,10 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
688
1042
  .map((/** @type {unknown} */ item, i) => {
689
1043
  const itemKey = loopKeyOf(item, counts);
690
1044
  const meta = { index: i, number: i + 1, first: i === 0, last: i === count - 1, count };
691
- return unmaskItemLoops(fillTemplateString(withLoopKey(masked, itemKey), item, meta), regions);
1045
+ return unmaskItemLoops(
1046
+ fillTemplateString(withLoopKey(masked, itemKey), item, meta, ctx.comp),
1047
+ regions
1048
+ );
692
1049
  })
693
1050
  .join("");
694
1051
 
@@ -777,6 +1134,9 @@ function loopClauseAttrs(c) {
777
1134
  */
778
1135
  function itemRelativeLoop(path, itemName, bodyLines, ctx, opts) {
779
1136
  assertReactiveDepth(ctx);
1137
+ // An item-relative loop's source IS the outer reactive row, so no clause can
1138
+ // be the cause: the source branch of the hint is always the right one.
1139
+ if (pipeRowShape(bodyLines)) throw reactiveTableRows(ctx, opts, path);
780
1140
  /** @type {Ctx} */
781
1141
  // loopKey: undefined — an item-relative loop's source is a path off the outer
782
1142
  // row, not a top-level state key, so a per-row `remove` inside it has no valid
@@ -853,13 +1213,84 @@ function withLoopKey(template, itemKey) {
853
1213
  /**
854
1214
  * @param {string} template
855
1215
  * @param {unknown} item
856
- * @param {Record<string, unknown>} [meta] Per-row meta values for the initial paint.
1216
+ * @param {Record<string, unknown>} meta Per-row meta values for the initial paint.
1217
+ * @param {Compilation} comp Declared state, for a per-row `:if` predicate that
1218
+ * also reads `:state`/`:store`.
857
1219
  * @returns {string}
858
1220
  */
859
- function fillTemplateString(template, item, meta = {}) {
1221
+ function fillTemplateString(template, item, meta, comp) {
860
1222
  // Resolve per-item :if regions first (recursively, so nested conditionals are
861
- // pre-rendered for the initial paint), then fill text binds and meta markers.
862
- return fillEachMeta(fillEachText(fillEachIfRegions(template, item, meta), item), meta);
1223
+ // pre-rendered for the initial paint), then fill text binds, meta markers and
1224
+ // bound destinations.
1225
+ return fillEachAttr(
1226
+ fillEachMeta(fillEachText(fillEachIfRegions(template, item, meta, comp), item), meta),
1227
+ item,
1228
+ meta,
1229
+ comp
1230
+ );
1231
+ }
1232
+
1233
+ // An open tag carrying a per-row bound destination. Attribute values are
1234
+ // HTML-escaped, so `>` cannot appear inside one and `[^>]*` cannot overrun the
1235
+ // tag.
1236
+ const EACH_ATTR_TAG = /<[a-zA-Z][a-zA-Z0-9-]*[^>]*\sdata-wd-each-attr="([^"]*)"[^>]*>/g;
1237
+
1238
+ /**
1239
+ * Fill a per-row bound destination (`data-wd-each-attr`) for the initial paint.
1240
+ *
1241
+ * The template is the one `attrBindPlugin` stamped: `[name, …parts]`, where a
1242
+ * part is either a literal chunk or a reader — `["i", path]` off the row,
1243
+ * `["m", name]` off the row meta, `["s", key, path]` off declared state. The
1244
+ * build-time seed on the element is deliberately EMPTY for the reader parts (one
1245
+ * template serves every row), so without this fill every painted row shipped the
1246
+ * literal skeleton — `href="/p//"` — to a crawler and to a no-JS reader.
1247
+ *
1248
+ * The assembly mirrors the compile-time seed in `attrBindPlugin` exactly:
1249
+ * `encodeDest` on each resolved value (a destination has no quoting, so an
1250
+ * unencoded `)` or space breaks the link), then the scheme guard on the WHOLE
1251
+ * assembled value, so a row that assembles to `javascript:…` paints empty.
1252
+ * @param {string} str
1253
+ * @param {unknown} item
1254
+ * @param {Record<string, unknown>} meta
1255
+ * @param {Compilation} comp
1256
+ * @returns {string}
1257
+ */
1258
+ function fillEachAttr(str, item, meta, comp) {
1259
+ return str.replace(EACH_ATTR_TAG, (tag, template) => {
1260
+ // The compiler serialized this template itself (`attrBindPlugin`), so the
1261
+ // parse cannot fail, the shape is `[name, …parts]`, and `name` (`href`/`src`)
1262
+ // is always already on the tag as the empty seed — the same
1263
+ // compiler-wrote-it-so-it-parses contract the `data-wd-if-expr` AST next door
1264
+ // relies on.
1265
+ const [name, ...parts] =
1266
+ /** @type {[string, ...(string | [string, string] | [string, string, string])[]]} */ (
1267
+ JSON.parse(unescapeHtml(template))
1268
+ );
1269
+ let out = "";
1270
+ for (const part of parts) {
1271
+ if (typeof part === "string") {
1272
+ out += part;
1273
+ continue;
1274
+ }
1275
+ const value =
1276
+ part[0] === "s"
1277
+ ? getPath(comp.state.get(part[1]), part[2] ? part[2].split(".") : [])
1278
+ : part[0] === "m"
1279
+ ? meta[part[1]]
1280
+ : getPath(item, part[1] ? part[1].split(".") : []);
1281
+ out += encodeDest(value ?? "");
1282
+ }
1283
+ // Splice the value between the seed's own quotes: `name` never reaches a
1284
+ // RegExp, and the closing quote is located on the ORIGINAL tag, so an escaped
1285
+ // `&quot;` in the painted value cannot be mistaken for it.
1286
+ const open = ` ${name}="`;
1287
+ const start = tag.indexOf(open) + open.length;
1288
+ return (
1289
+ tag.slice(0, start) +
1290
+ escapeHtml(unsafeUrlValue(out) ? "" : out) +
1291
+ tag.slice(tag.indexOf('"', start))
1292
+ );
1293
+ });
863
1294
  }
864
1295
 
865
1296
  /**
@@ -886,10 +1317,11 @@ function fillEachMeta(str, meta) {
886
1317
  /**
887
1318
  * @param {string} str
888
1319
  * @param {unknown} item
889
- * @param {Record<string, unknown>} [meta]
1320
+ * @param {Record<string, unknown>} meta
1321
+ * @param {Compilation} comp
890
1322
  * @returns {string}
891
1323
  */
892
- function fillEachIfRegions(str, item, meta = {}) {
1324
+ function fillEachIfRegions(str, item, meta, comp) {
893
1325
  const marker = "<span data-wd-each-if ";
894
1326
  let result = "";
895
1327
  let i = 0;
@@ -898,7 +1330,7 @@ function fillEachIfRegions(str, item, meta = {}) {
898
1330
  if (start === -1) return result + str.slice(i);
899
1331
  result += str.slice(i, start);
900
1332
  const end = matchElement(str, start, "span");
901
- result += fillOneEachIf(str.slice(start, end), item, meta);
1333
+ result += fillOneEachIf(str.slice(start, end), item, meta, comp);
902
1334
  i = end;
903
1335
  }
904
1336
  }
@@ -907,10 +1339,18 @@ function fillEachIfRegions(str, item, meta = {}) {
907
1339
  * @param {string} region
908
1340
  * @param {unknown} item
909
1341
  * @param {Record<string, unknown>} meta
1342
+ * @param {Compilation} comp
910
1343
  * @returns {string}
911
1344
  */
912
- function fillOneEachIf(region, item, meta) {
1345
+ function fillOneEachIf(region, item, meta, comp) {
913
1346
  const metaMatch = region.match(/^<span data-wd-each-if data-wd-meta="([a-z]+)">/);
1347
+ // A per-row `:if` with an OPERATOR (`:if r.qty > 1`) ships its condition as a
1348
+ // serialized AST, not a path — the same payload the runtime walks. Without
1349
+ // this branch the path fell back to "", `getPath(item, [])` handed back the
1350
+ // row object, and `Boolean(row)` was true for every row: every operator `:if`
1351
+ // in every reactive loop shipped its TRUE branch in the pre-hydration HTML,
1352
+ // which is what a crawler and a no-JS reader see.
1353
+ const exprMatch = region.match(/^<span data-wd-each-if data-wd-if-expr="([^"]*)">/);
914
1354
  const path = (region.match(/^<span data-wd-each-if data-wd-path="([^"]*)">/) || ["", ""])[1];
915
1355
  const trueStart = region.indexOf("<template data-wd-if-true>");
916
1356
  const trueEnd = matchElement(region, trueStart, "template");
@@ -920,12 +1360,20 @@ function fillOneEachIf(region, item, meta) {
920
1360
  const close = "</template>".length;
921
1361
  const truthy = region.slice(trueStart + open, trueEnd - close);
922
1362
  const falsy = region.slice(falseStart + "<template data-wd-if-false>".length, falseEnd - close);
923
- const test = metaMatch
924
- ? Boolean(meta[metaMatch[1]])
925
- : Boolean(getPath(item, path ? path.split(".") : []));
1363
+ let test;
1364
+ if (exprMatch) {
1365
+ // The compiler serialized this AST itself, so the parse cannot fail; the
1366
+ // walk is the same closed evaluator the runtime uses, so the initial paint
1367
+ // and the first render agree.
1368
+ test = Boolean(evalAst(JSON.parse(unescapeHtml(exprMatch[1])), item, comp));
1369
+ } else if (metaMatch) {
1370
+ test = Boolean(meta[metaMatch[1]]);
1371
+ } else {
1372
+ test = Boolean(getPath(item, path ? path.split(".") : []));
1373
+ }
926
1374
  const branch = test ? truthy : falsy;
927
1375
  const head = region.slice(0, falseEnd);
928
- return `${head}<span data-wd-each-if-out>${fillEachIfRegions(branch, item, meta)}</span></span>`;
1376
+ return `${head}<span data-wd-each-if-out>${fillEachIfRegions(branch, item, meta, comp)}</span></span>`;
929
1377
  }
930
1378
 
931
1379
  /**
@@ -989,7 +1437,7 @@ export function matchElement(str, start, tag) {
989
1437
  * @returns {string}
990
1438
  */
991
1439
  export function loopKeyOf(item, counts) {
992
- const base =
1440
+ const raw =
993
1441
  item && typeof item === "object"
994
1442
  ? String(
995
1443
  /** @type {Record<string, unknown>} */ (item).id ??
@@ -997,6 +1445,12 @@ export function loopKeyOf(item, counts) {
997
1445
  JSON.stringify(item)
998
1446
  )
999
1447
  : String(item);
1448
+ // Escape every literal `#` in the value BEFORE the `#n` disambiguator is
1449
+ // appended, so a real key can never be mistaken for a duplicate suffix. A row
1450
+ // with `id: "a#1"` and the second row with `id: "a"` both serialized to `a#1`,
1451
+ // and because the runtime's reuse Map is keyed on this attribute the collision
1452
+ // made a 3-item list grow a row on every render, unbounded.
1453
+ const base = raw.replace(/#/g, "##");
1000
1454
  const seen = counts.get(base) || 0;
1001
1455
  counts.set(base, seen + 1);
1002
1456
  return seen ? `${base}#${seen}` : base;