@zvndev/darkmown 0.19.0 → 1.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.
Files changed (69) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +115 -5
  4. package/docs/spec-alignment.md +16 -3
  5. package/package.json +22 -4
  6. package/site/_/base.skin +372 -0
  7. package/site/_/base.wd +4 -0
  8. package/site/_/footer.wd +14 -0
  9. package/site/_/metrics.json +13 -0
  10. package/site/_/nav.skin +35 -12
  11. package/site/_/nav.wd +8 -10
  12. package/site/_/products.json +10 -0
  13. package/site/pages/app.wd +1 -1
  14. package/site/pages/carousel/index.js +82 -0
  15. package/site/pages/carousel/index.skin +185 -0
  16. package/site/pages/carousel/index.wd +109 -0
  17. package/site/pages/carousel/slide-1.jpg +0 -0
  18. package/site/pages/carousel/slide-2.jpg +0 -0
  19. package/site/pages/carousel/slide-3.jpg +0 -0
  20. package/site/pages/carousel/slide-4.jpg +0 -0
  21. package/site/pages/carousel/slide-5.jpg +0 -0
  22. package/site/pages/compass/index.skin +247 -0
  23. package/site/pages/compass/index.wd +194 -0
  24. package/site/pages/docs/index.skin +48 -28
  25. package/site/pages/docs/index.wd +71 -5
  26. package/site/pages/folio/-style.skin +375 -0
  27. package/site/pages/folio/-style.wd +6 -0
  28. package/site/pages/folio/cart.skin +309 -0
  29. package/site/pages/folio/cart.wd +201 -0
  30. package/site/pages/folio/index.skin +24 -0
  31. package/site/pages/folio/index.wd +167 -0
  32. package/site/pages/forge/index.skin +676 -0
  33. package/site/pages/forge/index.wd +330 -0
  34. package/site/pages/index.skin +135 -144
  35. package/site/pages/index.wd +17 -5
  36. package/site/pages/ledger/index.skin +257 -0
  37. package/site/pages/ledger/index.wd +174 -0
  38. package/site/pages/media/clip-poster.jpg +0 -0
  39. package/site/pages/media/clip.mp4 +0 -0
  40. package/site/pages/media/index.skin +82 -0
  41. package/site/pages/media/index.wd +84 -0
  42. package/site/pages/media/tone.mp3 +0 -0
  43. package/site/pages/pulse/index.skin +434 -0
  44. package/site/pages/pulse/index.wd +178 -0
  45. package/site/pages/showcase/index.skin +130 -0
  46. package/site/pages/showcase/index.wd +168 -0
  47. package/src/builder.js +20 -14
  48. package/src/cli.js +216 -77
  49. package/src/compiler/body.js +457 -0
  50. package/src/compiler/context.js +160 -0
  51. package/src/compiler/directives.js +1345 -0
  52. package/src/compiler/format.js +244 -0
  53. package/src/compiler/frontmatter.js +117 -0
  54. package/src/compiler/includes.js +116 -0
  55. package/src/compiler/index.js +22 -0
  56. package/src/compiler/interpolation.js +223 -0
  57. package/src/compiler/loops.js +715 -0
  58. package/src/compiler/markdown.js +242 -0
  59. package/src/compiler/page.js +267 -0
  60. package/src/compiler/predicates.js +314 -0
  61. package/src/compiler.js +26 -2932
  62. package/src/dev.js +3 -1
  63. package/src/headers.js +8 -4
  64. package/src/index.js +22 -0
  65. package/src/router.js +3 -1
  66. package/src/runtime.js +168 -14
  67. package/src/scaffold.js +158 -131
  68. package/src/skin.js +11 -2
  69. package/site/_/og.png +0 -0
@@ -0,0 +1,242 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Markdown layer: the shared markdown-it instances (raw-HTML default + a lazy
3
+ // `html: false` strict variant), the `wd_binding` inline plugin and `wd_attrs`
4
+ // core plugin, and prose rendering with `{ name.path }` interpolation resolved
5
+ // against the loop item / static scope / declared state.
6
+ // ---------------------------------------------------------------------------
7
+
8
+ import MarkdownIt from "markdown-it";
9
+ import { LOOP_META } from "./context.js";
10
+ import { applyPipeline, fmtAttr, validatePipes } from "./format.js";
11
+ import {
12
+ escapeHtml,
13
+ getPath,
14
+ interpolateLeaf,
15
+ lookupVar,
16
+ resolveStateKey
17
+ } from "./interpolation.js";
18
+
19
+ /**
20
+ * @typedef {import("./context.js").Meta} Meta
21
+ * @typedef {import("./context.js").Ctx} Ctx
22
+ */
23
+
24
+ const md = new MarkdownIt({ html: true });
25
+ md.use(bindingPlugin);
26
+ md.use(attrsPlugin);
27
+
28
+ // Raw HTML in markdown passes through by default (a documented design choice).
29
+ // Pages can opt out with frontmatter `html: false` for untrusted content; the
30
+ // stricter instance is built lazily so the default path stays a single instance.
31
+ /** @type {MarkdownIt | null} */
32
+ let mdNoHtml = null;
33
+ /**
34
+ * Pick the markdown-it instance for a page (raw HTML on by default, off with `html: false`).
35
+ * @param {Meta} [meta]
36
+ * @returns {MarkdownIt}
37
+ */
38
+ export function selectMd(meta) {
39
+ // Frontmatter scalars stay strings (no coercion), so accept both forms.
40
+ if (meta?.html !== false && meta?.html !== "false") return md;
41
+ if (!mdNoHtml) {
42
+ mdNoHtml = new MarkdownIt({ html: false });
43
+ mdNoHtml.use(bindingPlugin);
44
+ mdNoHtml.use(attrsPlugin);
45
+ }
46
+ return mdNoHtml;
47
+ }
48
+
49
+ /**
50
+ * @param {string} text
51
+ * @param {Ctx} ctx
52
+ * @returns {string}
53
+ */
54
+ export function renderProse(text, ctx) {
55
+ text = resolveDestinationBindings(text, ctx);
56
+ return (ctx.md ?? md).render(text, {
57
+ resolveBinding: (/** @type {string} */ expr) => resolveBindingHtml(expr, ctx)
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Pre-resolve `{ expr }` interpolations sitting in a markdown link/image
63
+ * destination — the `](…)` slot — so a build-time `@loop` (or any static scope)
64
+ * can drive an href/src. A markdown destination cannot contain the spaces inside
65
+ * `{ … }`, so markdown-it would otherwise never form the link; substituting the
66
+ * static value here lets the normal link/image parser run. Only build-time
67
+ * (static-scope) values are substituted — reactive `:state`/reactive-loop
68
+ * bindings resolve to `null` and are left untouched (they cannot live in a
69
+ * destination). Issue #19.
70
+ * @param {string} text
71
+ * @param {Ctx} ctx
72
+ * @returns {string}
73
+ */
74
+ function resolveDestinationBindings(text, ctx) {
75
+ return text.replace(
76
+ /(\]\(\s*)\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/g,
77
+ (whole, prefix, expr) => {
78
+ const raw = resolveBindingRaw(expr, ctx);
79
+ return raw == null ? whole : prefix + raw;
80
+ }
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Resolve `{ expr }` to its raw static value (a plain string) for use in a
86
+ * destination, or `null` when the binding is reactive or not in build-time
87
+ * scope. Mirrors the static branch of {@link resolveBindingHtml} but returns the
88
+ * unescaped value (markdown-it normalizes the URL).
89
+ * @param {string} expr
90
+ * @param {Ctx} ctx
91
+ * @returns {string | null}
92
+ */
93
+ function resolveBindingRaw(expr, ctx) {
94
+ const segs = expr.split(".");
95
+ const found = lookupVar(ctx.scope, segs[0]);
96
+ if (!found.found) return null;
97
+ const resolved = getPath(found.value, segs.slice(1));
98
+ const leaf = interpolateLeaf(resolved, expr, ctx);
99
+ return leaf == null ? null : String(leaf);
100
+ }
101
+
102
+ /**
103
+ * markdown-it plugin: turns `{ name.path }` into an inline `html_inline` token
104
+ * whose content comes from the env's `resolveBinding` callback.
105
+ * @param {MarkdownIt} mdInstance
106
+ * @returns {void}
107
+ */
108
+ function bindingPlugin(mdInstance) {
109
+ mdInstance.inline.ruler.push("wd_binding", (state, silent) => {
110
+ if (state.src.charCodeAt(state.pos) !== 0x7b /* { */) return false;
111
+ // Capture the path plus an optional `| formatter:arg | …` pipe chain. Pipe
112
+ // arguments may not contain `}` (they may be quoted), so the chain runs to
113
+ // the first closing brace.
114
+ const match = /^\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*(?:\|[^}]*)?)\}/.exec(
115
+ state.src.slice(state.pos)
116
+ );
117
+ if (!match) return false;
118
+ const resolve = state.env?.resolveBinding;
119
+ const html = resolve ? resolve(match[1].trim()) : null;
120
+ if (typeof html !== "string") return false;
121
+ if (!silent) {
122
+ const token = state.push("html_inline", "", 0);
123
+ token.content = html;
124
+ }
125
+ state.pos += match[0].length;
126
+ return true;
127
+ });
128
+ }
129
+
130
+ /**
131
+ * markdown-it plugin (core rule): a `{.class .class #id}` block immediately
132
+ * following an inline element (link, image, em/strong, code) attaches those
133
+ * classes / id to that element. Lets a link be styled as a button without a
134
+ * wrapper container. The block must directly follow the element (no space), and
135
+ * `{ … }` interpolation is unaffected (its content starts with a name, not `.`/`#`).
136
+ * Issue #18.
137
+ * @param {MarkdownIt} mdInstance
138
+ * @returns {void}
139
+ */
140
+ function attrsPlugin(mdInstance) {
141
+ mdInstance.core.ruler.push("wd_attrs", (state) => {
142
+ for (const block of state.tokens) {
143
+ if (block.type !== "inline" || !block.children) continue;
144
+ const children = block.children;
145
+ for (let i = 0; i < children.length; i++) {
146
+ const tok = children[i];
147
+ if (tok.type !== "text") continue;
148
+ const m = /^\{([.#][^}]*)\}/.exec(tok.content);
149
+ if (!m) continue;
150
+ const target = attrTarget(children, i);
151
+ if (!target) continue;
152
+ for (const part of m[1].split(/\s+/).filter(Boolean)) {
153
+ if (part[0] === ".") target.attrJoin("class", part.slice(1));
154
+ else if (part[0] === "#") target.attrSet("id", part.slice(1));
155
+ }
156
+ tok.content = tok.content.slice(m[0].length);
157
+ }
158
+ }
159
+ return false;
160
+ });
161
+ }
162
+
163
+ /**
164
+ * The inline token an attr block attaches to: the immediately-preceding image
165
+ * (self-closing) or the open token matching the immediately-preceding close
166
+ * (link/em/strong/…). Returns null when nothing valid precedes. Exported so its
167
+ * contract — including the defensive unbalanced-close fallback that markdown-it's
168
+ * always-balanced token stream never triggers in practice — is unit-testable.
169
+ * @param {any[]} children markdown-it inline child tokens
170
+ * @param {number} i Index of the attr-block text token.
171
+ * @returns {any} the matching markdown-it Token, or null
172
+ */
173
+ export function attrTarget(children, i) {
174
+ const prev = children[i - 1];
175
+ if (!prev) return null;
176
+ if (prev.type === "image") return prev;
177
+ // Not a close token (e.g. text, softbreak, or an open) → nothing to attach to.
178
+ if (prev.nesting !== -1) return null;
179
+ // Walk back to the matching open, counting nested same-type close/open pairs.
180
+ const openType = prev.type.replace(/_close$/, "_open");
181
+ let depth = 0;
182
+ for (let j = i - 1; j >= 0; j--) {
183
+ const t = children[j];
184
+ if (t.type === prev.type) depth++;
185
+ else if (t.type === openType) {
186
+ depth--;
187
+ if (depth === 0) return t;
188
+ }
189
+ }
190
+ // Unbalanced close with no matching open — markdown-it never emits this, but
191
+ // the guard keeps attrTarget total: no element is attached.
192
+ return null;
193
+ }
194
+
195
+ /**
196
+ * Resolve a `{ name.path }` binding to HTML: loop item span, static value, or
197
+ * a reactive bind span. Returns null when nothing in scope matches.
198
+ * @param {string} expr
199
+ * @param {Ctx} ctx
200
+ * @returns {string | null}
201
+ */
202
+ function resolveBindingHtml(expr, ctx) {
203
+ const { path, stages } = validatePipes(expr, ctx);
204
+ const segs = path.split(".");
205
+ const head = segs[0];
206
+ // Reactive nodes carry the pipe chain in data-wd-fmt; static values fold now.
207
+ const fmt = stages.length ? ` data-wd-fmt="${escapeHtml(fmtAttr(stages))}"` : "";
208
+ /** @param {unknown} v */
209
+ const fold = (v) => (stages.length ? applyPipeline(v, stages) : v);
210
+
211
+ // Per-row meta vars ($index/$number/$first/$last/$count) — only inside a loop.
212
+ if (LOOP_META[head]) {
213
+ if (!ctx.loopMeta)
214
+ throw new Error(
215
+ `"{ ${expr} }" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Move it into a loop body, or rename your value.`
216
+ );
217
+ if (ctx.loopItem) return `<span data-wd-each-meta="${LOOP_META[head]}"${fmt}></span>`; // reactive: filled per row
218
+ // static: the value is in scope (injected by staticUnroll); fall through.
219
+ }
220
+
221
+ if (ctx.loopItem && head === ctx.loopItem) {
222
+ const rest = segs.slice(1).join(".");
223
+ return `<span data-wd-each${rest ? ` data-wd-path="${escapeHtml(rest)}"` : ""}${fmt}></span>`;
224
+ }
225
+
226
+ const staticValue = lookupVar(ctx.scope, head);
227
+ if (staticValue.found) {
228
+ const resolved = getPath(staticValue.value, segs.slice(1));
229
+ return escapeHtml(interpolateLeaf(fold(resolved), expr, ctx));
230
+ }
231
+
232
+ const key = resolveStateKey(head, ctx);
233
+ if (key) {
234
+ ctx.comp.assets.runtime = true;
235
+ const initial = getPath(ctx.comp.state.get(key), segs.slice(1));
236
+ const rest = segs.slice(1).join(".");
237
+ const pathAttr = rest ? ` data-wd-path="${escapeHtml(rest)}"` : "";
238
+ return `<span data-wd-bind="${key}"${pathAttr}${fmt}>${escapeHtml(interpolateLeaf(fold(initial), expr, ctx))}</span>`;
239
+ }
240
+
241
+ return null;
242
+ }
@@ -0,0 +1,267 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Page shell + document assembly: wrap a compiled body in the full HTML shell
3
+ // (title, social meta, favicon, skins, scripts, optional view transitions +
4
+ // speculationrules), harden every `<img>` at compile time, and drive the
5
+ // per-file compile (frontmatter + colocated assets + body / `.md` passthrough).
6
+ // ---------------------------------------------------------------------------
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { imageSize } from "image-size";
11
+ import { compileBody } from "./body.js";
12
+ import { createCompilation, createScope } from "./context.js";
13
+ import { parseFrontmatter, warnLikelyFrontmatter } from "./frontmatter.js";
14
+ import { collectColocatedAssets, scanMarkdownHints } from "./includes.js";
15
+ import { escapeHtml } from "./interpolation.js";
16
+ import { selectMd } from "./markdown.js";
17
+
18
+ /**
19
+ * @typedef {import("./context.js").Paths} Paths
20
+ * @typedef {import("./context.js").Meta} Meta
21
+ * @typedef {import("./context.js").Scope} Scope
22
+ * @typedef {import("./context.js").Compilation} Compilation
23
+ * @typedef {import("./context.js").Ctx} Ctx
24
+ * @typedef {import("./context.js").CompiledDocument} CompiledDocument
25
+ * @typedef {import("./context.js").CompiledPage} CompiledPage
26
+ */
27
+
28
+ /**
29
+ * Compile a page source file into a full HTML document plus its assets.
30
+ * @param {string} file Absolute path to the source `.md`/`.wd` file.
31
+ * @param {Paths} context Resolved project paths.
32
+ * @returns {CompiledPage}
33
+ */
34
+ export function compilePage(file, context) {
35
+ const compiled = compileDocument(file, context);
36
+ const title = compiled.meta.title || "Darkmown";
37
+ const description = compiled.meta.description || "";
38
+ // Optional `image:` frontmatter sets the social-share preview (absolute URL).
39
+ const image = typeof compiled.meta.image === "string" ? compiled.meta.image : "";
40
+ /** @type {string[]} */
41
+ const social = [];
42
+ if (description) social.push(`<meta name="description" content="${escapeHtml(description)}">`);
43
+ if (description || image) {
44
+ social.push(`<meta property="og:title" content="${escapeHtml(title)}">`);
45
+ if (description)
46
+ social.push(`<meta property="og:description" content="${escapeHtml(description)}">`);
47
+ social.push(`<meta property="og:type" content="website">`);
48
+ }
49
+ if (image) {
50
+ social.push(`<meta property="og:image" content="${escapeHtml(image)}">`);
51
+ social.push(`<meta name="twitter:image" content="${escapeHtml(image)}">`);
52
+ }
53
+ if (description || image) {
54
+ social.push(
55
+ `<meta name="twitter:card" content="${image ? "summary_large_image" : "summary"}">`
56
+ );
57
+ }
58
+ const descriptionTag = social.length ? `\n ${social.join("\n ")}` : "";
59
+ const favicon =
60
+ "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E%3Crect%20width='32'%20height='32'%20rx='6'%20fill='%2318221d'/%3E%3Ctext%20x='16'%20y='23'%20text-anchor='middle'%20font-family='Georgia,serif'%20font-size='19'%20font-weight='bold'%20fill='%23f7f3ea'%3ED%3C/text%3E%3C/svg%3E";
61
+ const cssLinks = [...compiled.assets.skins]
62
+ .map((href) => `<link rel="stylesheet" href="${href}">`)
63
+ .join("\n");
64
+ const scriptSrcs = compiled.assets.runtime
65
+ ? ["/__wd/runtime.js", ...compiled.assets.scripts]
66
+ : [...compiled.assets.scripts];
67
+ const scripts = scriptSrcs
68
+ .map((src) => `<script type="module" src="${src}"></script>`)
69
+ .join("\n");
70
+ // Cross-document view transitions: opt in per page with `transitions: true` in
71
+ // frontmatter to emit a CSS-only stylesheet — a smooth same-origin transition
72
+ // on navigation, with zero JavaScript. Ignored by browsers without support
73
+ // (graceful, transition-free fallback) and only applies to same-origin
74
+ // navigations where both the outgoing and incoming page opt in.
75
+ //
76
+ // The default UA animation cross-fades the root: outgoing opacity 1→0 while
77
+ // incoming 0→1, both at once. Mid-navigation that leaves the two pages
78
+ // superimposed at ~50% opacity — headings ghost over headings. We override it
79
+ // with a directional fade+slide (old lifts up and out, new rises up and in) so
80
+ // the pages move past each other instead of stacking. Short + eased = peppy.
81
+ const wantTransitions =
82
+ compiled.meta.transitions === true || compiled.meta.transitions === "true";
83
+ const transitions = wantTransitions
84
+ ? `\n <style>
85
+ @view-transition { navigation: auto; }
86
+ ::view-transition-old(root) { animation: wd-nav-out 200ms cubic-bezier(0.4, 0, 1, 1) both; }
87
+ ::view-transition-new(root) { animation: wd-nav-in 200ms cubic-bezier(0, 0, 0.2, 1) both; }
88
+ @keyframes wd-nav-out { to { opacity: 0; transform: translateY(-1rem); } }
89
+ @keyframes wd-nav-in { from { opacity: 0; transform: translateY(1rem); } }
90
+ @media (prefers-reduced-motion: reduce) {
91
+ ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 1ms; }
92
+ @keyframes wd-nav-out { to { opacity: 0; } }
93
+ @keyframes wd-nav-in { from { opacity: 0; } }
94
+ }
95
+ </style>`
96
+ : "";
97
+
98
+ // The latency half of smooth navigation — and what kills the white flash. A
99
+ // declarative speculationrules script (the browser interprets it — not
100
+ // framework runtime JS, so the zero-JS invariant holds) *prerenders* the next
101
+ // same-origin page on hover/pointerdown. Prefetch only warms the cache, so the
102
+ // page still has to render on click — that render gap is the white flash. A
103
+ // prerender renders the whole page in a hidden tab ahead of the click, so
104
+ // activation is instant: no render gap, and the view transition fires on the
105
+ // already-painted page. Safe now that pages are light. Eagerness `moderate` =
106
+ // ~200ms hover / pointerdown, capped at two. Mark a link `{.no-prefetch}` to
107
+ // opt it out; `rel=nofollow` links are never speculated. Browsers without
108
+ // support (or with preloading disabled) ignore the tag and navigate normally.
109
+ const speculation = wantTransitions
110
+ ? `\n <script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"selector_matches":".no-prefetch"}},{"not":{"selector_matches":"[rel~=nofollow]"}}]},"eagerness":"moderate"}]}</script>`
111
+ : "";
112
+
113
+ const body = enhanceImages(compiled.html, context);
114
+
115
+ return {
116
+ meta: compiled.meta,
117
+ html: `<!doctype html>
118
+ <html lang="en">
119
+ <head>
120
+ <meta charset="utf-8">
121
+ <meta name="viewport" content="width=device-width, initial-scale=1">
122
+ <title>${escapeHtml(title)}</title>${descriptionTag}
123
+ <link rel="icon" href="${favicon}">
124
+ ${cssLinks}${transitions}${speculation}
125
+ </head>
126
+ <body>
127
+ ${body}
128
+ ${scripts}
129
+ </body>
130
+ </html>`,
131
+ assets: compiled.assets,
132
+ warnings: compiled.warnings
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Harden every `<img>` in the assembled page body — compile-time only, zero
138
+ * runtime JS. Bare markdown images reflow the page as they decode (cumulative
139
+ * layout shift — the literal "jump" on navigation) and load eagerly. This
140
+ * stamps intrinsic `width`/`height` (read from the file on disk so the browser
141
+ * reserves space), `decoding="async"` on all, and a load-priority split: the
142
+ * first image is the LCP candidate (eager + `fetchpriority="high"`), the rest
143
+ * lazy-load. Author-set attributes are never overwritten.
144
+ * @param {string} html Assembled page body HTML.
145
+ * @param {Paths} paths Resolved project paths.
146
+ * @returns {string}
147
+ */
148
+ export function enhanceImages(html, paths) {
149
+ let index = 0;
150
+ return html.replace(/<img\b[^>]*?\/?>/g, (tag) => {
151
+ const i = index++;
152
+ const has = (/** @type {string} */ attr) => new RegExp(`\\b${attr}\\s*=`, "i").test(tag);
153
+ /** @type {string[]} */
154
+ const add = [];
155
+ // Dimensions only when the author hasn't sized it — adding one axis to a
156
+ // manually-sized image would distort its aspect ratio.
157
+ if (!has("width") && !has("height")) {
158
+ const dim = measureImage(srcOf(tag), paths);
159
+ if (dim) add.push(`width="${dim.width}"`, `height="${dim.height}"`);
160
+ }
161
+ if (!has("decoding")) add.push(`decoding="async"`);
162
+ if (i === 0) {
163
+ // First image is the LCP candidate: stays eager, gets a priority hint.
164
+ if (!has("fetchpriority")) add.push(`fetchpriority="high"`);
165
+ } else if (!has("loading")) {
166
+ add.push(`loading="lazy"`);
167
+ }
168
+ if (!add.length) return tag;
169
+ return `${tag.replace(/\s*\/?>\s*$/, "")} ${add.join(" ")}>`;
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Extract the `src` attribute value from an `<img>` tag, or "" if absent.
175
+ * @param {string} tag
176
+ * @returns {string}
177
+ */
178
+ function srcOf(tag) {
179
+ const m = tag.match(/\bsrc\s*=\s*["']([^"']*)["']/i);
180
+ return m ? m[1] : "";
181
+ }
182
+
183
+ /**
184
+ * Read an image's intrinsic dimensions from disk, or null when the src is
185
+ * remote/relative/unreadable. Resolution mirrors the asset emit: `/__wd/media/x`
186
+ * comes from the shelf (`site/_`), other absolute paths from `site/pages`.
187
+ * @param {string} src
188
+ * @param {Paths} paths
189
+ * @returns {{ width: number, height: number } | null}
190
+ */
191
+ function measureImage(src, paths) {
192
+ if (!src || /^(https?:)?\/\//i.test(src) || src.startsWith("data:")) return null;
193
+ let filePath;
194
+ if (src.startsWith("/__wd/media/")) {
195
+ filePath = path.join(paths.shelfRoot, src.slice("/__wd/media/".length));
196
+ } else if (src.startsWith("/")) {
197
+ filePath = path.join(paths.routesRoot, src.slice(1));
198
+ } else {
199
+ return null; // page-relative: the source directory is lost after assembly
200
+ }
201
+ try {
202
+ const { width, height } = imageSize(fs.readFileSync(filePath));
203
+ if (typeof width === "number" && typeof height === "number") return { width, height };
204
+ } catch {
205
+ /* missing or unreadable — degrade to no dimensions */
206
+ }
207
+ return null;
208
+ }
209
+
210
+ /**
211
+ * Compile a source file into its body HTML, frontmatter, and assets (no page shell).
212
+ * @param {string} file Absolute path to the source file.
213
+ * @param {Paths} context Resolved project paths.
214
+ * @param {string[]} [stack] Include stack for cycle detection.
215
+ * @param {Record<string, unknown>} [vars] Initial static scope variables.
216
+ * @returns {CompiledDocument}
217
+ */
218
+ export function compileDocument(file, context, stack = [], vars = {}) {
219
+ const comp = createCompilation();
220
+ const result = compileFile(file, context, stack, createScope(null, vars), comp, [], null);
221
+ return { meta: result.meta, html: result.html, assets: comp.assets, warnings: comp.warnings };
222
+ }
223
+
224
+ /**
225
+ * Compile a single file: parse frontmatter, collect assets, render body.
226
+ * @param {string} file
227
+ * @param {Paths} context
228
+ * @param {string[]} stack
229
+ * @param {Scope} scope
230
+ * @param {Compilation} comp
231
+ * @param {string[]} sections
232
+ * @param {string | null} loopItem
233
+ * @returns {{ meta: Meta, html: string }}
234
+ */
235
+ export function compileFile(file, context, stack, scope, comp, sections, loopItem) {
236
+ const real = fs.realpathSync(file);
237
+ if (stack.includes(real)) {
238
+ throw new Error(
239
+ `Include cycle detected: ${[...stack, real].map((p) => path.basename(p)).join(" -> ")}`
240
+ );
241
+ }
242
+
243
+ const raw = fs.readFileSync(file, "utf8");
244
+ const { meta, body } = parseFrontmatter(raw, file);
245
+ warnLikelyFrontmatter(raw, file, comp);
246
+ collectColocatedAssets(file, context, comp.assets);
247
+
248
+ if (path.extname(file) === ".md") {
249
+ scanMarkdownHints(body, file, comp);
250
+ return { meta, html: selectMd(meta).render(body, {}) };
251
+ }
252
+
253
+ // Expose this file's frontmatter to the body under `meta` so `{ meta.title }`,
254
+ // `{ meta.tags }`, and `@loop meta.tags into tag` resolve as static values.
255
+ /** @type {Ctx} */
256
+ const ctx = {
257
+ file,
258
+ context,
259
+ stack: [...stack, real],
260
+ scope: createScope(scope, { meta }),
261
+ comp,
262
+ sections,
263
+ loopItem,
264
+ md: selectMd(meta)
265
+ };
266
+ return { meta, html: compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx) };
267
+ }