@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/CHANGELOG.md +19 -0
- package/README.md +61 -2
- package/SECURITY.md +39 -4
- package/docs/spec-alignment.md +15 -1
- package/package.json +4 -3
- package/site/_/nav.wd +1 -0
- package/site/pages/data.wd +1 -0
- package/site/pages/docs/index.wd +9 -0
- package/site/pages/index.wd +1 -0
- package/site/pages/loops.wd +23 -0
- package/site/pages/markdown.md +1 -0
- package/site/pages/reactive.wd +1 -0
- package/site/pages/store.wd +1 -0
- package/site/pages/theme.skin +53 -0
- package/site/pages/theme.wd +58 -0
- package/src/builder.js +50 -0
- package/src/compiler.js +317 -27
- package/src/headers.js +94 -0
- package/src/runtime.js +83 -59
- package/src/skin.js +60 -2
- package/src/statics.js +19 -2
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
|
|
139
|
-
//
|
|
140
|
-
//
|
|
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
|
|
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
|
-
${
|
|
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
|
-
|
|
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(
|
|
2020
|
+
return unmaskItemLoops(fillTemplateString(withLoopKey(masked, itemKey), item, meta), regions);
|
|
1838
2021
|
})
|
|
1839
2022
|
.join("");
|
|
1840
2023
|
|
|
1841
|
-
const emptyTemplate =
|
|
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
|
-
|
|
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
|
+
}
|