@zvndev/darkmown 0.17.0 → 0.19.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/src/compiler.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import MarkdownIt from "markdown-it";
4
+ import { imageSize } from "image-size";
4
5
 
5
6
  /**
6
7
  * @typedef {import("./config.js").Paths} Paths
@@ -135,12 +136,48 @@ export function compilePage(file, context) {
135
136
  const scriptSrcs = compiled.assets.runtime ? ["/__wd/runtime.js", ...compiled.assets.scripts] : [...compiled.assets.scripts];
136
137
  const scripts = scriptSrcs.map((src) => `<script type="module" src="${src}"></script>`).join("\n");
137
138
  // Cross-document view transitions: opt in per page with `transitions: true` in
138
- // frontmatter to emit the CSS-only @view-transition rule — a smooth same-origin
139
- // cross-fade on navigation, with zero JavaScript. Ignored by browsers without
140
- // support (graceful fade-free fallback) and only applies to same-origin
139
+ // frontmatter to emit a CSS-only stylesheet — a smooth same-origin transition
140
+ // on navigation, with zero JavaScript. Ignored by browsers without support
141
+ // (graceful, transition-free fallback) and only applies to same-origin
141
142
  // navigations where both the outgoing and incoming page opt in.
143
+ //
144
+ // The default UA animation cross-fades the root: outgoing opacity 1→0 while
145
+ // incoming 0→1, both at once. Mid-navigation that leaves the two pages
146
+ // superimposed at ~50% opacity — headings ghost over headings. We override it
147
+ // with a directional fade+slide (old lifts up and out, new rises up and in) so
148
+ // the pages move past each other instead of stacking. Short + eased = peppy.
142
149
  const wantTransitions = compiled.meta.transitions === true || compiled.meta.transitions === "true";
143
- const transitions = wantTransitions ? `\n <style>@view-transition { navigation: auto; }</style>` : "";
150
+ const transitions = wantTransitions
151
+ ? `\n <style>
152
+ @view-transition { navigation: auto; }
153
+ ::view-transition-old(root) { animation: wd-nav-out 200ms cubic-bezier(0.4, 0, 1, 1) both; }
154
+ ::view-transition-new(root) { animation: wd-nav-in 200ms cubic-bezier(0, 0, 0.2, 1) both; }
155
+ @keyframes wd-nav-out { to { opacity: 0; transform: translateY(-1rem); } }
156
+ @keyframes wd-nav-in { from { opacity: 0; transform: translateY(1rem); } }
157
+ @media (prefers-reduced-motion: reduce) {
158
+ ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 1ms; }
159
+ @keyframes wd-nav-out { to { opacity: 0; } }
160
+ @keyframes wd-nav-in { from { opacity: 0; } }
161
+ }
162
+ </style>`
163
+ : "";
164
+
165
+ // The latency half of smooth navigation — and what kills the white flash. A
166
+ // declarative speculationrules script (the browser interprets it — not
167
+ // framework runtime JS, so the zero-JS invariant holds) *prerenders* the next
168
+ // same-origin page on hover/pointerdown. Prefetch only warms the cache, so the
169
+ // page still has to render on click — that render gap is the white flash. A
170
+ // prerender renders the whole page in a hidden tab ahead of the click, so
171
+ // activation is instant: no render gap, and the view transition fires on the
172
+ // already-painted page. Safe now that pages are light. Eagerness `moderate` =
173
+ // ~200ms hover / pointerdown, capped at two. Mark a link `{.no-prefetch}` to
174
+ // opt it out; `rel=nofollow` links are never speculated. Browsers without
175
+ // support (or with preloading disabled) ignore the tag and navigate normally.
176
+ const speculation = wantTransitions
177
+ ? `\n <script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"selector_matches":".no-prefetch"}},{"not":{"selector_matches":"[rel~=nofollow]"}}]},"eagerness":"moderate"}]}</script>`
178
+ : "";
179
+
180
+ const body = enhanceImages(compiled.html, context);
144
181
 
145
182
  return {
146
183
  meta: compiled.meta,
@@ -151,10 +188,10 @@ export function compilePage(file, context) {
151
188
  <meta name="viewport" content="width=device-width, initial-scale=1">
152
189
  <title>${escapeHtml(title)}</title>${descriptionTag}
153
190
  <link rel="icon" href="${favicon}">
154
- ${cssLinks}${transitions}
191
+ ${cssLinks}${transitions}${speculation}
155
192
  </head>
156
193
  <body>
157
- ${compiled.html}
194
+ ${body}
158
195
  ${scripts}
159
196
  </body>
160
197
  </html>`,
@@ -163,6 +200,80 @@ ${scripts}
163
200
  };
164
201
  }
165
202
 
203
+ /**
204
+ * Harden every `<img>` in the assembled page body — compile-time only, zero
205
+ * runtime JS. Bare markdown images reflow the page as they decode (cumulative
206
+ * layout shift — the literal "jump" on navigation) and load eagerly. This
207
+ * stamps intrinsic `width`/`height` (read from the file on disk so the browser
208
+ * reserves space), `decoding="async"` on all, and a load-priority split: the
209
+ * first image is the LCP candidate (eager + `fetchpriority="high"`), the rest
210
+ * lazy-load. Author-set attributes are never overwritten.
211
+ * @param {string} html Assembled page body HTML.
212
+ * @param {Paths} paths Resolved project paths.
213
+ * @returns {string}
214
+ */
215
+ export function enhanceImages(html, paths) {
216
+ let index = 0;
217
+ return html.replace(/<img\b[^>]*?\/?>/g, (tag) => {
218
+ const i = index++;
219
+ const has = (/** @type {string} */ attr) => new RegExp(`\\b${attr}\\s*=`, "i").test(tag);
220
+ /** @type {string[]} */
221
+ const add = [];
222
+ // Dimensions only when the author hasn't sized it — adding one axis to a
223
+ // manually-sized image would distort its aspect ratio.
224
+ if (!has("width") && !has("height")) {
225
+ const dim = measureImage(srcOf(tag), paths);
226
+ if (dim) add.push(`width="${dim.width}"`, `height="${dim.height}"`);
227
+ }
228
+ if (!has("decoding")) add.push(`decoding="async"`);
229
+ if (i === 0) {
230
+ // First image is the LCP candidate: stays eager, gets a priority hint.
231
+ if (!has("fetchpriority")) add.push(`fetchpriority="high"`);
232
+ } else if (!has("loading")) {
233
+ add.push(`loading="lazy"`);
234
+ }
235
+ if (!add.length) return tag;
236
+ return `${tag.replace(/\s*\/?>\s*$/, "")} ${add.join(" ")}>`;
237
+ });
238
+ }
239
+
240
+ /**
241
+ * Extract the `src` attribute value from an `<img>` tag, or "" if absent.
242
+ * @param {string} tag
243
+ * @returns {string}
244
+ */
245
+ function srcOf(tag) {
246
+ const m = tag.match(/\bsrc\s*=\s*["']([^"']*)["']/i);
247
+ return m ? m[1] : "";
248
+ }
249
+
250
+ /**
251
+ * Read an image's intrinsic dimensions from disk, or null when the src is
252
+ * remote/relative/unreadable. Resolution mirrors the asset emit: `/__wd/media/x`
253
+ * comes from the shelf (`site/_`), other absolute paths from `site/pages`.
254
+ * @param {string} src
255
+ * @param {Paths} paths
256
+ * @returns {{ width: number, height: number } | null}
257
+ */
258
+ function measureImage(src, paths) {
259
+ if (!src || /^(https?:)?\/\//i.test(src) || src.startsWith("data:")) return null;
260
+ let filePath;
261
+ if (src.startsWith("/__wd/media/")) {
262
+ filePath = path.join(paths.shelfRoot, src.slice("/__wd/media/".length));
263
+ } else if (src.startsWith("/")) {
264
+ filePath = path.join(paths.routesRoot, src.slice(1));
265
+ } else {
266
+ return null; // page-relative: the source directory is lost after assembly
267
+ }
268
+ try {
269
+ const { width, height } = imageSize(fs.readFileSync(filePath));
270
+ if (typeof width === "number" && typeof height === "number") return { width, height };
271
+ } catch {
272
+ /* missing or unreadable — degrade to no dimensions */
273
+ }
274
+ return null;
275
+ }
276
+
166
277
  /**
167
278
  * Compile a source file into its body HTML, frontmatter, and assets (no page shell).
168
279
  * @param {string} file Absolute path to the source file.
@@ -216,6 +327,7 @@ function compileFile(file, context, stack, scope, comp, sections, loopItem) {
216
327
 
217
328
  const raw = fs.readFileSync(file, "utf8");
218
329
  const { meta, body } = parseFrontmatter(raw, file);
330
+ warnLikelyFrontmatter(raw, file, comp);
219
331
  collectColocatedAssets(file, context, comp.assets);
220
332
 
221
333
  if (path.extname(file) === ".md") {
@@ -254,6 +366,37 @@ export function parseFrontmatter(raw, file) {
254
366
  return { meta, body };
255
367
  }
256
368
 
369
+ /**
370
+ * Warn (non-fatal) when a file looks like it MEANT to open frontmatter but forgot
371
+ * the leading `---`: the first content line is a `key: value` pair and a bare
372
+ * `---` fence appears within the opening lines. Conservative — a normal markdown
373
+ * body whose first line happens to contain a colon (with no early `---`) is left
374
+ * alone, since `---` is also a valid horizontal rule.
375
+ * @param {string} raw
376
+ * @param {string} file
377
+ * @param {Compilation} comp
378
+ * @returns {void}
379
+ */
380
+ function warnLikelyFrontmatter(raw, file, comp) {
381
+ if (raw.startsWith("---\n")) return; // a real opener: parseFrontmatter handled it
382
+ const lines = raw.replace(/\r\n?/g, "\n").split("\n");
383
+ let i = 0;
384
+ while (i < lines.length && !lines[i].trim()) i++; // skip leading blanks
385
+ if (i >= lines.length || !/^[A-Za-z][\w-]*:\s*\S/.test(lines[i])) return;
386
+ // Only warn when the run of lines up to a bare `---` is ALL `key: value` pairs:
387
+ // a forgotten opener, not prose that merely contains a colon and a later rule.
388
+ for (let j = i; j < lines.length && j < i + 12; j++) {
389
+ const t = lines[j].trim();
390
+ if (t === "---") {
391
+ comp.warnings.push(
392
+ `${file}: this looks like frontmatter missing its opening "---". Use: a "---" line before the fields (and another "---" to close) — otherwise the block renders as page text.`
393
+ );
394
+ return;
395
+ }
396
+ if (t && !/^[A-Za-z][\w-]*:\s*\S/.test(t)) return; // hit prose → not frontmatter
397
+ }
398
+ }
399
+
257
400
  // Frontmatter values are scalars, except an inline flow array `[a, b, c]`.
258
401
  // Block sequences (`- item` on following lines) are intentionally out of scope —
259
402
  // the parser stays single-pass and line-based.
@@ -460,6 +603,7 @@ function compileBody(lines, ctx) {
460
603
  if (/^(@endloop|:endif|:endfor|:endform|:else)\s*$/.test(line)) {
461
604
  throw new Error(`Stray "${line.trim()}" with no matching opener in ${ctx.file}`);
462
605
  }
606
+ warnUnknownDirective(line, ctx);
463
607
  prose.push(line);
464
608
  }
465
609
 
@@ -467,6 +611,44 @@ function compileBody(lines, ctx) {
467
611
  return out.join("\n");
468
612
  }
469
613
 
614
+ // Lines that reached prose but LOOK like a directive (a `@word`/`:word` token at
615
+ // the very start, followed by a space or end of line) and match no handler are
616
+ // almost always a typo — every real directive was intercepted above. Warn, never
617
+ // throw: prose legitimately contains `@` and `:`, so the match is deliberately
618
+ // narrow (lowercase token + boundary; emoji shortcodes, times, and `@user`
619
+ // followed by punctuation do not match).
620
+ const KNOWN_DIRECTIVE =
621
+ /^(?:@(?:include|loop|empty|endloop)|:(?:state|store|fetch|computed|effect|form|endform|input|textarea|select|checkbox|radio|bind|submit|button|if|endif|else|try|note|sprint))(?:\s|$)/;
622
+ /**
623
+ * @param {string} line
624
+ * @param {Ctx} ctx
625
+ * @returns {void}
626
+ */
627
+ function warnUnknownDirective(line, ctx) {
628
+ // Catch capitalized (`:State`) and hyphenated (`:end-loop`) typos too — the
629
+ // known-directive whitelist stays case-sensitive, so a mis-cased real
630
+ // directive correctly trips the warning. Still unindented-only, to keep prose
631
+ // that legitimately starts lines with `@`/`:` from false-positiving.
632
+ const m = line.match(/^([@:][A-Za-z][A-Za-z-]*)(?:\s|$)/);
633
+ if (m && !KNOWN_DIRECTIVE.test(line)) {
634
+ ctx.comp.warnings.push(
635
+ `${ctx.file}: "${m[1]}" looks like a directive but matches none — it will render as literal text. Check the spelling (e.g. @loop, @include, :state, :if).`
636
+ );
637
+ }
638
+ }
639
+
640
+ /**
641
+ * Format a source location as `file:line` (1-based) for compile errors. Keeps the
642
+ * file path intact so existing message matchers still pass, while pointing at the
643
+ * unclosed opener's line.
644
+ * @param {string} file
645
+ * @param {number} index 0-based line index of the opener.
646
+ * @returns {string}
647
+ */
648
+ function at(file, index) {
649
+ return `${file}:${index + 1}`;
650
+ }
651
+
470
652
  /**
471
653
  * Capture lines from an opener until a matching close token, honoring nesting and fences.
472
654
  * @param {string[]} lines
@@ -501,7 +683,7 @@ function scanBlock(lines, start, openRe, endToken, file) {
501
683
  }
502
684
  body.push(line);
503
685
  }
504
- throw new Error(`Missing ${endToken} for "${lines[start]}" in ${file}`);
686
+ throw new Error(`Missing ${endToken} for "${lines[start]}" in ${at(file, start)}`);
505
687
  }
506
688
 
507
689
  /**
@@ -572,7 +754,7 @@ function scanContainer(lines, start, file) {
572
754
  }
573
755
  body.push(line);
574
756
  }
575
- throw new Error(`Missing closing ::: for "${lines[start]}" in ${file}`);
757
+ throw new Error(`Missing closing ::: for "${lines[start]}" in ${at(file, start)}`);
576
758
  }
577
759
 
578
760
  /**
@@ -641,7 +823,7 @@ function scanConditional(lines, start, file) {
641
823
  }
642
824
  current.push(line);
643
825
  }
644
- throw new Error(`Missing :endif for "${lines[start]}" in ${file}`);
826
+ throw new Error(`Missing :endif for "${lines[start]}" in ${at(file, start)}`);
645
827
  }
646
828
 
647
829
  // ---------------------------------------------------------------------------
@@ -1526,6 +1708,11 @@ function handleLoop(line, bodyLines, emptyLines, ctx) {
1526
1708
  const fullKey = segs.length > 1 ? `${key}.${segs.slice(1).join(".")}` : key;
1527
1709
  return reactiveLoop(fullKey, itemName, bodyLines, ctx, opts);
1528
1710
  }
1711
+ // Inside a reactive loop, `@loop <outerItem>.<path> into x` loops a field of
1712
+ // the enclosing row item — an ITEM-RELATIVE loop the runtime fills per row.
1713
+ if (ctx.loopItem && segs[0] === ctx.loopItem && segs.length > 1) {
1714
+ return itemRelativeLoop(segs.slice(1).join("."), itemName, bodyLines, ctx, opts);
1715
+ }
1529
1716
  }
1530
1717
 
1531
1718
  throw new Error(
@@ -1800,15 +1987,7 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
1800
1987
  const templateCtx = { ...ctx, loopItem: itemName, loopKey: key ?? undefined, loopMeta: true, scope: createScope(ctx.scope) };
1801
1988
  const templateHtml = compileBody(bodyLines, templateCtx).trim();
1802
1989
 
1803
- let wrapperTag = "div";
1804
- let itemTemplate = templateHtml;
1805
- const listMatch = templateHtml.match(/^<(ul|ol)>\s*([\s\S]*?)\s*<\/\1>$/);
1806
- if (listMatch && (listMatch[2].match(/<li>/g) || []).length === 1) {
1807
- wrapperTag = listMatch[1];
1808
- itemTemplate = listMatch[2].trim();
1809
- } else {
1810
- itemTemplate = `<div data-wd-loop-piece>${templateHtml}</div>`;
1811
- }
1990
+ const { wrapperTag, itemTemplate } = wrapRowTemplate(templateHtml);
1812
1991
 
1813
1992
  const where = opts?.where || null;
1814
1993
  const sort = opts?.sort || null;
@@ -1830,27 +2009,132 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
1830
2009
  /** @type {Map<string, number>} */
1831
2010
  const counts = new Map();
1832
2011
  const count = rows.length;
2012
+ // Inner ITEM-RELATIVE loop regions are filled per row by the runtime, not at
2013
+ // build time; mask them so the outer initial-paint string fill leaves their
2014
+ // pristine <template>/empty-out markup untouched, then restore.
2015
+ const { masked, regions } = maskItemLoops(itemTemplate);
1833
2016
  const initial = rows
1834
2017
  .map((/** @type {unknown} */ item, i) => {
1835
2018
  const itemKey = loopKeyOf(item, counts);
1836
2019
  const meta = { index: i, number: i + 1, first: i === 0, last: i === count - 1, count };
1837
- return fillTemplateString(withLoopKey(itemTemplate, itemKey), item, meta);
2020
+ return unmaskItemLoops(fillTemplateString(withLoopKey(masked, itemKey), item, meta), regions);
1838
2021
  })
1839
2022
  .join("");
1840
2023
 
1841
- const emptyTemplate = emptyLines ? `<template data-wd-loop-empty>${compileBody(emptyLines, { ...ctx, loopMeta: true }).trim()}</template>` : "";
2024
+ const emptyTemplate = loopEmptyTemplate(emptyLines, ctx);
1842
2025
  const initialOut = count === 0 && emptyLines ? compileBody(emptyLines, { ...ctx, loopMeta: true }).trim() : initial;
1843
2026
 
1844
- const attrs =
1845
- (where ? ` data-wd-loop-where="${escapeHtml(where.body)}"` : "") +
1846
- (sort ? ` data-wd-loop-sort="${escapeHtml(sort.key)}" data-wd-loop-sort-dir="${sort.dir}"` : "") +
1847
- (reverse ? ` data-wd-loop-reverse` : "") +
1848
- (offset ? ` data-wd-loop-offset="${numArgAttr(offset)}"` : "") +
1849
- (limit ? ` data-wd-loop-limit="${numArgAttr(limit)}"` : "") +
2027
+ const attrs = loopClauseAttrs({ where, sort, reverse, offset, limit }) +
1850
2028
  (baked ? ` data-wd-loop-data="${escapeHtml(JSON.stringify(baked))}"` : "");
1851
2029
  return `<div data-wd-loop="${escapeHtml(key || "")}"${attrs}><template data-wd-loop-template>${itemTemplate}</template>${emptyTemplate}<${wrapperTag} data-wd-loop-out>${initialOut}</${wrapperTag}></div>`;
1852
2030
  }
1853
2031
 
2032
+ /**
2033
+ * Unwrap a compiled row body into a wrapper tag + row template. A body that is a
2034
+ * single-`<li>` `<ul>`/`<ol>` becomes an `<li>` row under that list wrapper;
2035
+ * anything else is wrapped in a `<div data-wd-loop-piece>` under a `<div>`. Shared
2036
+ * by top-level reactive loops and item-relative (nested) loops.
2037
+ * @param {string} templateHtml
2038
+ * @returns {{ wrapperTag: string, itemTemplate: string }}
2039
+ */
2040
+ function wrapRowTemplate(templateHtml) {
2041
+ const listMatch = templateHtml.match(/^<(ul|ol)>\s*([\s\S]*?)\s*<\/\1>$/);
2042
+ if (listMatch && (listMatch[2].match(/<li>/g) || []).length === 1) {
2043
+ return { wrapperTag: listMatch[1], itemTemplate: listMatch[2].trim() };
2044
+ }
2045
+ return { wrapperTag: "div", itemTemplate: `<div data-wd-loop-piece>${templateHtml}</div>` };
2046
+ }
2047
+
2048
+ /**
2049
+ * Compile an `@empty` branch body into a `<template data-wd-loop-empty>` (or "" when
2050
+ * there is no empty branch). Shared by top-level and item-relative loops.
2051
+ * @param {string[] | null} emptyLines
2052
+ * @param {Ctx} ctx
2053
+ * @returns {string}
2054
+ */
2055
+ function loopEmptyTemplate(emptyLines, ctx) {
2056
+ return emptyLines ? `<template data-wd-loop-empty>${compileBody(emptyLines, { ...ctx, loopMeta: true }).trim()}</template>` : "";
2057
+ }
2058
+
2059
+ /**
2060
+ * Serialize the shared `where`/`sort`/`reverse`/`offset`/`limit` clause config to
2061
+ * the `data-wd-loop-*` attribute string consumed by the runtime pipeline. Shared
2062
+ * by top-level reactive loops and item-relative (nested) loops.
2063
+ * @param {Pick<LoopOpts, "where" | "sort" | "reverse" | "offset" | "limit">} c
2064
+ * @returns {string}
2065
+ */
2066
+ function loopClauseAttrs(c) {
2067
+ return (
2068
+ (c.where ? ` data-wd-loop-where="${escapeHtml(c.where.body)}"` : "") +
2069
+ (c.sort ? ` data-wd-loop-sort="${escapeHtml(c.sort.key)}" data-wd-loop-sort-dir="${c.sort.dir}"` : "") +
2070
+ (c.reverse ? ` data-wd-loop-reverse` : "") +
2071
+ (c.offset ? ` data-wd-loop-offset="${numArgAttr(c.offset)}"` : "") +
2072
+ (c.limit ? ` data-wd-loop-limit="${numArgAttr(c.limit)}"` : "")
2073
+ );
2074
+ }
2075
+
2076
+ /**
2077
+ * Emit ONE level of nested reactive loop: a `@loop <outerItem>.<path> into x`
2078
+ * whose rows are read off the enclosing loop row at runtime. The region carries
2079
+ * its own row <template> + clause config but, unlike a top-level loop, its source
2080
+ * is the relative `path` (not a global state key) — `fillItem` resolves the rows
2081
+ * off the current outer item and reconciles per row. The output starts empty; the
2082
+ * runtime fills it on first render (the page already ships the runtime).
2083
+ * @param {string} path Dotted sub-path off the outer item (e.g. `items`).
2084
+ * @param {string} itemName Inner loop item name.
2085
+ * @param {string[]} bodyLines Inner loop body.
2086
+ * @param {Ctx} ctx
2087
+ * @param {Partial<LoopOpts>} opts
2088
+ * @returns {string}
2089
+ */
2090
+ function itemRelativeLoop(path, itemName, bodyLines, ctx, opts) {
2091
+ /** @type {Ctx} */
2092
+ // loopKey: undefined — an item-relative loop's source is a path off the outer
2093
+ // row, not a top-level state key, so a per-row `remove` inside it has no valid
2094
+ // target and must be rejected (rather than inheriting the outer loop's key).
2095
+ const templateCtx = { ...ctx, loopItem: itemName, loopKey: undefined, loopMeta: true, scope: createScope(ctx.scope) };
2096
+ const templateHtml = compileBody(bodyLines, templateCtx).trim();
2097
+
2098
+ const { wrapperTag, itemTemplate } = wrapRowTemplate(templateHtml);
2099
+
2100
+ const emptyTemplate = loopEmptyTemplate(opts.empty || null, ctx);
2101
+ const attrs = loopClauseAttrs({ where: opts.where || null, sort: opts.sort || null, reverse: opts.reverse || false, offset: opts.offset || null, limit: opts.limit || null });
2102
+ return `<div data-wd-loop-item="${escapeHtml(path)}"${attrs}><template data-wd-loop-template>${itemTemplate}</template>${emptyTemplate}<${wrapperTag} data-wd-loop-out></${wrapperTag}></div>`;
2103
+ }
2104
+
2105
+ /**
2106
+ * Replace each top-level `data-wd-loop-item` region in a row template with an
2107
+ * opaque placeholder so the outer initial-paint string fill cannot reach inside
2108
+ * its pristine markup. Returns the masked string and the excised regions in order.
2109
+ * @param {string} template
2110
+ * @returns {{ masked: string, regions: string[] }}
2111
+ */
2112
+ function maskItemLoops(template) {
2113
+ const marker = "<div data-wd-loop-item=";
2114
+ /** @type {string[]} */
2115
+ const regions = [];
2116
+ let masked = "";
2117
+ let i = 0;
2118
+ for (;;) {
2119
+ const start = template.indexOf(marker, i);
2120
+ if (start === -1) return { masked: masked + template.slice(i), regions };
2121
+ const end = matchElement(template, start, "div");
2122
+ masked += template.slice(i, start) + `\x00WDLI${regions.length}\x00`;
2123
+ regions.push(template.slice(start, end));
2124
+ i = end;
2125
+ }
2126
+ }
2127
+
2128
+ /**
2129
+ * Restore the masked `data-wd-loop-item` regions into a filled row string.
2130
+ * @param {string} str
2131
+ * @param {string[]} regions
2132
+ * @returns {string}
2133
+ */
2134
+ function unmaskItemLoops(str, regions) {
2135
+ return regions.length ? str.replace(/\x00WDLI(\d+)\x00/g, (_, n) => regions[Number(n)]) : str;
2136
+ }
2137
+
1854
2138
  /**
1855
2139
  * @param {string} template
1856
2140
  * @param {string} itemKey
@@ -2396,7 +2680,13 @@ function parseSingleAction(expression, raw, ctx) {
2396
2680
  if (remove) {
2397
2681
  const operand = remove[2].trim();
2398
2682
  if (ctx.loopItem && operand === ctx.loopItem) {
2399
- if (!ctx.loopKey || resolveStateKey(remove[1], ctx) !== ctx.loopKey) {
2683
+ // A nested (item-relative) loop has a loop item but no top-level state key —
2684
+ // its source is a path off the outer row, which the runtime's row-remove
2685
+ // can't target. Fail loud with the honest workaround.
2686
+ if (!ctx.loopKey) {
2687
+ throw new Error(`Button action "${raw}" can't delete a row of a nested (item-relative) loop in ${ctx.file}. Per-row "remove" needs a top-level :state/:store list; carry the row into one (cart += ${ctx.loopItem}) and remove it there.`);
2688
+ }
2689
+ if (resolveStateKey(remove[1], ctx) !== ctx.loopKey) {
2400
2690
  throw new Error(`Button action "${raw}" must target the :state list being looped (@loop ${remove[1]} into ${ctx.loopItem}) in ${ctx.file}.`);
2401
2691
  }
2402
2692
  return { op: "remove", target: ctx.loopKey };
package/src/headers.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Security headers Darkmown emits on every delivery surface — the local
3
+ * `darkmown serve`/preview server (`src/statics.js`), Cloudflare Pages
4
+ * (`dist/_headers`, written by `src/builder.js`), and Vercel (`vercel.json`).
5
+ *
6
+ * `statics.js` and the generated `_headers` derive from the values here, so they
7
+ * never drift. `vercel.json` is the exception: Vercel reads it BEFORE the build
8
+ * runs, so its CSP strings and static-route list are hand-maintained copies, not
9
+ * build output. The `vercel-csp-routes` test cross-checks vercel.json against the
10
+ * built route manifest so a new static route can't silently miss the strict CSP.
11
+ *
12
+ * The CSP is derived from what the framework's own output actually emits
13
+ * (verify against built `dist/` HTML):
14
+ * - External script `/__wd/runtime.js` (same-origin) on reactive pages.
15
+ * - Inline `<script type="application/json" data-wd-state>` (state seed) and
16
+ * inline `<script type="speculationrules">` (prerender) — JSON/speculation
17
+ * blocks can't be nonce'd cleanly, so `script-src 'unsafe-inline'`.
18
+ * - The reactive runtime compiles validated expressions via `new Function`,
19
+ * so reactive pages additionally need `script-src 'unsafe-eval'`.
20
+ * - Inline `<style>` for view transitions — `style-src 'unsafe-inline'`.
21
+ * - `data:` favicon and remote images — `img-src 'self' data: https:`.
22
+ *
23
+ * Static pages (zero framework JS) still emit inline `<style>` and an inline
24
+ * `<script type="speculationrules">`, so they keep `'unsafe-inline'`, but they
25
+ * never call `new Function`, so their CSP drops `'unsafe-eval'`.
26
+ */
27
+
28
+ /**
29
+ * `connect-src` for the demo is `'self'`: the framework's own pages only
30
+ * `:fetch` same-origin endpoints. Apps whose `:fetch` targets a remote host
31
+ * must widen this (e.g. to `connect-src 'self' https://api.example.com` or
32
+ * `connect-src *`) by editing their `vercel.json` / `_headers` / serve config.
33
+ * @type {string}
34
+ */
35
+ const CONNECT_SRC = "connect-src 'self'";
36
+
37
+ /**
38
+ * Directives shared by both the static and reactive CSP. Order is cosmetic.
39
+ * @type {string[]}
40
+ */
41
+ const COMMON_CSP_DIRECTIVES = [
42
+ "default-src 'self'",
43
+ "style-src 'self' 'unsafe-inline'",
44
+ "img-src 'self' data: https:",
45
+ "font-src 'self'",
46
+ CONNECT_SRC,
47
+ "object-src 'none'",
48
+ "base-uri 'self'",
49
+ "frame-ancestors 'self'"
50
+ ];
51
+
52
+ /**
53
+ * Relaxed CSP for reactive pages: `new Function` needs `'unsafe-eval'`, the
54
+ * inline state/speculationrules JSON needs `'unsafe-inline'`.
55
+ * @type {string}
56
+ */
57
+ export const REACTIVE_CSP = [
58
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
59
+ ...COMMON_CSP_DIRECTIVES
60
+ ].join("; ");
61
+
62
+ /**
63
+ * Stricter CSP for static pages: no runtime means no `new Function`, so
64
+ * `'unsafe-eval'` is dropped. Inline `<style>`/`<script type=speculationrules>`
65
+ * keep `'unsafe-inline'` on script/style.
66
+ * @type {string}
67
+ */
68
+ export const STATIC_CSP = [
69
+ "script-src 'self' 'unsafe-inline'",
70
+ ...COMMON_CSP_DIRECTIVES
71
+ ].join("; ");
72
+
73
+ /**
74
+ * Baseline security headers applied to every HTML response, independent of the
75
+ * CSP. CSP is added per-route (static vs reactive) by the helpers below.
76
+ * @type {Record<string, string>}
77
+ */
78
+ export const BASE_SECURITY_HEADERS = {
79
+ "X-Content-Type-Options": "nosniff",
80
+ "Referrer-Policy": "strict-origin-when-cross-origin",
81
+ "X-Frame-Options": "SAMEORIGIN"
82
+ };
83
+
84
+ /**
85
+ * The full security-header set for one response, including the CSP variant.
86
+ * @param {boolean} reactive Whether the route ships the reactive runtime.
87
+ * @returns {Record<string, string>}
88
+ */
89
+ export function securityHeaders(reactive) {
90
+ return {
91
+ ...BASE_SECURITY_HEADERS,
92
+ "Content-Security-Policy": reactive ? REACTIVE_CSP : STATIC_CSP
93
+ };
94
+ }