@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.
- package/AGENTS.md +392 -77
- package/CHANGELOG.md +47 -0
- package/README.md +316 -49
- package/docs/cli.md +51 -17
- package/docs/spec-alignment.md +25 -4
- package/grammar/wd-directives.gbnf +13 -5
- package/package.json +7 -3
- package/site/_/base.skin +1 -1
- package/site/_/footer.wd +1 -1
- package/site/pages/api-errors/index.wd +81 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/attr-binding/index.wd +84 -0
- package/site/pages/blog/darkmown-2-7-0.md +176 -0
- package/site/pages/blog/darkmown-2-7-0.skin +127 -0
- package/site/pages/blog/no-unsafe-eval.md +66 -0
- package/site/pages/blog/no-unsafe-eval.skin +127 -0
- package/site/pages/blog/zero-js-by-default.md +1 -1
- package/site/pages/branch-state/index.wd +68 -0
- package/site/pages/docs/index.wd +320 -19
- package/site/pages/index.wd +3 -1
- package/site/pages/playground/index.js +63 -56
- package/site/pages/pulse/index.wd +1 -1
- package/site/pages/settings/index.wd +97 -0
- package/site/pages/showcase/index.wd +37 -1
- package/site/pages/upload/index.wd +63 -0
- package/site/pages/url-state/index.wd +70 -0
- package/src/builder.js +222 -34
- package/src/catalog.js +217 -21
- package/src/cli.js +6 -3
- package/src/compiler/actions.js +80 -12
- package/src/compiler/body.js +104 -28
- package/src/compiler/collections.js +29 -13
- package/src/compiler/context.js +9 -2
- package/src/compiler/expr-ast.js +14 -0
- package/src/compiler/fetch.js +8 -4
- package/src/compiler/forms.js +133 -14
- package/src/compiler/frontmatter.js +28 -7
- package/src/compiler/includes.js +46 -6
- package/src/compiler/interpolation.js +56 -1
- package/src/compiler/loops.js +489 -35
- package/src/compiler/markdown.js +457 -44
- package/src/compiler/page.js +8 -3
- package/src/compiler/predicates.js +84 -14
- package/src/compiler/state.js +167 -18
- package/src/compiler/structure.js +159 -29
- package/src/errors.js +111 -3
- package/src/feeds.js +140 -7
- package/src/grammar.js +53 -10
- package/src/runtime.js +509 -130
- package/src/runtime.min.js +2 -0
- package/src/runtime.min.js.map +1 -0
- package/src/statics.js +46 -2
- package/src/templates/blog/README.md +2 -0
- package/src/templates/dashboard/README.md +2 -0
- package/src/templates/landing/README.md +2 -0
- package/src/templates/starter/README.md +1 -1
- package/src/templates/store/README.md +2 -0
- package/src/tools/validate.js +14 -4
- package/types/builder.d.ts +6 -2
- package/types/catalog.d.ts +12 -0
- package/types/compiler/actions.d.ts +1 -1
- package/types/compiler/body.d.ts +0 -3
- package/types/compiler/forms.d.ts +12 -1
- package/types/compiler/frontmatter.d.ts +8 -1
- package/types/compiler/includes.d.ts +16 -2
- package/types/compiler/interpolation.d.ts +23 -0
- package/types/compiler/loops.d.ts +47 -0
- package/types/compiler/markdown.d.ts +63 -1
- package/types/compiler/predicates.d.ts +7 -1
- package/types/compiler/state.d.ts +8 -2
- package/types/compiler/structure.d.ts +32 -1
- package/types/feeds.d.ts +68 -2
- package/types/statics.d.ts +25 -0
package/src/compiler/loops.js
CHANGED
|
@@ -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
|
|
52
|
-
//
|
|
53
|
-
//
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
129
|
-
if (!
|
|
130
|
-
where =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
749
|
+
out.push(ctx.compileBody(lines, rowCtx));
|
|
612
750
|
}
|
|
613
|
-
|
|
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(
|
|
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>}
|
|
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
|
|
862
|
-
|
|
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
|
+
// `"` 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>}
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
|
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;
|