@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +25 -0
- package/README.md +115 -5
- package/docs/spec-alignment.md +16 -3
- package/package.json +22 -4
- package/site/_/base.skin +372 -0
- package/site/_/base.wd +4 -0
- package/site/_/footer.wd +14 -0
- package/site/_/metrics.json +13 -0
- package/site/_/nav.skin +35 -12
- package/site/_/nav.wd +8 -10
- package/site/_/products.json +10 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/carousel/index.js +82 -0
- package/site/pages/carousel/index.skin +185 -0
- package/site/pages/carousel/index.wd +109 -0
- package/site/pages/carousel/slide-1.jpg +0 -0
- package/site/pages/carousel/slide-2.jpg +0 -0
- package/site/pages/carousel/slide-3.jpg +0 -0
- package/site/pages/carousel/slide-4.jpg +0 -0
- package/site/pages/carousel/slide-5.jpg +0 -0
- package/site/pages/compass/index.skin +247 -0
- package/site/pages/compass/index.wd +194 -0
- package/site/pages/docs/index.skin +48 -28
- package/site/pages/docs/index.wd +71 -5
- package/site/pages/folio/-style.skin +375 -0
- package/site/pages/folio/-style.wd +6 -0
- package/site/pages/folio/cart.skin +309 -0
- package/site/pages/folio/cart.wd +201 -0
- package/site/pages/folio/index.skin +24 -0
- package/site/pages/folio/index.wd +167 -0
- package/site/pages/forge/index.skin +676 -0
- package/site/pages/forge/index.wd +330 -0
- package/site/pages/index.skin +135 -144
- package/site/pages/index.wd +17 -5
- package/site/pages/ledger/index.skin +257 -0
- package/site/pages/ledger/index.wd +174 -0
- package/site/pages/media/clip-poster.jpg +0 -0
- package/site/pages/media/clip.mp4 +0 -0
- package/site/pages/media/index.skin +82 -0
- package/site/pages/media/index.wd +84 -0
- package/site/pages/media/tone.mp3 +0 -0
- package/site/pages/pulse/index.skin +434 -0
- package/site/pages/pulse/index.wd +178 -0
- package/site/pages/showcase/index.skin +130 -0
- package/site/pages/showcase/index.wd +168 -0
- package/src/builder.js +20 -14
- package/src/cli.js +216 -77
- package/src/compiler/body.js +457 -0
- package/src/compiler/context.js +160 -0
- package/src/compiler/directives.js +1345 -0
- package/src/compiler/format.js +244 -0
- package/src/compiler/frontmatter.js +117 -0
- package/src/compiler/includes.js +116 -0
- package/src/compiler/index.js +22 -0
- package/src/compiler/interpolation.js +223 -0
- package/src/compiler/loops.js +715 -0
- package/src/compiler/markdown.js +242 -0
- package/src/compiler/page.js +267 -0
- package/src/compiler/predicates.js +314 -0
- package/src/compiler.js +26 -2932
- package/src/dev.js +3 -1
- package/src/headers.js +8 -4
- package/src/index.js +22 -0
- package/src/router.js +3 -1
- package/src/runtime.js +168 -14
- package/src/scaffold.js +158 -131
- package/src/skin.js +11 -2
- 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
|
+
}
|