@zvndev/darkmown 1.5.0 → 2.0.1

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 (95) hide show
  1. package/AGENTS.md +18 -4
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +49 -19
  4. package/SECURITY.md +21 -18
  5. package/docs/cli.md +5 -1
  6. package/docs/spec-alignment.md +21 -7
  7. package/package.json +3 -2
  8. package/site/_/base.skin +8 -0
  9. package/site/_/base.wd +4 -0
  10. package/site/_/feature-card.wd +4 -0
  11. package/site/_/footer.wd +5 -1
  12. package/site/_/nav.wd +4 -0
  13. package/site/_/scoped-promo.wd +4 -0
  14. package/site/_/scoped-stat.wd +4 -0
  15. package/site/pages/404.wd +1 -0
  16. package/site/pages/app.wd +2 -1
  17. package/site/pages/blog/collections-and-pagination.skin +127 -0
  18. package/site/pages/blog/drafts-that-never-leak.skin +127 -0
  19. package/site/pages/blog/index.wd +1 -0
  20. package/site/pages/blog/shipping-feeds.skin +127 -0
  21. package/site/pages/blog/typed-frontmatter.skin +127 -0
  22. package/site/pages/blog/unfinished-thoughts.skin +127 -0
  23. package/site/pages/blog/zero-js-by-default.md +1 -1
  24. package/site/pages/blog/zero-js-by-default.skin +127 -0
  25. package/site/pages/carousel/index.wd +1 -0
  26. package/site/pages/compass/index.wd +1 -0
  27. package/site/pages/data.wd +3 -0
  28. package/site/pages/docs/-relative-note.wd +4 -0
  29. package/site/pages/docs/index.skin +27 -0
  30. package/site/pages/docs/index.wd +85 -14
  31. package/site/pages/folio/-style.wd +4 -0
  32. package/site/pages/folio/cart.wd +1 -0
  33. package/site/pages/folio/index.wd +1 -0
  34. package/site/pages/forge/index.wd +1 -0
  35. package/site/pages/highlight/index.wd +1 -0
  36. package/site/pages/index.js +19 -0
  37. package/site/pages/index.skin +46 -1
  38. package/site/pages/index.wd +6 -3
  39. package/site/pages/interactions/index.wd +1 -0
  40. package/site/pages/ledger/index.wd +1 -0
  41. package/site/pages/loops.wd +1 -0
  42. package/site/pages/media/index.wd +1 -0
  43. package/site/pages/pulse/index.wd +1 -0
  44. package/site/pages/reactive.wd +1 -0
  45. package/site/pages/scoped/index.wd +1 -0
  46. package/site/pages/showcase/index.wd +2 -1
  47. package/site/pages/store.wd +1 -0
  48. package/site/pages/theme.wd +1 -0
  49. package/src/builder.js +444 -65
  50. package/src/cli.js +96 -18
  51. package/src/compiler/actions.js +303 -0
  52. package/src/compiler/body.js +36 -24
  53. package/src/compiler/context.js +65 -6
  54. package/src/compiler/directives.js +31 -1470
  55. package/src/compiler/fetch.js +159 -0
  56. package/src/compiler/format.js +17 -4
  57. package/src/compiler/forms.js +456 -0
  58. package/src/compiler/frontmatter.js +10 -5
  59. package/src/compiler/includes.js +75 -4
  60. package/src/compiler/index.js +15 -3
  61. package/src/compiler/loops.js +85 -23
  62. package/src/compiler/markdown.js +65 -15
  63. package/src/compiler/media.js +133 -0
  64. package/src/compiler/page.js +88 -77
  65. package/src/compiler/state.js +168 -0
  66. package/src/compiler/structure.js +399 -0
  67. package/src/dev.js +76 -0
  68. package/src/headers.js +56 -17
  69. package/src/router.js +12 -1
  70. package/src/runtime.js +5 -3
  71. package/src/statics.js +63 -12
  72. package/src/templates/blog/README.md +13 -4
  73. package/src/templates/blog/site/_/nav.wd +7 -6
  74. package/src/templates/blog/site/pages/index.skin +10 -4
  75. package/src/templates/blog/site/pages/index.wd +11 -6
  76. package/src/templates/blog/site/pages/posts/_schema.wd +16 -0
  77. package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
  78. package/src/templates/blog/site/pages/posts/hello-darkmown.skin +59 -0
  79. package/src/templates/blog/site/pages/posts/markdown-native.md +9 -4
  80. package/src/templates/blog/site/pages/posts/markdown-native.skin +59 -0
  81. package/src/templates/dashboard/site/_/nav.wd +7 -6
  82. package/src/templates/dashboard/site/pages/index.skin +7 -4
  83. package/src/templates/dashboard/site/pages/index.wd +2 -2
  84. package/src/templates/landing/site/_/nav.wd +7 -6
  85. package/src/templates/landing/site/pages/index.skin +13 -4
  86. package/src/templates/landing/site/pages/index.wd +22 -8
  87. package/src/templates/starter/site/_/nav.wd +8 -7
  88. package/src/templates/starter/site/pages/about.md +8 -2
  89. package/src/templates/starter/site/pages/about.skin +44 -0
  90. package/src/templates/starter/site/pages/index.skin +10 -6
  91. package/src/templates/starter/site/pages/index.wd +2 -2
  92. package/src/templates/store/site/_/nav.wd +7 -6
  93. package/src/templates/store/site/pages/index.skin +7 -4
  94. package/src/templates/store/site/pages/index.wd +2 -2
  95. package/src/templates/blog/site/_/posts.json +0 -16
@@ -12,13 +12,15 @@ import { stripQuotes } from "./interpolation.js";
12
12
  */
13
13
 
14
14
  /**
15
- * Split a raw file into its frontmatter `meta` and `body`.
15
+ * Split a raw file into its frontmatter `meta` and `body`. `bodyLine` is the
16
+ * 0-based file line index the body starts on (the number of lines the
17
+ * frontmatter block consumed) so compile errors can report true file lines.
16
18
  * @param {string} raw Full file contents.
17
19
  * @param {string} [file] Source path, used only for error messages.
18
- * @returns {{ meta: Meta, body: string }}
20
+ * @returns {{ meta: Meta, body: string, bodyLine: number }}
19
21
  */
20
22
  export function parseFrontmatter(raw, file) {
21
- if (!raw.startsWith("---\n")) return { meta: {}, body: raw };
23
+ if (!raw.startsWith("---\n")) return { meta: {}, body: raw, bodyLine: 0 };
22
24
  const end = raw.indexOf("\n---", 3);
23
25
  if (end === -1) {
24
26
  const where = file ? ` in ${file}` : "";
@@ -27,14 +29,17 @@ export function parseFrontmatter(raw, file) {
27
29
  );
28
30
  }
29
31
  const front = raw.slice(4, end).trim();
30
- const body = raw.slice(end + 4).replace(/^\n/, "");
32
+ let bodyStart = end + 4;
33
+ if (raw[bodyStart] === "\n") bodyStart++;
34
+ const body = raw.slice(bodyStart);
35
+ const bodyLine = raw.slice(0, bodyStart).split("\n").length - 1;
31
36
  /** @type {Meta} */
32
37
  const meta = {};
33
38
  for (const line of front.split("\n")) {
34
39
  const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
35
40
  if (match) meta[match[1]] = parseFrontmatterValue(match[2]);
36
41
  }
37
- return { meta, body };
42
+ return { meta, body, bodyLine };
38
43
  }
39
44
 
40
45
  /**
@@ -1,7 +1,8 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // Includes / assets: resolve `@include` (and `@loop` data) targets to absolute
3
3
  // paths inside `site/pages` or `site/_`, register colocated `.skin`/`.js`
4
- // siblings, and warn when a plain `.md` file uses `.wd`-only syntax.
4
+ // siblings, detect scoped skins and stamp their subtree HTML (`stampScope`),
5
+ // and warn when a plain `.md` file uses `.wd`-only syntax.
5
6
  // ---------------------------------------------------------------------------
6
7
 
7
8
  import fs from "node:fs";
@@ -57,6 +58,70 @@ export function scopedSkinFor(file, context) {
57
58
  return scopeInfoForSkin(candidate, context.cwd);
58
59
  }
59
60
 
61
+ /**
62
+ * Raw-text (`script`, `style`) and escapable-raw-text (`textarea`, `title`)
63
+ * elements per the HTML spec: their CONTENT is not markup, so `<`/`>` inside them
64
+ * are plain characters and must never be treated as tags. The content runs to the
65
+ * first matching close tag.
66
+ * @type {Set<string>}
67
+ */
68
+ const RAW_TEXT_ELEMENTS = new Set(["script", "style", "textarea", "title"]);
69
+
70
+ /**
71
+ * Stamp `data-wd-scope="<id>"` onto every opening tag of a subtree — the HTML
72
+ * half of compile-time scoped styles, mirroring {@link enhanceImages}: a
73
+ * compile-time post-pass over assembled HTML, zero runtime JS. Every element
74
+ * start tag that doesn't already carry a scope attribute gets one, so the scoped
75
+ * stylesheet (whose selectors end in `[data-wd-scope="<id>"]`) matches inside
76
+ * this subtree and nowhere else. Void/self-closing tags are stamped too (the
77
+ * attribute is valid on any element). Closing tags, comments, doctype, and the
78
+ * `<!`/`<?` families are left alone. A tag that ALREADY has a `data-wd-scope`
79
+ * (e.g. a nested include with its own scope) keeps its own — first stamp wins.
80
+ *
81
+ * Raw-text / escapable-raw-text elements (`script`, `style`, `textarea`,
82
+ * `title`) are special-cased: their OPENING tag is stamped (valid), but their
83
+ * BODY is left byte-intact — `<`/`>` inside a `<script>` are JavaScript, not
84
+ * markup, so stamping there would corrupt the content. A small tokenizer walks
85
+ * tag-by-tag (quote-aware in attribute lists) and, on entering a raw-text
86
+ * element, copies through verbatim until the matching close tag.
87
+ * @param {string} html Subtree HTML to scope.
88
+ * @param {string} scopeId Scope id (from {@link import("../skin.js").scopeIdFor}).
89
+ * @returns {string}
90
+ */
91
+ export function stampScope(html, scopeId) {
92
+ // Opening tag: name + (quote-aware) attribute list + optional self-close slash.
93
+ const openTag = /<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
94
+ let out = "";
95
+ let cursor = 0;
96
+ for (let m = openTag.exec(html); m; m = openTag.exec(html)) {
97
+ const [tag, name, attrs, slash] = m;
98
+ out += html.slice(cursor, m.index); // text/markup before this opening tag
99
+ const stamped = /\bdata-wd-scope\s*=/.test(attrs)
100
+ ? tag // first stamp wins — a pre-stamped tag keeps its own scope
101
+ : `<${name}${attrs} data-wd-scope="${scopeId}"${slash ? " /" : ""}>`;
102
+ out += stamped;
103
+ cursor = m.index + tag.length;
104
+
105
+ // Raw-text element (not self-closed): copy its body through untouched and
106
+ // resume scanning after the matching close tag, so nothing inside is stamped.
107
+ if (!slash && RAW_TEXT_ELEMENTS.has(name.toLowerCase())) {
108
+ const rest = html.slice(cursor);
109
+ const closeMatch = new RegExp(`</${name}\\s*>`, "i").exec(rest);
110
+ if (!closeMatch) {
111
+ out += rest; // unterminated raw-text element — copy the remainder verbatim
112
+ cursor = html.length;
113
+ break;
114
+ }
115
+ const through = closeMatch.index + closeMatch[0].length; // body + close tag
116
+ out += rest.slice(0, through); // byte-intact
117
+ cursor += through;
118
+ openTag.lastIndex = cursor; // skip the body the scanner would otherwise re-walk
119
+ }
120
+ }
121
+ out += html.slice(cursor); // trailing text after the last opening tag
122
+ return out;
123
+ }
124
+
60
125
  /**
61
126
  * Register a page's colocated `.skin`/`.js` siblings as emitted assets.
62
127
  * @param {string} file
@@ -93,9 +158,13 @@ export function collectColocatedAssets(file, context, assets) {
93
158
  * @param {string} fromFile File requesting the include.
94
159
  * @param {Paths} context
95
160
  * @param {boolean} [allowAny] Allow non-page extensions (e.g. JSON for `@loop`).
161
+ * @param {string} [loc] Source location (`file:line`) for the error messages, so
162
+ * an unresolved/out-of-sandbox include reports the directive's line. Callers
163
+ * with a line index pass `at(ctx, index)`; it defaults to `fromFile` (the bare
164
+ * path) for any caller without one, preserving the file-only message.
96
165
  * @returns {string}
97
166
  */
98
- export function resolveInclude(spec, fromFile, context, allowAny = false) {
167
+ export function resolveInclude(spec, fromFile, context, allowAny = false, loc = fromFile) {
99
168
  const clean = stripQuotes(spec);
100
169
  const candidates = [];
101
170
  if (clean.startsWith("/")) {
@@ -107,13 +176,15 @@ export function resolveInclude(spec, fromFile, context, allowAny = false) {
107
176
  for (const candidate of candidates) {
108
177
  const resolved = path.resolve(candidate);
109
178
  if (!isAllowedInclude(resolved, context)) {
110
- throw new Error(`Include "${spec}" from ${fromFile} resolves outside site/pages or site/_`);
179
+ throw new Error(`Include "${spec}" from ${loc} resolves outside site/pages or site/_`);
111
180
  }
112
181
  if (!fs.existsSync(resolved)) continue;
113
182
  if (!allowAny && !pageIncludeExtensions.includes(path.extname(resolved))) continue;
114
183
  return resolved;
115
184
  }
116
- throw new Error(`Could not resolve include "${spec}" from ${fromFile}`);
185
+ throw new Error(
186
+ `Could not resolve include "${spec}" from ${loc}. Looked in site/pages and site/_ — check the path and leading slash.`
187
+ );
117
188
  }
118
189
 
119
190
  /**
@@ -4,20 +4,32 @@
4
4
  // context.js shared typedefs + tiny helpers (createCompilation, at, …)
5
5
  // interpolation.js path/value resolution, state-key lookup, escapeHtml, literals
6
6
  // frontmatter.js `---` block → meta + body, inline scalar/array parsing
7
- // includes.js @include / @loop-data resolution, colocated assets, .md hints
7
+ // includes.js @include / @loop-data resolution, colocated assets,
8
+ // scoped-skin detection + the HTML scope stamp, .md hints
8
9
  // predicates.js where / when / :if / :computed expression compilers
9
10
  // format.js { value | pipe } format pipes + list aggregates (value layer)
10
11
  // markdown.js markdown-it instances + plugins + prose interpolation
12
+ // state.js :state / :store / :computed / :theme + declare* helpers
13
+ // actions.js the :button action parser + :button / :effect / :every
14
+ // fetch.js :fetch + the shared URL scheme guard
15
+ // forms.js :form + field directives + :bind / :slider
16
+ // media.js :video / :audio / :embed
17
+ // structure.js @include / ::: container / :if / :carousel + demo directives
18
+ // directives.js barrel re-exporting the handle* family from the six above
11
19
  // loops.js @loop pipeline + row-template initial-paint fill
12
- // directives.js the handle* family + :button action parser + demo directives
13
20
  // body.js the line-based directive dispatcher (compileBody)
14
21
  // page.js HTML page shell, image hardening, per-file compile
15
22
  //
23
+ // Handlers recurse into nested bodies through `ctx.compileBody` (and `@include`
24
+ // through `ctx.compileFile`), threaded in by `compileFile` — so the graph is an
25
+ // import-cycle-free DAG rooted at context.js.
26
+ //
16
27
  // This barrel re-exports the public API; `src/compiler.js` re-exports from here
17
28
  // so every existing `import … from "./compiler.js"` keeps working unchanged.
18
29
  // ---------------------------------------------------------------------------
19
30
 
20
31
  export { parseFrontmatter } from "./frontmatter.js";
32
+ export { stampScope } from "./includes.js";
21
33
  export { escapeHtml } from "./interpolation.js";
22
34
  export { loopKeyOf } from "./loops.js";
23
- export { compileDocument, compilePage, enhanceImages, stampScope } from "./page.js";
35
+ export { compileDocument, compilePage, enhanceImages } from "./page.js";
@@ -7,8 +7,7 @@
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
9
  import fs from "node:fs";
10
- import { compileBody } from "./body.js";
11
- import { createScope } from "./context.js";
10
+ import { at, createScope, nestedCtx } from "./context.js";
12
11
  import { applyPipeline, stagesFromAttr } from "./format.js";
13
12
  import { resolveInclude } from "./includes.js";
14
13
  import {
@@ -213,11 +212,14 @@ function numArgAttr(arg) {
213
212
  * @param {string[]} bodyLines
214
213
  * @param {string[] | null} emptyLines
215
214
  * @param {Ctx} ctx
215
+ * @param {number} index 0-based line index of the `@loop` opener.
216
+ * @param {number} [emptyStart] 0-based index the empty branch starts at within
217
+ * the loop body, so its nested errors report the true file line.
216
218
  * @returns {string}
217
219
  */
218
- export function handleLoop(line, bodyLines, emptyLines, ctx) {
220
+ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart = 0) {
219
221
  const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(\s+.+?)?\s*$/);
220
- if (!match) throw new Error(`Malformed @loop in ${ctx.file}: ${line}. ${LOOP_USAGE}`);
222
+ if (!match) throw new Error(`Malformed @loop in ${at(ctx, index)}: ${line}. ${LOOP_USAGE}`);
221
223
  const source = stripQuotes(match[1].trim());
222
224
  const itemName = match[2];
223
225
  const clauses = match[3]
@@ -265,10 +267,19 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
265
267
  offset: clauses.offset,
266
268
  limit: clauses.limit,
267
269
  empty: emptyLines,
270
+ emptyStart,
268
271
  clauseRefsState: clauses.refsState,
269
272
  sortable: clauses.sortable,
270
273
  paginate: clauses.paginate
271
274
  };
275
+ // The loop body starts on the line after the opener; nested errors in it (and
276
+ // in the empty branch, via `opts.emptyStart`) report the true file line. The
277
+ // opener's location rides on the body ctx so a reactive loop nested inside this
278
+ // one can point the depth error back at its own opener line.
279
+ const bodyCtx = {
280
+ ...nestedCtx(ctx, index + 1),
281
+ loopOpener: { at: at(ctx, index), line: line.trim() }
282
+ };
272
283
 
273
284
  if (
274
285
  source.startsWith("/") ||
@@ -277,21 +288,29 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
277
288
  source.endsWith(".json")
278
289
  ) {
279
290
  if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
280
- const dataFile = resolveInclude(source, ctx.file, ctx.context, true);
291
+ const dataFile = resolveInclude(source, ctx.file, ctx.context, true, at(ctx, index));
292
+ ctx.comp.deps.add(dataFile);
281
293
  const rows = JSON.parse(fs.readFileSync(dataFile, "utf8"));
282
294
  if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
283
- return loopOverData(rows, itemName, bodyLines, ctx, opts);
295
+ return loopOverData(rows, itemName, bodyLines, bodyCtx, opts);
284
296
  }
285
297
 
286
298
  const resolved = lookupPath(source, ctx);
287
299
  if (resolved.found) {
288
300
  if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
301
+ // A missing/unset field (e.g. an optional frontmatter key) is an EMPTY
302
+ // list, not an error — the `@empty` branch renders, matching the runtime.
303
+ if (resolved.value === null || resolved.value === undefined) {
304
+ return loopOverData([], itemName, bodyLines, bodyCtx, opts);
305
+ }
289
306
  if (!Array.isArray(resolved.value)) {
290
307
  throw new Error(
291
- `@loop ${source} in ${ctx.file} found an in-scope value, but it is not a list`
308
+ `@loop ${source} in ${at(ctx, index)} found an in-scope value, but it is not a list ` +
309
+ `(got ${typeof resolved.value}). Use: a list value (e.g. \`tags: [a, b]\` in frontmatter) — ` +
310
+ `or leave the field out entirely, which loops zero rows and renders the @empty branch.`
292
311
  );
293
312
  }
294
- return loopOverData(resolved.value, itemName, bodyLines, ctx, opts);
313
+ return loopOverData(resolved.value, itemName, bodyLines, bodyCtx, opts);
295
314
  }
296
315
 
297
316
  // A bare name OR a dotted path resolving to declared :state (e.g. team.members).
@@ -304,13 +323,16 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
304
323
  if (key) {
305
324
  if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
306
325
  const fullKey = segs.length > 1 ? `${key}.${segs.slice(1).join(".")}` : key;
307
- return reactiveLoop(fullKey, itemName, bodyLines, ctx, opts);
326
+ return reactiveLoop(fullKey, itemName, bodyLines, bodyCtx, opts);
308
327
  }
309
328
  // A bare name matching a collection (any `site/pages/<name>/` subdir) resolves
310
329
  // to its entry rows at build time and static-unrolls — zero JS. This is checked
311
330
  // after state so a declared `:state` of the same name still wins.
312
331
  const collection = segs.length === 1 ? ctx.comp.collections.get(source) : undefined;
313
- if (collection) return loopOverCollection(collection, source, itemName, bodyLines, ctx, opts);
332
+ if (collection) {
333
+ ctx.comp.collectionsUsed.add(source);
334
+ return loopOverCollection(collection, source, itemName, bodyLines, bodyCtx, opts);
335
+ }
314
336
  // Inside a reactive loop, `@loop <outerItem>.<path> into x` loops a field of
315
337
  // the enclosing row item — an ITEM-RELATIVE loop the runtime fills per row.
316
338
  if (ctx.loopItem && segs[0] === ctx.loopItem && segs.length > 1) {
@@ -320,7 +342,7 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
320
342
  );
321
343
  }
322
344
  if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
323
- return itemRelativeLoop(segs.slice(1).join("."), itemName, bodyLines, ctx, opts);
345
+ return itemRelativeLoop(segs.slice(1).join("."), itemName, bodyLines, bodyCtx, opts);
324
346
  }
325
347
  }
326
348
 
@@ -355,6 +377,27 @@ function paginateOnlyCollections(ctx) {
355
377
  return `@loop paginate requires a collection source (a site/pages/<name>/ subdirectory) in ${ctx.file}. ${LOOP_USAGE}`;
356
378
  }
357
379
 
380
+ /**
381
+ * Reject a reactive `@loop` that would open a THIRD reactive nesting level. The
382
+ * runtime reconciles at most two nested `data-wd-loop` levels (an outer loop and
383
+ * one inner loop); a third level's `data-wd-loop-out` paints empty. This guards
384
+ * every reactive-region entry point ({@link reactiveLoop}, {@link itemRelativeLoop}),
385
+ * so a state-key, item-relative, or state-filtered source is all covered. Static
386
+ * (build-unrolled) loops carry the depth through without incrementing it, so an
387
+ * interleaved static level neither triggers nor masks the limit.
388
+ * `ctx.reactiveDepth` is the number of reactive loops already enclosing this one.
389
+ * @param {Ctx} ctx
390
+ * @returns {void}
391
+ */
392
+ function assertReactiveDepth(ctx) {
393
+ if ((ctx.reactiveDepth ?? 0) < 2) return;
394
+ const opener = ctx.loopOpener ?? { at: ctx.file, line: "" };
395
+ throw new Error(
396
+ `Reactive @loop nesting is limited to one inner level in ${opener.at}: "${opener.line}". ` +
397
+ `Unroll the outer data at build time (JSON/frontmatter source) or move the innermost list into build-time data.`
398
+ );
399
+ }
400
+
358
401
  /**
359
402
  * Loop over a build-time collection's entry rows. With no `paginate`, this is a
360
403
  * plain static unroll (filter/sort/reverse/offset/limit at build time, zero JS).
@@ -387,7 +430,14 @@ function loopOverCollection(rows, name, itemName, bodyLines, ctx, opts) {
387
430
  // document scope). Slice this page out of the already-filtered/sorted list.
388
431
  const current = currentPage(ctx);
389
432
  const start = (current - 1) * perPage;
390
- return staticUnroll(filtered.slice(start, start + perPage), itemName, bodyLines, ctx, opts.empty);
433
+ return staticUnroll(
434
+ filtered.slice(start, start + perPage),
435
+ itemName,
436
+ bodyLines,
437
+ ctx,
438
+ opts.empty,
439
+ opts.emptyStart
440
+ );
391
441
  }
392
442
 
393
443
  /**
@@ -466,7 +516,8 @@ function loopOverData(rows, itemName, bodyLines, ctx, opts) {
466
516
  itemName,
467
517
  bodyLines,
468
518
  ctx,
469
- opts.empty
519
+ opts.empty,
520
+ opts.emptyStart
470
521
  );
471
522
  }
472
523
 
@@ -476,10 +527,13 @@ function loopOverData(rows, itemName, bodyLines, ctx, opts) {
476
527
  * @param {string[]} bodyLines
477
528
  * @param {Ctx} ctx
478
529
  * @param {string[] | null} [empty] Empty-branch body, rendered when 0 rows.
530
+ * @param {number} [emptyStart] 0-based index the empty branch starts at within
531
+ * the loop body, so its nested errors report the true file line.
479
532
  * @returns {string}
480
533
  */
481
- function staticUnroll(rows, itemName, bodyLines, ctx, empty = null) {
482
- if (rows.length === 0 && empty) return compileBody(empty, { ...ctx, loopMeta: true });
534
+ function staticUnroll(rows, itemName, bodyLines, ctx, empty = null, emptyStart = 0) {
535
+ if (rows.length === 0 && empty)
536
+ return ctx.compileBody(empty, { ...nestedCtx(ctx, emptyStart), loopMeta: true });
483
537
  const out = [];
484
538
  const count = rows.length;
485
539
  for (let i = 0; i < count; i++) {
@@ -495,7 +549,7 @@ function staticUnroll(rows, itemName, bodyLines, ctx, empty = null) {
495
549
  loopMeta: true,
496
550
  scope: createScope(ctx.scope, { [itemName]: rows[i], ...meta })
497
551
  };
498
- out.push(compileBody(bodyLines, rowCtx));
552
+ out.push(ctx.compileBody(bodyLines, rowCtx));
499
553
  }
500
554
  return out.join("\n");
501
555
  }
@@ -512,6 +566,7 @@ function staticUnroll(rows, itemName, bodyLines, ctx, empty = null) {
512
566
  * @returns {string}
513
567
  */
514
568
  function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
569
+ assertReactiveDepth(ctx);
515
570
  ctx.comp.assets.runtime = true;
516
571
  /** @type {Ctx} */
517
572
  const templateCtx = {
@@ -519,9 +574,10 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
519
574
  loopItem: itemName,
520
575
  loopKey: key ?? undefined,
521
576
  loopMeta: true,
577
+ reactiveDepth: (ctx.reactiveDepth ?? 0) + 1,
522
578
  scope: createScope(ctx.scope)
523
579
  };
524
- const templateHtml = compileBody(bodyLines, templateCtx).trim();
580
+ const templateHtml = ctx.compileBody(bodyLines, templateCtx).trim();
525
581
 
526
582
  const { wrapperTag, itemTemplate } = wrapRowTemplate(templateHtml);
527
583
 
@@ -577,10 +633,12 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
577
633
  })
578
634
  .join("");
579
635
 
580
- const emptyTemplate = loopEmptyTemplate(emptyLines, ctx);
636
+ const emptyTemplate = loopEmptyTemplate(emptyLines, ctx, opts?.emptyStart);
581
637
  const initialOut =
582
638
  count === 0 && emptyLines
583
- ? compileBody(emptyLines, { ...ctx, loopMeta: true }).trim()
639
+ ? ctx
640
+ .compileBody(emptyLines, { ...nestedCtx(ctx, opts?.emptyStart ?? 0), loopMeta: true })
641
+ .trim()
584
642
  : initial;
585
643
 
586
644
  // `sortable` tags the region for the drag-reorder behavior (emitted only here)
@@ -615,11 +673,13 @@ function wrapRowTemplate(templateHtml) {
615
673
  * there is no empty branch). Shared by top-level and item-relative loops.
616
674
  * @param {string[] | null} emptyLines
617
675
  * @param {Ctx} ctx
676
+ * @param {number} [emptyStart] 0-based index the empty branch starts at within
677
+ * the loop body, so its nested errors report the true file line.
618
678
  * @returns {string}
619
679
  */
620
- function loopEmptyTemplate(emptyLines, ctx) {
680
+ function loopEmptyTemplate(emptyLines, ctx, emptyStart = 0) {
621
681
  return emptyLines
622
- ? `<template data-wd-loop-empty>${compileBody(emptyLines, { ...ctx, loopMeta: true }).trim()}</template>`
682
+ ? `<template data-wd-loop-empty>${ctx.compileBody(emptyLines, { ...nestedCtx(ctx, emptyStart), loopMeta: true }).trim()}</template>`
623
683
  : "";
624
684
  }
625
685
 
@@ -657,6 +717,7 @@ function loopClauseAttrs(c) {
657
717
  * @returns {string}
658
718
  */
659
719
  function itemRelativeLoop(path, itemName, bodyLines, ctx, opts) {
720
+ assertReactiveDepth(ctx);
660
721
  /** @type {Ctx} */
661
722
  // loopKey: undefined — an item-relative loop's source is a path off the outer
662
723
  // row, not a top-level state key, so a per-row `remove` inside it has no valid
@@ -666,13 +727,14 @@ function itemRelativeLoop(path, itemName, bodyLines, ctx, opts) {
666
727
  loopItem: itemName,
667
728
  loopKey: undefined,
668
729
  loopMeta: true,
730
+ reactiveDepth: (ctx.reactiveDepth ?? 0) + 1,
669
731
  scope: createScope(ctx.scope)
670
732
  };
671
- const templateHtml = compileBody(bodyLines, templateCtx).trim();
733
+ const templateHtml = ctx.compileBody(bodyLines, templateCtx).trim();
672
734
 
673
735
  const { wrapperTag, itemTemplate } = wrapRowTemplate(templateHtml);
674
736
 
675
- const emptyTemplate = loopEmptyTemplate(opts.empty || null, ctx);
737
+ const emptyTemplate = loopEmptyTemplate(opts.empty || null, ctx, opts.emptyStart);
676
738
  const attrs = loopClauseAttrs({
677
739
  where: opts.where || null,
678
740
  sort: opts.sort || null,
@@ -1,6 +1,6 @@
1
1
  // ---------------------------------------------------------------------------
2
- // Markdown layer: the shared markdown-it instances (raw-HTML default + a lazy
3
- // `html: false` strict variant), the `wd_binding` inline plugin and `wd_attrs`
2
+ // Markdown layer: the shared markdown-it instances (strict default + a lazy
3
+ // `html: true` raw-HTML variant), the `wd_binding` inline plugin and `wd_attrs`
4
4
  // core plugin, and prose rendering with `{ name.path }` interpolation resolved
5
5
  // against the loop item / static scope / declared state.
6
6
  // ---------------------------------------------------------------------------
@@ -26,29 +26,33 @@ import {
26
26
  // with a known language. The callback returns escaped token HTML or `""` to let
27
27
  // markdown-it render a plain escaped `<code>` (graceful degradation). Both
28
28
  // instances share it so `html: false` pages highlight too.
29
- const md = new MarkdownIt({ html: true, highlight: highlightCode });
29
+ const md = new MarkdownIt({ html: false, highlight: highlightCode });
30
30
  md.use(bindingPlugin);
31
31
  md.use(attrsPlugin);
32
+ md.use(anchorPlugin);
32
33
 
33
- // Raw HTML in markdown passes through by default (a documented design choice).
34
- // Pages can opt out with frontmatter `html: false` for untrusted content; the
35
- // stricter instance is built lazily so the default path stays a single instance.
34
+ // Raw HTML in markdown is escaped by default: a stray `<script>` or `onerror=`
35
+ // attribute in content renders as inert text, so multi-author content (blog
36
+ // collections, contributed docs) is stored-XSS-safe out of the box. A page
37
+ // whose author writes their own HTML opts in with frontmatter `html: true`;
38
+ // that instance is built lazily so the default path stays a single instance.
36
39
  /** @type {MarkdownIt | null} */
37
- let mdNoHtml = null;
40
+ let mdRawHtml = null;
38
41
  /**
39
- * Pick the markdown-it instance for a page (raw HTML on by default, off with `html: false`).
42
+ * Pick the markdown-it instance for a page (raw HTML off by default, on with `html: true`).
40
43
  * @param {Meta} [meta]
41
44
  * @returns {MarkdownIt}
42
45
  */
43
46
  export function selectMd(meta) {
44
47
  // Frontmatter scalars stay strings (no coercion), so accept both forms.
45
- if (meta?.html !== false && meta?.html !== "false") return md;
46
- if (!mdNoHtml) {
47
- mdNoHtml = new MarkdownIt({ html: false, highlight: highlightCode });
48
- mdNoHtml.use(bindingPlugin);
49
- mdNoHtml.use(attrsPlugin);
48
+ if (meta?.html !== true && meta?.html !== "true") return md;
49
+ if (!mdRawHtml) {
50
+ mdRawHtml = new MarkdownIt({ html: true, highlight: highlightCode });
51
+ mdRawHtml.use(bindingPlugin);
52
+ mdRawHtml.use(attrsPlugin);
53
+ mdRawHtml.use(anchorPlugin);
50
54
  }
51
- return mdNoHtml;
55
+ return mdRawHtml;
52
56
  }
53
57
 
54
58
  /**
@@ -59,7 +63,8 @@ export function selectMd(meta) {
59
63
  export function renderProse(text, ctx) {
60
64
  text = resolveDestinationBindings(text, ctx);
61
65
  return (ctx.md ?? md).render(text, {
62
- resolveBinding: (/** @type {string} */ expr) => resolveBindingHtml(expr, ctx)
66
+ resolveBinding: (/** @type {string} */ expr) => resolveBindingHtml(expr, ctx),
67
+ headingSlugs: ctx.comp.headingSlugs
63
68
  });
64
69
  }
65
70
 
@@ -197,6 +202,51 @@ export function attrTarget(children, i) {
197
202
  return null;
198
203
  }
199
204
 
205
+ /**
206
+ * GitHub-style heading slug: lowercase, punctuation stripped (letters, numbers,
207
+ * whitespace, `_`, and `-` survive), each whitespace character becomes a hyphen.
208
+ * Punctuation-only headings fall back to `"section"` so the id is never empty.
209
+ * @param {string} text Plain heading text.
210
+ * @returns {string}
211
+ */
212
+ export function slugify(text) {
213
+ const slug = text
214
+ .trim()
215
+ .toLowerCase()
216
+ .replace(/[^\p{L}\p{N}\s_-]+/gu, "")
217
+ .replace(/\s/gu, "-");
218
+ return slug || "section";
219
+ }
220
+
221
+ /**
222
+ * markdown-it plugin (core rule): every heading gets a stable slugified `id`
223
+ * so long pages are linkable without any JS. Duplicate slugs dedupe with
224
+ * `-1`/`-2` suffixes; the counters live in `env.headingSlugs` so a page whose
225
+ * prose renders in chunks (directives between headings) still dedupes across
226
+ * the whole document. Bindings and inline HTML inside a heading contribute no
227
+ * slug text — only literal text and inline code do.
228
+ * @param {MarkdownIt} mdInstance
229
+ * @returns {void}
230
+ */
231
+ function anchorPlugin(mdInstance) {
232
+ mdInstance.core.ruler.push("wd_heading_anchors", (state) => {
233
+ const counts = (state.env.headingSlugs ??= new Map());
234
+ const tokens = state.tokens;
235
+ for (let i = 0; i < tokens.length; i++) {
236
+ if (tokens[i].type !== "heading_open") continue;
237
+ let text = "";
238
+ for (const child of tokens[i + 1].children ?? []) {
239
+ if (child.type === "text" || child.type === "code_inline") text += child.content;
240
+ }
241
+ const base = slugify(text);
242
+ const seen = counts.get(base) ?? 0;
243
+ counts.set(base, seen + 1);
244
+ tokens[i].attrSet("id", seen ? `${base}-${seen}` : base);
245
+ }
246
+ return false;
247
+ });
248
+ }
249
+
200
250
  /**
201
251
  * Resolve a `{ name.path }` binding to HTML: loop item span, static value, or
202
252
  * a reactive bind span. Returns null when nothing in scope matches.