@zvndev/darkmown 1.4.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +17 -3
- package/CHANGELOG.md +38 -0
- package/README.md +114 -17
- package/SECURITY.md +21 -18
- package/docs/cli.md +5 -1
- package/docs/spec-alignment.md +31 -4
- package/package.json +1 -1
- package/site/_/base.skin +239 -18
- package/site/_/base.wd +4 -0
- package/site/_/feature-card.wd +4 -0
- package/site/_/footer.wd +4 -0
- package/site/_/nav.wd +5 -1
- package/site/_/scoped-promo.wd +4 -0
- package/site/_/scoped-stat.wd +4 -0
- package/site/pages/404.wd +1 -0
- package/site/pages/app.skin +27 -5
- package/site/pages/app.wd +1 -0
- package/site/pages/blog/_schema.wd +15 -0
- package/site/pages/blog/collections-and-pagination.md +38 -0
- package/site/pages/blog/collections-and-pagination.skin +127 -0
- package/site/pages/blog/drafts-that-never-leak.md +2 -0
- package/site/pages/blog/drafts-that-never-leak.skin +127 -0
- package/site/pages/blog/index.skin +144 -0
- package/site/pages/blog/index.wd +74 -0
- package/site/pages/blog/shipping-feeds.md +1 -0
- package/site/pages/blog/shipping-feeds.skin +127 -0
- package/site/pages/blog/typed-frontmatter.md +45 -0
- package/site/pages/blog/typed-frontmatter.skin +127 -0
- package/site/pages/blog/unfinished-thoughts.skin +127 -0
- package/site/pages/blog/zero-js-by-default.md +31 -0
- package/site/pages/blog/zero-js-by-default.skin +127 -0
- package/site/pages/carousel/index.wd +1 -0
- package/site/pages/compass/index.wd +1 -0
- package/site/pages/data.skin +50 -9
- package/site/pages/data.wd +3 -0
- package/site/pages/docs/-relative-note.wd +4 -0
- package/site/pages/docs/index.skin +27 -0
- package/site/pages/docs/index.wd +115 -11
- package/site/pages/folio/-style.wd +4 -0
- package/site/pages/folio/cart.wd +1 -0
- package/site/pages/folio/index.wd +1 -0
- package/site/pages/forge/index.wd +1 -0
- package/site/pages/highlight/index.wd +1 -0
- package/site/pages/index.wd +1 -0
- package/site/pages/interactions/index.wd +1 -0
- package/site/pages/ledger/index.wd +1 -0
- package/site/pages/loops.skin +43 -6
- package/site/pages/loops.wd +1 -0
- package/site/pages/media/index.wd +1 -0
- package/site/pages/pulse/index.wd +1 -0
- package/site/pages/reactive.skin +38 -6
- package/site/pages/reactive.wd +1 -0
- package/site/pages/scoped/index.wd +1 -0
- package/site/pages/showcase/index.wd +1 -0
- package/site/pages/store.skin +43 -6
- package/site/pages/store.wd +1 -0
- package/site/pages/theme.wd +1 -0
- package/src/builder.js +535 -48
- package/src/cli.js +96 -18
- package/src/compiler/actions.js +303 -0
- package/src/compiler/body.js +36 -24
- package/src/compiler/collections.js +316 -0
- package/src/compiler/context.js +83 -6
- package/src/compiler/directives.js +31 -1470
- package/src/compiler/fetch.js +159 -0
- package/src/compiler/format.js +17 -4
- package/src/compiler/forms.js +456 -0
- package/src/compiler/frontmatter.js +10 -5
- package/src/compiler/includes.js +66 -1
- package/src/compiler/index.js +15 -3
- package/src/compiler/loops.js +181 -31
- package/src/compiler/markdown.js +65 -15
- package/src/compiler/media.js +133 -0
- package/src/compiler/page.js +78 -41
- package/src/compiler/state.js +168 -0
- package/src/compiler/structure.js +377 -0
- package/src/dev.js +76 -0
- package/src/headers.js +56 -17
- package/src/router.js +12 -1
- package/src/runtime.js +5 -3
- package/src/statics.js +63 -12
- package/src/templates/blog/README.md +13 -4
- package/src/templates/blog/site/_/nav.wd +4 -0
- package/src/templates/blog/site/pages/index.skin +87 -0
- package/src/templates/blog/site/pages/index.wd +10 -4
- package/src/templates/blog/site/pages/posts/_schema.wd +16 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.skin +59 -0
- package/src/templates/blog/site/pages/posts/markdown-native.md +9 -4
- package/src/templates/blog/site/pages/posts/markdown-native.skin +59 -0
- package/src/templates/dashboard/site/_/nav.wd +4 -0
- package/src/templates/dashboard/site/pages/index.skin +84 -0
- package/src/templates/dashboard/site/pages/index.wd +1 -0
- package/src/templates/landing/site/_/nav.wd +4 -0
- package/src/templates/landing/site/pages/index.skin +113 -6
- package/src/templates/landing/site/pages/index.wd +1 -0
- package/src/templates/starter/site/_/nav.wd +4 -0
- package/src/templates/starter/site/pages/about.md +18 -1
- package/src/templates/starter/site/pages/about.skin +69 -0
- package/src/templates/starter/site/pages/index.skin +81 -3
- package/src/templates/starter/site/pages/index.wd +1 -0
- package/src/templates/store/site/_/nav.wd +4 -0
- package/src/templates/store/site/pages/index.skin +93 -11
- package/src/templates/store/site/pages/index.wd +1 -0
- package/site/pages/blog/index.md +0 -13
- package/src/templates/blog/site/_/posts.json +0 -16
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Media — :video / :audio / :embed. Compile-time only (zero runtime): hardened
|
|
3
|
+
// HTML5 media and privacy-friendly, lazy iframe embeds. The extension stays the
|
|
4
|
+
// feature gate; these emit no `data-wd-*`, so a media-only page ships zero JS.
|
|
5
|
+
//
|
|
6
|
+
// Every handler receives the 0-based line `index` so its malformed/invalid
|
|
7
|
+
// errors report `file:line` (via `at`) — matching the unclosed-block errors —
|
|
8
|
+
// while keeping the message text and `Use:` hints intact.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
import { at } from "./context.js";
|
|
12
|
+
import { validateFetchUrl } from "./fetch.js";
|
|
13
|
+
import { escapeHtml, stripQuotes } from "./interpolation.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {import("./context.js").Ctx} Ctx
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** @type {Record<"video" | "audio", { flags: string[], attrs: string[] }>} */
|
|
20
|
+
const MEDIA_SPEC = {
|
|
21
|
+
video: {
|
|
22
|
+
flags: ["controls", "autoplay", "loop", "muted", "playsinline"],
|
|
23
|
+
attrs: ["poster", "width", "height", "preload"]
|
|
24
|
+
},
|
|
25
|
+
audio: { flags: ["controls", "autoplay", "loop", "muted"], attrs: ["preload"] }
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `:video /clip.mp4 [poster=…] [width=…] [height=…] [preload=…] [controls] [autoplay]
|
|
30
|
+
* [loop] [muted] [playsinline]` and `:audio /track.mp3 [preload=…] [controls] …` →
|
|
31
|
+
* a hardened `<video>`/`<audio>`. Defaults: `preload="metadata"`, and `controls`
|
|
32
|
+
* unless the clip is an `autoplay` background (autoplay also implies `muted`, which
|
|
33
|
+
* browsers require). Author flags/attrs always win. URLs use the `:fetch` scheme
|
|
34
|
+
* guard (relative or http(s)).
|
|
35
|
+
* @param {string} line
|
|
36
|
+
* @param {"video" | "audio"} kind
|
|
37
|
+
* @param {Ctx} ctx
|
|
38
|
+
* @param {number} index 0-based line index for `file:line` errors.
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
export function handleMedia(line, kind, ctx, index) {
|
|
42
|
+
const match = line.match(new RegExp(`^:${kind}\\s+(\\S+)\\s*(.*)$`));
|
|
43
|
+
if (!match) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Malformed :${kind} in ${at(ctx, index)}: ${line}. Use: :${kind} /clip [controls] [autoplay] [loop] [muted]`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const src = validateFetchUrl(stripQuotes(match[1]), ctx, `:${kind}`);
|
|
49
|
+
const spec = MEDIA_SPEC[kind];
|
|
50
|
+
/** @type {Set<string>} */
|
|
51
|
+
const flags = new Set();
|
|
52
|
+
/** @type {Map<string, string>} */
|
|
53
|
+
const attrs = new Map();
|
|
54
|
+
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
55
|
+
for (const token of (match[2] || "").matchAll(re)) {
|
|
56
|
+
if (token[3]) {
|
|
57
|
+
if (!spec.flags.includes(token[3]))
|
|
58
|
+
throw new Error(`Unknown :${kind} flag "${token[3]}" in ${at(ctx, index)}`);
|
|
59
|
+
flags.add(token[3]);
|
|
60
|
+
} else {
|
|
61
|
+
if (!spec.attrs.includes(token[1]))
|
|
62
|
+
throw new Error(`Unknown :${kind} attribute "${token[1]}" in ${at(ctx, index)}`);
|
|
63
|
+
attrs.set(token[1], stripQuotes(token[2]));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Sensible defaults; author input always wins.
|
|
67
|
+
if (flags.has("autoplay")) flags.add("muted"); // browsers block unmuted autoplay
|
|
68
|
+
if (kind === "audio" || !flags.has("autoplay")) flags.add("controls");
|
|
69
|
+
const parts = [`src="${escapeHtml(src)}"`];
|
|
70
|
+
if (attrs.has("poster"))
|
|
71
|
+
parts.push(
|
|
72
|
+
`poster="${escapeHtml(validateFetchUrl(/** @type {string} */ (attrs.get("poster")), ctx, `:${kind} poster`))}"`
|
|
73
|
+
);
|
|
74
|
+
for (const name of ["width", "height"]) {
|
|
75
|
+
if (attrs.has(name))
|
|
76
|
+
parts.push(`${name}="${escapeHtml(/** @type {string} */ (attrs.get(name)))}"`);
|
|
77
|
+
}
|
|
78
|
+
parts.push(`preload="${escapeHtml(attrs.get("preload") || "metadata")}"`);
|
|
79
|
+
for (const flag of spec.flags) if (flags.has(flag)) parts.push(flag);
|
|
80
|
+
return `<${kind} ${parts.join(" ")}></${kind}>`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `:embed <url> [title="…"]` → a lazy, privacy-friendly responsive iframe. A
|
|
85
|
+
* YouTube or Vimeo URL is rewritten to its no-cookie / player embed; any other
|
|
86
|
+
* http(s) URL becomes a generic 16:9 iframe. Inline styles keep the wrapper
|
|
87
|
+
* self-contained (no framework CSS), so an embed-only page stays zero-JS/zero-CSS.
|
|
88
|
+
* @param {string} line
|
|
89
|
+
* @param {Ctx} ctx
|
|
90
|
+
* @param {number} index 0-based line index for `file:line` errors.
|
|
91
|
+
* @returns {string}
|
|
92
|
+
*/
|
|
93
|
+
export function handleEmbed(line, ctx, index) {
|
|
94
|
+
const match = line.match(/^:embed\s+(\S+)\s*(.*)$/);
|
|
95
|
+
if (!match) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Malformed :embed in ${at(ctx, index)}: ${line}. Use: :embed https://youtu.be/ID [title="…"]`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const raw = stripQuotes(match[1]);
|
|
101
|
+
let title = "";
|
|
102
|
+
const re = /([A-Za-z-]+)=("[^"]*"|\S+)/g;
|
|
103
|
+
for (const token of (match[2] || "").matchAll(re)) {
|
|
104
|
+
if (token[1] !== "title")
|
|
105
|
+
throw new Error(`Unknown :embed attribute "${token[1]}" in ${at(ctx, index)}`);
|
|
106
|
+
title = stripQuotes(token[2]);
|
|
107
|
+
}
|
|
108
|
+
const { url, label } = resolveEmbed(raw, ctx);
|
|
109
|
+
const iframe =
|
|
110
|
+
`<iframe src="${escapeHtml(url)}" title="${escapeHtml(title || label)}" loading="lazy" ` +
|
|
111
|
+
`referrerpolicy="strict-origin-when-cross-origin" ` +
|
|
112
|
+
`allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" ` +
|
|
113
|
+
`allowfullscreen style="width:100%;height:100%;border:0"></iframe>`;
|
|
114
|
+
return `<div class="wd-embed" style="aspect-ratio:16/9">${iframe}</div>`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Map a watch/share URL to its embeddable form. YouTube → no-cookie embed, Vimeo →
|
|
119
|
+
* player; anything else is validated as a generic http(s) iframe source.
|
|
120
|
+
* @param {string} raw
|
|
121
|
+
* @param {Ctx} ctx
|
|
122
|
+
* @returns {{ url: string, label: string }}
|
|
123
|
+
*/
|
|
124
|
+
function resolveEmbed(raw, ctx) {
|
|
125
|
+
const youtube = raw.match(
|
|
126
|
+
/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{6,})/
|
|
127
|
+
);
|
|
128
|
+
if (youtube)
|
|
129
|
+
return { url: `https://www.youtube-nocookie.com/embed/${youtube[1]}`, label: "YouTube video" };
|
|
130
|
+
const vimeo = raw.match(/vimeo\.com\/(?:video\/)?(\d+)/);
|
|
131
|
+
if (vimeo) return { url: `https://player.vimeo.com/video/${vimeo[1]}`, label: "Vimeo video" };
|
|
132
|
+
return { url: validateFetchUrl(raw, ctx, ":embed"), label: "Embedded content" };
|
|
133
|
+
}
|
package/src/compiler/page.js
CHANGED
|
@@ -12,7 +12,12 @@ import { htmlHasHighlight } from "../highlight.js";
|
|
|
12
12
|
import { compileBody } from "./body.js";
|
|
13
13
|
import { createCompilation, createScope } from "./context.js";
|
|
14
14
|
import { parseFrontmatter, warnLikelyFrontmatter } from "./frontmatter.js";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
collectColocatedAssets,
|
|
17
|
+
scanMarkdownHints,
|
|
18
|
+
scopedSkinFor,
|
|
19
|
+
stampScope
|
|
20
|
+
} from "./includes.js";
|
|
16
21
|
import { escapeHtml } from "./interpolation.js";
|
|
17
22
|
import { selectMd } from "./markdown.js";
|
|
18
23
|
|
|
@@ -26,19 +31,35 @@ import { selectMd } from "./markdown.js";
|
|
|
26
31
|
* @typedef {import("./context.js").CompiledPage} CompiledPage
|
|
27
32
|
*/
|
|
28
33
|
|
|
34
|
+
// Skip-to-content link: the first focusable element on every page, visually
|
|
35
|
+
// hidden until keyboard focus reveals it. Styled inline (not via a skin) so it
|
|
36
|
+
// behaves on pages with no stylesheet at all; it rides ahead of page skins in
|
|
37
|
+
// the head, so a project skin can restyle `.wd-skip-link` freely.
|
|
38
|
+
const SKIP_LINK_STYLE =
|
|
39
|
+
"<style>.wd-skip-link{position:absolute;top:0;left:0;z-index:100;padding:.55rem 1.1rem;background:#18221d;color:#f7f3ea;font:600 .9rem/1.2 system-ui,sans-serif;text-decoration:none;border-radius:0 0 8px 0;transform:translateY(-150%)}.wd-skip-link:focus{transform:none}</style>";
|
|
40
|
+
|
|
29
41
|
/**
|
|
30
42
|
* Compile a page source file into a full HTML document plus its assets.
|
|
31
43
|
* @param {string} file Absolute path to the source `.md`/`.wd` file.
|
|
32
44
|
* @param {Paths} context Resolved project paths.
|
|
33
|
-
* @param {{ feed?: { href: string, title: string } }} [options]
|
|
34
|
-
* RSS feed is emitted (the home page set `site_url` and the
|
|
35
|
-
* posts), `feed` carries its absolute href + title so every page
|
|
45
|
+
* @param {{ feed?: { href: string, title: string }, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown> }} [options]
|
|
46
|
+
* When a site-wide RSS feed is emitted (the home page set `site_url` and the
|
|
47
|
+
* site has dated posts), `feed` carries its absolute href + title so every page
|
|
48
|
+
* links it. `collections` is the build-time collection index a bare-name
|
|
49
|
+
* `@loop` resolves against; `vars` seeds the document scope (the pager `page`
|
|
50
|
+
* object on a paginated route).
|
|
36
51
|
* @returns {CompiledPage}
|
|
37
52
|
*/
|
|
38
53
|
export function compilePage(file, context, options = {}) {
|
|
39
|
-
const compiled = compileDocument(file, context);
|
|
54
|
+
const compiled = compileDocument(file, context, [], options.vars, options.collections);
|
|
40
55
|
const title = compiled.meta.title || "Darkmown";
|
|
41
56
|
const description = compiled.meta.description || "";
|
|
57
|
+
// Per-page document language for the `<html lang>` attribute (`lang: fr` in
|
|
58
|
+
// frontmatter). Defaults to English.
|
|
59
|
+
const lang =
|
|
60
|
+
typeof compiled.meta.lang === "string" && compiled.meta.lang.trim()
|
|
61
|
+
? compiled.meta.lang.trim()
|
|
62
|
+
: "en";
|
|
42
63
|
// Optional `image:` frontmatter sets the social-share preview (absolute URL).
|
|
43
64
|
const image = typeof compiled.meta.image === "string" ? compiled.meta.image : "";
|
|
44
65
|
/** @type {string[]} */
|
|
@@ -65,10 +86,8 @@ export function compilePage(file, context, options = {}) {
|
|
|
65
86
|
// The framework highlight stylesheet rides ahead of page skins so a project's
|
|
66
87
|
// own `$code-*` tokens / overrides still cascade over it. Pay-for-what-you-use:
|
|
67
88
|
// linked only on pages with a highlighted code block (`hasCode`).
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
: [];
|
|
71
|
-
const cssLinks = [...highlightLink, ...compiled.assets.skins]
|
|
89
|
+
const highlightHref = compiled.assets.hasCode ? ["/__wd/highlight.css"] : [];
|
|
90
|
+
const cssLinks = [...highlightHref, ...compiled.assets.skins]
|
|
72
91
|
.map((href) => `<link rel="stylesheet" href="${href}">`)
|
|
73
92
|
.join("\n");
|
|
74
93
|
// RSS feed discovery: when the site emits an `rss.xml` (home `site_url` set +
|
|
@@ -140,28 +159,53 @@ export function compilePage(file, context, options = {}) {
|
|
|
140
159
|
const stamped =
|
|
141
160
|
scopedSkin && scopedSkin.scoped ? stampScope(compiled.html, scopedSkin.scopeId) : compiled.html;
|
|
142
161
|
const body = enhanceImages(stamped, context);
|
|
162
|
+
const { content, target } = ensureMainLandmark(body);
|
|
143
163
|
|
|
144
164
|
return {
|
|
145
165
|
meta: compiled.meta,
|
|
166
|
+
deps: compiled.deps,
|
|
167
|
+
collectionsUsed: compiled.collectionsUsed,
|
|
146
168
|
html: `<!doctype html>
|
|
147
|
-
<html lang="
|
|
169
|
+
<html lang="${escapeHtml(lang)}">
|
|
148
170
|
<head>
|
|
149
171
|
<meta charset="utf-8">
|
|
150
172
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
151
173
|
<title>${escapeHtml(title)}</title>${descriptionTag}
|
|
152
174
|
<link rel="icon" href="${favicon}">${feedLink}
|
|
175
|
+
${SKIP_LINK_STYLE}
|
|
153
176
|
${cssLinks}${transitions}${speculation}
|
|
154
177
|
</head>
|
|
155
178
|
<body>
|
|
156
|
-
|
|
179
|
+
<a class="wd-skip-link" href="#${escapeHtml(target)}">Skip to content</a>
|
|
180
|
+
${content}
|
|
157
181
|
${scripts}
|
|
158
182
|
</body>
|
|
159
183
|
</html>`,
|
|
160
184
|
assets: compiled.assets,
|
|
161
|
-
warnings: compiled.warnings
|
|
185
|
+
warnings: compiled.warnings,
|
|
186
|
+
pagination: compiled.pagination
|
|
162
187
|
};
|
|
163
188
|
}
|
|
164
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Guarantee the page body exposes a main-content landmark the skip link can
|
|
192
|
+
* target. A body that already has a `<main>` keeps it: an author-supplied id
|
|
193
|
+
* wins as the skip target; a `<main>` without one gets `id="main"` stamped on.
|
|
194
|
+
* A body with no `<main>` at all is wrapped whole. Pure compile-time string
|
|
195
|
+
* pass (like {@link enhanceImages}) — a static page stays static.
|
|
196
|
+
* @param {string} html Assembled page body HTML.
|
|
197
|
+
* @returns {{ content: string, target: string }}
|
|
198
|
+
*/
|
|
199
|
+
function ensureMainLandmark(html) {
|
|
200
|
+
const open = html.match(/<main\b[^>]*>/i);
|
|
201
|
+
if (!open) return { content: `<main id="main">\n${html}\n</main>`, target: "main" };
|
|
202
|
+
// The id attribute must start after whitespace (or a closing quote — sloppy
|
|
203
|
+
// but browser-parsed) so `data-id="…"` never masquerades as the real id.
|
|
204
|
+
const id = open[0].match(/[\s"']id\s*=\s*["']([^"']*)["']/i);
|
|
205
|
+
if (id) return { content: html, target: id[1] || "main" };
|
|
206
|
+
return { content: html.replace(open[0], `${open[0].slice(0, -1)} id="main">`), target: "main" };
|
|
207
|
+
}
|
|
208
|
+
|
|
165
209
|
/**
|
|
166
210
|
* Harden every `<img>` in the assembled page body — compile-time only, zero
|
|
167
211
|
* runtime JS. Bare markdown images reflow the page as they decode (cumulative
|
|
@@ -199,30 +243,6 @@ export function enhanceImages(html, paths) {
|
|
|
199
243
|
});
|
|
200
244
|
}
|
|
201
245
|
|
|
202
|
-
/**
|
|
203
|
-
* Stamp `data-wd-scope="<id>"` onto every opening tag of a subtree — the HTML
|
|
204
|
-
* half of compile-time scoped styles, mirroring {@link enhanceImages}: a regex
|
|
205
|
-
* post-pass over assembled HTML, zero runtime JS. Every element start tag that
|
|
206
|
-
* doesn't already carry a scope attribute gets one, so the scoped stylesheet
|
|
207
|
-
* (whose selectors end in `[data-wd-scope="<id>"]`) matches inside this subtree
|
|
208
|
-
* and nowhere else. Void/self-closing tags are stamped too (the attribute is
|
|
209
|
-
* valid on any element). Closing tags, comments, doctype, and the `<!`/`<?`
|
|
210
|
-
* families are left alone. A tag that ALREADY has a `data-wd-scope` (e.g. a
|
|
211
|
-
* nested include with its own scope) keeps its own — first stamp wins.
|
|
212
|
-
* @param {string} html Subtree HTML to scope.
|
|
213
|
-
* @param {string} scopeId Scope id (from {@link import("../skin.js").scopeIdFor}).
|
|
214
|
-
* @returns {string}
|
|
215
|
-
*/
|
|
216
|
-
export function stampScope(html, scopeId) {
|
|
217
|
-
return html.replace(
|
|
218
|
-
/<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g,
|
|
219
|
-
(tag, name, attrs, slash) => {
|
|
220
|
-
if (/\bdata-wd-scope\s*=/.test(attrs)) return tag;
|
|
221
|
-
return `<${name}${attrs} data-wd-scope="${scopeId}"${slash ? " /" : ""}>`;
|
|
222
|
-
}
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
246
|
/**
|
|
227
247
|
* Extract the `src` attribute value from an `<img>` tag, or "" if absent.
|
|
228
248
|
* @param {string} tag
|
|
@@ -266,12 +286,23 @@ function measureImage(src, paths) {
|
|
|
266
286
|
* @param {Paths} context Resolved project paths.
|
|
267
287
|
* @param {string[]} [stack] Include stack for cycle detection.
|
|
268
288
|
* @param {Record<string, unknown>} [vars] Initial static scope variables.
|
|
289
|
+
* @param {Map<string, import("./collections.js").CollectionRow[]>} [collections]
|
|
290
|
+
* Build-time collection index a bare-name `@loop` resolves against.
|
|
269
291
|
* @returns {CompiledDocument}
|
|
270
292
|
*/
|
|
271
|
-
export function compileDocument(file, context, stack = [], vars = {}) {
|
|
293
|
+
export function compileDocument(file, context, stack = [], vars = {}, collections = new Map()) {
|
|
272
294
|
const comp = createCompilation();
|
|
295
|
+
comp.collections = collections;
|
|
273
296
|
const result = compileFile(file, context, stack, createScope(null, vars), comp, [], null);
|
|
274
|
-
return {
|
|
297
|
+
return {
|
|
298
|
+
meta: result.meta,
|
|
299
|
+
html: result.html,
|
|
300
|
+
assets: comp.assets,
|
|
301
|
+
warnings: comp.warnings,
|
|
302
|
+
pagination: comp.pagination,
|
|
303
|
+
deps: comp.deps,
|
|
304
|
+
collectionsUsed: comp.collectionsUsed
|
|
305
|
+
};
|
|
275
306
|
}
|
|
276
307
|
|
|
277
308
|
/**
|
|
@@ -294,13 +325,16 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
|
|
|
294
325
|
}
|
|
295
326
|
|
|
296
327
|
const raw = fs.readFileSync(file, "utf8");
|
|
297
|
-
const { meta, body } = parseFrontmatter(raw, file);
|
|
328
|
+
const { meta, body, bodyLine } = parseFrontmatter(raw, file);
|
|
298
329
|
warnLikelyFrontmatter(raw, file, comp);
|
|
330
|
+
comp.deps.add(file);
|
|
299
331
|
collectColocatedAssets(file, context, comp.assets);
|
|
300
332
|
|
|
301
333
|
if (path.extname(file) === ".md") {
|
|
302
334
|
scanMarkdownHints(body, file, comp);
|
|
303
|
-
|
|
335
|
+
// Share the compilation's heading-slug counters (as renderProse does), so a
|
|
336
|
+
// `.md` included from a `.wd` page still dedupes anchors document-wide.
|
|
337
|
+
const html = selectMd(meta).render(body, { headingSlugs: comp.headingSlugs });
|
|
304
338
|
if (htmlHasHighlight(html)) comp.assets.hasCode = true;
|
|
305
339
|
return { meta, html };
|
|
306
340
|
}
|
|
@@ -310,13 +344,16 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
|
|
|
310
344
|
/** @type {Ctx} */
|
|
311
345
|
const ctx = {
|
|
312
346
|
file,
|
|
347
|
+
bodyLine,
|
|
313
348
|
context,
|
|
314
349
|
stack: [...stack, real],
|
|
315
350
|
scope: createScope(scope, { meta }),
|
|
316
351
|
comp,
|
|
317
352
|
sections,
|
|
318
353
|
loopItem,
|
|
319
|
-
md: selectMd(meta)
|
|
354
|
+
md: selectMd(meta),
|
|
355
|
+
compileBody,
|
|
356
|
+
compileFile
|
|
320
357
|
};
|
|
321
358
|
const html = compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx);
|
|
322
359
|
if (htmlHasHighlight(html)) comp.assets.hasCode = true;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// State declarations: the `:state` / `:store` / `:computed` / `:theme` handlers
|
|
3
|
+
// plus the shared declare* helpers (declareState/declareStore/declareErrorState)
|
|
4
|
+
// the `:fetch` and form handlers lean on to register their auto-declared keys.
|
|
5
|
+
//
|
|
6
|
+
// Every handler receives the 0-based line `index` so its malformed/invalid
|
|
7
|
+
// errors report `file:line` (via `at`) — matching the unclosed-block errors —
|
|
8
|
+
// while keeping the message text and `Use:` hints intact.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
import { at } from "./context.js";
|
|
12
|
+
import { FORMATTERS } from "./format.js";
|
|
13
|
+
import { escapeHtml, getPath, parseStateValue, safeScriptJson } from "./interpolation.js";
|
|
14
|
+
import { compileComputedExpr } from "./predicates.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {import("./context.js").Ctx} Ctx
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} line
|
|
22
|
+
* @param {Ctx} ctx
|
|
23
|
+
* @param {number} index 0-based line index for `file:line` errors.
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
export function handleState(line, ctx, index) {
|
|
27
|
+
const match = line.match(/^:state\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+persist)?$/);
|
|
28
|
+
if (!match) throw new Error(`Malformed :state in ${at(ctx, index)}: ${line}`);
|
|
29
|
+
const value = parseStateValue(match[2], at(ctx, index));
|
|
30
|
+
const key = declareState(match[1], value, ctx);
|
|
31
|
+
const persistAttr = match[3] ? ` data-wd-persist="${key}"` : "";
|
|
32
|
+
return `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Register a state key in the current section scope, enabling the runtime.
|
|
37
|
+
* @param {string} name
|
|
38
|
+
* @param {unknown} value
|
|
39
|
+
* @param {Ctx} ctx
|
|
40
|
+
* @returns {string} The fully-qualified state key.
|
|
41
|
+
*/
|
|
42
|
+
export function declareState(name, value, ctx) {
|
|
43
|
+
if (ctx.loopItem)
|
|
44
|
+
throw new Error(`State cannot be declared inside a reactive @loop body (${ctx.file})`);
|
|
45
|
+
if (ctx.comp.stores.has(name))
|
|
46
|
+
throw new Error(
|
|
47
|
+
`State "${name}" collides with a :store of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one.`
|
|
48
|
+
);
|
|
49
|
+
const key = ctx.sections.length ? `${ctx.sections.at(-1)}:${name}` : name;
|
|
50
|
+
if (ctx.comp.state.has(key))
|
|
51
|
+
throw new Error(`State "${name}" is declared twice in the same scope (${ctx.file})`);
|
|
52
|
+
ctx.comp.state.set(key, value);
|
|
53
|
+
ctx.comp.assets.runtime = true;
|
|
54
|
+
return key;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} line
|
|
59
|
+
* @param {Ctx} ctx
|
|
60
|
+
* @param {number} index 0-based line index for `file:line` errors.
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export function handleStore(line, ctx, index) {
|
|
64
|
+
const match = line.match(/^:store\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+ephemeral)?$/);
|
|
65
|
+
if (!match)
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Malformed :store in ${at(ctx, index)}: ${line}. Use: :store name = value [ephemeral]`
|
|
68
|
+
);
|
|
69
|
+
const value = parseStateValue(match[2], at(ctx, index));
|
|
70
|
+
const name = declareStore(match[1], value, ctx);
|
|
71
|
+
const ephemeral = match[3] ? " data-wd-store-ephemeral" : "";
|
|
72
|
+
return `<script type="application/json" data-wd-store="${name}"${ephemeral}>${safeScriptJson(value)}</script>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Register a page-global store. The bare name is added to `comp.state` so every
|
|
77
|
+
* resolver (interpolation, :if, @loop, :computed, actions) sees it, and tracked
|
|
78
|
+
* in `comp.stores` for collision checks. Never section-scoped.
|
|
79
|
+
* @param {string} name
|
|
80
|
+
* @param {unknown} value
|
|
81
|
+
* @param {Ctx} ctx
|
|
82
|
+
* @returns {string} The store name (also its bare state key).
|
|
83
|
+
*/
|
|
84
|
+
export function declareStore(name, value, ctx) {
|
|
85
|
+
if (ctx.comp.stores.has(name))
|
|
86
|
+
throw new Error(`Store "${name}" is declared twice in ${ctx.file}. Use: :store name = value`);
|
|
87
|
+
if (ctx.comp.state.has(name))
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Store "${name}" collides with a :state of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one.`
|
|
90
|
+
);
|
|
91
|
+
ctx.comp.stores.add(name);
|
|
92
|
+
ctx.comp.state.set(name, value);
|
|
93
|
+
ctx.comp.assets.runtime = true;
|
|
94
|
+
return name;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Seed a `<name>_error` state key (null) if absent. Shared by :fetch and the
|
|
99
|
+
* round-trip :form so error fallbacks have a key to bind.
|
|
100
|
+
* @param {string} key
|
|
101
|
+
* @param {Ctx} ctx
|
|
102
|
+
* @returns {void}
|
|
103
|
+
*/
|
|
104
|
+
export function declareErrorState(key, ctx) {
|
|
105
|
+
const errorKey = `${key}_error`;
|
|
106
|
+
if (!ctx.comp.state.has(errorKey)) ctx.comp.state.set(errorKey, null);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {string} line
|
|
111
|
+
* @param {Ctx} ctx
|
|
112
|
+
* @param {number} index 0-based line index for `file:line` errors.
|
|
113
|
+
* @returns {string}
|
|
114
|
+
*/
|
|
115
|
+
export function handleComputed(line, ctx, index) {
|
|
116
|
+
const match = line.match(/^:computed\s+([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
|
|
117
|
+
if (!match)
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Malformed :computed in ${at(ctx, index)}: ${line}. Use: :computed total = items.length * 4`
|
|
120
|
+
);
|
|
121
|
+
const expr = compileComputedExpr(match[2].trim(), ctx);
|
|
122
|
+
/** @type {unknown} */
|
|
123
|
+
let initial;
|
|
124
|
+
try {
|
|
125
|
+
/** @param {string} key @param {string} [path] */
|
|
126
|
+
const read = (key, path) => getPath(ctx.comp.state.get(key), path ? path.split(".") : []);
|
|
127
|
+
// Build-time mirror of the runtime's AGG helper, so a `:computed total = sum(...)`
|
|
128
|
+
// has the same initial value the runtime will recompute to.
|
|
129
|
+
const agg = (
|
|
130
|
+
/** @type {string} */ name,
|
|
131
|
+
/** @type {any} */ list,
|
|
132
|
+
/** @type {string} */ field
|
|
133
|
+
) => (FORMATTERS[name] ? FORMATTERS[name](list, field == null ? [] : [field]) : undefined);
|
|
134
|
+
initial = new Function("S", "A", `return (${expr});`)(read, agg);
|
|
135
|
+
} catch {
|
|
136
|
+
console.warn(
|
|
137
|
+
`:computed "${match[2].trim()}" in ${ctx.file} could not be evaluated at build time; falling back to null. Check the expression.`
|
|
138
|
+
);
|
|
139
|
+
initial = null;
|
|
140
|
+
}
|
|
141
|
+
const key = declareState(match[1], initial ?? null, ctx);
|
|
142
|
+
return `<span data-wd-computed data-wd-computed-key="${key}" data-wd-computed-expr="${escapeHtml(expr)}"></span><script type="application/json" data-wd-state>${safeScriptJson({ [key]: initial ?? null })}</script>`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `:theme [name] [= "auto"]` declares a durable `:store` (default name `theme`,
|
|
147
|
+
* seed `"auto"`) and reflects its value onto `<html data-theme>`. This layers a
|
|
148
|
+
* manual light/dark switch over the OS preference: `"auto"` follows the skin's
|
|
149
|
+
* `tokens dark` media query, while `"light"`/`"dark"` force the matching
|
|
150
|
+
* `:root[data-theme]` scope. Wire a switch with ordinary store actions —
|
|
151
|
+
* `:button "Dark" -> theme = "dark"`. Durable, so the choice survives reloads.
|
|
152
|
+
* @param {string} line
|
|
153
|
+
* @param {Ctx} ctx
|
|
154
|
+
* @param {number} index 0-based line index for `file:line` errors.
|
|
155
|
+
* @returns {string}
|
|
156
|
+
*/
|
|
157
|
+
export function handleTheme(line, ctx, index) {
|
|
158
|
+
const match = line.match(/^:theme(?:\s+([A-Za-z_$][\w$]*))?(?:\s*=\s*(.+))?$/);
|
|
159
|
+
if (!match) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`Malformed :theme in ${at(ctx, index)}: ${line}. Use: :theme (or :theme name = "auto")`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const name = match[1] || "theme";
|
|
165
|
+
const seed = match[2] != null ? parseStateValue(match[2], at(ctx, index)) : "auto";
|
|
166
|
+
const storeName = declareStore(name, seed, ctx);
|
|
167
|
+
return `<script type="application/json" data-wd-store="${storeName}">${safeScriptJson(seed)}</script><span data-wd-theme="${storeName}" hidden></span>`;
|
|
168
|
+
}
|