@zvndev/darkmown 1.5.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 +28 -0
- package/README.md +44 -16
- package/SECURITY.md +21 -18
- package/docs/cli.md +5 -1
- package/docs/spec-alignment.md +19 -6
- package/package.json +1 -1
- package/site/_/base.skin +8 -0
- package/site/_/base.wd +4 -0
- package/site/_/feature-card.wd +4 -0
- package/site/_/footer.wd +4 -0
- package/site/_/nav.wd +4 -0
- 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.wd +1 -0
- package/site/pages/blog/collections-and-pagination.skin +127 -0
- package/site/pages/blog/drafts-that-never-leak.skin +127 -0
- package/site/pages/blog/index.wd +1 -0
- package/site/pages/blog/shipping-feeds.skin +127 -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.skin +127 -0
- package/site/pages/carousel/index.wd +1 -0
- package/site/pages/compass/index.wd +1 -0
- 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 +70 -13
- 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.wd +1 -0
- package/site/pages/media/index.wd +1 -0
- package/site/pages/pulse/index.wd +1 -0
- 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.wd +1 -0
- package/site/pages/theme.wd +1 -0
- package/src/builder.js +444 -65
- package/src/cli.js +96 -18
- package/src/compiler/actions.js +303 -0
- package/src/compiler/body.js +36 -24
- package/src/compiler/context.js +56 -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 +53 -21
- package/src/compiler/markdown.js +65 -15
- package/src/compiler/media.js +133 -0
- package/src/compiler/page.js +59 -75
- 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 +3 -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.wd +1 -0
- package/src/templates/landing/site/_/nav.wd +4 -0
- 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.wd +1 -0
- package/src/templates/store/site/_/nav.wd +4 -0
- package/src/templates/store/site/pages/index.wd +1 -0
- package/src/templates/blog/site/_/posts.json +0 -16
|
@@ -12,13 +12,15 @@ import { stripQuotes } from "./interpolation.js";
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Split a raw file into its frontmatter `meta` and `body`.
|
|
15
|
+
* Split a raw file into its frontmatter `meta` and `body`. `bodyLine` is the
|
|
16
|
+
* 0-based file line index the body starts on (the number of lines the
|
|
17
|
+
* frontmatter block consumed) so compile errors can report true file lines.
|
|
16
18
|
* @param {string} raw Full file contents.
|
|
17
19
|
* @param {string} [file] Source path, used only for error messages.
|
|
18
|
-
* @returns {{ meta: Meta, body: string }}
|
|
20
|
+
* @returns {{ meta: Meta, body: string, bodyLine: number }}
|
|
19
21
|
*/
|
|
20
22
|
export function parseFrontmatter(raw, file) {
|
|
21
|
-
if (!raw.startsWith("---\n")) return { meta: {}, body: raw };
|
|
23
|
+
if (!raw.startsWith("---\n")) return { meta: {}, body: raw, bodyLine: 0 };
|
|
22
24
|
const end = raw.indexOf("\n---", 3);
|
|
23
25
|
if (end === -1) {
|
|
24
26
|
const where = file ? ` in ${file}` : "";
|
|
@@ -27,14 +29,17 @@ export function parseFrontmatter(raw, file) {
|
|
|
27
29
|
);
|
|
28
30
|
}
|
|
29
31
|
const front = raw.slice(4, end).trim();
|
|
30
|
-
|
|
32
|
+
let bodyStart = end + 4;
|
|
33
|
+
if (raw[bodyStart] === "\n") bodyStart++;
|
|
34
|
+
const body = raw.slice(bodyStart);
|
|
35
|
+
const bodyLine = raw.slice(0, bodyStart).split("\n").length - 1;
|
|
31
36
|
/** @type {Meta} */
|
|
32
37
|
const meta = {};
|
|
33
38
|
for (const line of front.split("\n")) {
|
|
34
39
|
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
35
40
|
if (match) meta[match[1]] = parseFrontmatterValue(match[2]);
|
|
36
41
|
}
|
|
37
|
-
return { meta, body };
|
|
42
|
+
return { meta, body, bodyLine };
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
/**
|
package/src/compiler/includes.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
2
|
// Includes / assets: resolve `@include` (and `@loop` data) targets to absolute
|
|
3
3
|
// paths inside `site/pages` or `site/_`, register colocated `.skin`/`.js`
|
|
4
|
-
// siblings,
|
|
4
|
+
// siblings, detect scoped skins and stamp their subtree HTML (`stampScope`),
|
|
5
|
+
// and warn when a plain `.md` file uses `.wd`-only syntax.
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
|
|
7
8
|
import fs from "node:fs";
|
|
@@ -57,6 +58,70 @@ export function scopedSkinFor(file, context) {
|
|
|
57
58
|
return scopeInfoForSkin(candidate, context.cwd);
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Raw-text (`script`, `style`) and escapable-raw-text (`textarea`, `title`)
|
|
63
|
+
* elements per the HTML spec: their CONTENT is not markup, so `<`/`>` inside them
|
|
64
|
+
* are plain characters and must never be treated as tags. The content runs to the
|
|
65
|
+
* first matching close tag.
|
|
66
|
+
* @type {Set<string>}
|
|
67
|
+
*/
|
|
68
|
+
const RAW_TEXT_ELEMENTS = new Set(["script", "style", "textarea", "title"]);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Stamp `data-wd-scope="<id>"` onto every opening tag of a subtree — the HTML
|
|
72
|
+
* half of compile-time scoped styles, mirroring {@link enhanceImages}: a
|
|
73
|
+
* compile-time post-pass over assembled HTML, zero runtime JS. Every element
|
|
74
|
+
* start tag that doesn't already carry a scope attribute gets one, so the scoped
|
|
75
|
+
* stylesheet (whose selectors end in `[data-wd-scope="<id>"]`) matches inside
|
|
76
|
+
* this subtree and nowhere else. Void/self-closing tags are stamped too (the
|
|
77
|
+
* attribute is valid on any element). Closing tags, comments, doctype, and the
|
|
78
|
+
* `<!`/`<?` families are left alone. A tag that ALREADY has a `data-wd-scope`
|
|
79
|
+
* (e.g. a nested include with its own scope) keeps its own — first stamp wins.
|
|
80
|
+
*
|
|
81
|
+
* Raw-text / escapable-raw-text elements (`script`, `style`, `textarea`,
|
|
82
|
+
* `title`) are special-cased: their OPENING tag is stamped (valid), but their
|
|
83
|
+
* BODY is left byte-intact — `<`/`>` inside a `<script>` are JavaScript, not
|
|
84
|
+
* markup, so stamping there would corrupt the content. A small tokenizer walks
|
|
85
|
+
* tag-by-tag (quote-aware in attribute lists) and, on entering a raw-text
|
|
86
|
+
* element, copies through verbatim until the matching close tag.
|
|
87
|
+
* @param {string} html Subtree HTML to scope.
|
|
88
|
+
* @param {string} scopeId Scope id (from {@link import("../skin.js").scopeIdFor}).
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
export function stampScope(html, scopeId) {
|
|
92
|
+
// Opening tag: name + (quote-aware) attribute list + optional self-close slash.
|
|
93
|
+
const openTag = /<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
|
|
94
|
+
let out = "";
|
|
95
|
+
let cursor = 0;
|
|
96
|
+
for (let m = openTag.exec(html); m; m = openTag.exec(html)) {
|
|
97
|
+
const [tag, name, attrs, slash] = m;
|
|
98
|
+
out += html.slice(cursor, m.index); // text/markup before this opening tag
|
|
99
|
+
const stamped = /\bdata-wd-scope\s*=/.test(attrs)
|
|
100
|
+
? tag // first stamp wins — a pre-stamped tag keeps its own scope
|
|
101
|
+
: `<${name}${attrs} data-wd-scope="${scopeId}"${slash ? " /" : ""}>`;
|
|
102
|
+
out += stamped;
|
|
103
|
+
cursor = m.index + tag.length;
|
|
104
|
+
|
|
105
|
+
// Raw-text element (not self-closed): copy its body through untouched and
|
|
106
|
+
// resume scanning after the matching close tag, so nothing inside is stamped.
|
|
107
|
+
if (!slash && RAW_TEXT_ELEMENTS.has(name.toLowerCase())) {
|
|
108
|
+
const rest = html.slice(cursor);
|
|
109
|
+
const closeMatch = new RegExp(`</${name}\\s*>`, "i").exec(rest);
|
|
110
|
+
if (!closeMatch) {
|
|
111
|
+
out += rest; // unterminated raw-text element — copy the remainder verbatim
|
|
112
|
+
cursor = html.length;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
const through = closeMatch.index + closeMatch[0].length; // body + close tag
|
|
116
|
+
out += rest.slice(0, through); // byte-intact
|
|
117
|
+
cursor += through;
|
|
118
|
+
openTag.lastIndex = cursor; // skip the body the scanner would otherwise re-walk
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
out += html.slice(cursor); // trailing text after the last opening tag
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
60
125
|
/**
|
|
61
126
|
* Register a page's colocated `.skin`/`.js` siblings as emitted assets.
|
|
62
127
|
* @param {string} file
|
package/src/compiler/index.js
CHANGED
|
@@ -4,20 +4,32 @@
|
|
|
4
4
|
// context.js shared typedefs + tiny helpers (createCompilation, at, …)
|
|
5
5
|
// interpolation.js path/value resolution, state-key lookup, escapeHtml, literals
|
|
6
6
|
// frontmatter.js `---` block → meta + body, inline scalar/array parsing
|
|
7
|
-
// includes.js @include / @loop-data resolution, colocated assets,
|
|
7
|
+
// includes.js @include / @loop-data resolution, colocated assets,
|
|
8
|
+
// scoped-skin detection + the HTML scope stamp, .md hints
|
|
8
9
|
// predicates.js where / when / :if / :computed expression compilers
|
|
9
10
|
// format.js { value | pipe } format pipes + list aggregates (value layer)
|
|
10
11
|
// markdown.js markdown-it instances + plugins + prose interpolation
|
|
12
|
+
// state.js :state / :store / :computed / :theme + declare* helpers
|
|
13
|
+
// actions.js the :button action parser + :button / :effect / :every
|
|
14
|
+
// fetch.js :fetch + the shared URL scheme guard
|
|
15
|
+
// forms.js :form + field directives + :bind / :slider
|
|
16
|
+
// media.js :video / :audio / :embed
|
|
17
|
+
// structure.js @include / ::: container / :if / :carousel + demo directives
|
|
18
|
+
// directives.js barrel re-exporting the handle* family from the six above
|
|
11
19
|
// loops.js @loop pipeline + row-template initial-paint fill
|
|
12
|
-
// directives.js the handle* family + :button action parser + demo directives
|
|
13
20
|
// body.js the line-based directive dispatcher (compileBody)
|
|
14
21
|
// page.js HTML page shell, image hardening, per-file compile
|
|
15
22
|
//
|
|
23
|
+
// Handlers recurse into nested bodies through `ctx.compileBody` (and `@include`
|
|
24
|
+
// through `ctx.compileFile`), threaded in by `compileFile` — so the graph is an
|
|
25
|
+
// import-cycle-free DAG rooted at context.js.
|
|
26
|
+
//
|
|
16
27
|
// This barrel re-exports the public API; `src/compiler.js` re-exports from here
|
|
17
28
|
// so every existing `import … from "./compiler.js"` keeps working unchanged.
|
|
18
29
|
// ---------------------------------------------------------------------------
|
|
19
30
|
|
|
20
31
|
export { parseFrontmatter } from "./frontmatter.js";
|
|
32
|
+
export { stampScope } from "./includes.js";
|
|
21
33
|
export { escapeHtml } from "./interpolation.js";
|
|
22
34
|
export { loopKeyOf } from "./loops.js";
|
|
23
|
-
export { compileDocument, compilePage, enhanceImages
|
|
35
|
+
export { compileDocument, compilePage, enhanceImages } from "./page.js";
|
package/src/compiler/loops.js
CHANGED
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
|
|
9
9
|
import fs from "node:fs";
|
|
10
|
-
import {
|
|
11
|
-
import { createScope } from "./context.js";
|
|
10
|
+
import { at, createScope, nestedCtx } from "./context.js";
|
|
12
11
|
import { applyPipeline, stagesFromAttr } from "./format.js";
|
|
13
12
|
import { resolveInclude } from "./includes.js";
|
|
14
13
|
import {
|
|
@@ -213,9 +212,12 @@ function numArgAttr(arg) {
|
|
|
213
212
|
* @param {string[]} bodyLines
|
|
214
213
|
* @param {string[] | null} emptyLines
|
|
215
214
|
* @param {Ctx} ctx
|
|
215
|
+
* @param {number} index 0-based line index of the `@loop` opener.
|
|
216
|
+
* @param {number} [emptyStart] 0-based index the empty branch starts at within
|
|
217
|
+
* the loop body, so its nested errors report the true file line.
|
|
216
218
|
* @returns {string}
|
|
217
219
|
*/
|
|
218
|
-
export function handleLoop(line, bodyLines, emptyLines, ctx) {
|
|
220
|
+
export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart = 0) {
|
|
219
221
|
const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(\s+.+?)?\s*$/);
|
|
220
222
|
if (!match) throw new Error(`Malformed @loop in ${ctx.file}: ${line}. ${LOOP_USAGE}`);
|
|
221
223
|
const source = stripQuotes(match[1].trim());
|
|
@@ -265,10 +267,14 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
|
|
|
265
267
|
offset: clauses.offset,
|
|
266
268
|
limit: clauses.limit,
|
|
267
269
|
empty: emptyLines,
|
|
270
|
+
emptyStart,
|
|
268
271
|
clauseRefsState: clauses.refsState,
|
|
269
272
|
sortable: clauses.sortable,
|
|
270
273
|
paginate: clauses.paginate
|
|
271
274
|
};
|
|
275
|
+
// The loop body starts on the line after the opener; nested errors in it (and
|
|
276
|
+
// in the empty branch, via `opts.emptyStart`) report the true file line.
|
|
277
|
+
const bodyCtx = nestedCtx(ctx, index + 1);
|
|
272
278
|
|
|
273
279
|
if (
|
|
274
280
|
source.startsWith("/") ||
|
|
@@ -278,20 +284,28 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
|
|
|
278
284
|
) {
|
|
279
285
|
if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
|
|
280
286
|
const dataFile = resolveInclude(source, ctx.file, ctx.context, true);
|
|
287
|
+
ctx.comp.deps.add(dataFile);
|
|
281
288
|
const rows = JSON.parse(fs.readFileSync(dataFile, "utf8"));
|
|
282
289
|
if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
|
|
283
|
-
return loopOverData(rows, itemName, bodyLines,
|
|
290
|
+
return loopOverData(rows, itemName, bodyLines, bodyCtx, opts);
|
|
284
291
|
}
|
|
285
292
|
|
|
286
293
|
const resolved = lookupPath(source, ctx);
|
|
287
294
|
if (resolved.found) {
|
|
288
295
|
if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
|
|
296
|
+
// A missing/unset field (e.g. an optional frontmatter key) is an EMPTY
|
|
297
|
+
// list, not an error — the `@empty` branch renders, matching the runtime.
|
|
298
|
+
if (resolved.value === null || resolved.value === undefined) {
|
|
299
|
+
return loopOverData([], itemName, bodyLines, bodyCtx, opts);
|
|
300
|
+
}
|
|
289
301
|
if (!Array.isArray(resolved.value)) {
|
|
290
302
|
throw new Error(
|
|
291
|
-
`@loop ${source} in ${ctx
|
|
303
|
+
`@loop ${source} in ${at(ctx, index)} found an in-scope value, but it is not a list ` +
|
|
304
|
+
`(got ${typeof resolved.value}). Use: a list value (e.g. \`tags: [a, b]\` in frontmatter) — ` +
|
|
305
|
+
`or leave the field out entirely, which loops zero rows and renders the @empty branch.`
|
|
292
306
|
);
|
|
293
307
|
}
|
|
294
|
-
return loopOverData(resolved.value, itemName, bodyLines,
|
|
308
|
+
return loopOverData(resolved.value, itemName, bodyLines, bodyCtx, opts);
|
|
295
309
|
}
|
|
296
310
|
|
|
297
311
|
// A bare name OR a dotted path resolving to declared :state (e.g. team.members).
|
|
@@ -304,13 +318,16 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
|
|
|
304
318
|
if (key) {
|
|
305
319
|
if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
|
|
306
320
|
const fullKey = segs.length > 1 ? `${key}.${segs.slice(1).join(".")}` : key;
|
|
307
|
-
return reactiveLoop(fullKey, itemName, bodyLines,
|
|
321
|
+
return reactiveLoop(fullKey, itemName, bodyLines, bodyCtx, opts);
|
|
308
322
|
}
|
|
309
323
|
// A bare name matching a collection (any `site/pages/<name>/` subdir) resolves
|
|
310
324
|
// to its entry rows at build time and static-unrolls — zero JS. This is checked
|
|
311
325
|
// after state so a declared `:state` of the same name still wins.
|
|
312
326
|
const collection = segs.length === 1 ? ctx.comp.collections.get(source) : undefined;
|
|
313
|
-
if (collection)
|
|
327
|
+
if (collection) {
|
|
328
|
+
ctx.comp.collectionsUsed.add(source);
|
|
329
|
+
return loopOverCollection(collection, source, itemName, bodyLines, bodyCtx, opts);
|
|
330
|
+
}
|
|
314
331
|
// Inside a reactive loop, `@loop <outerItem>.<path> into x` loops a field of
|
|
315
332
|
// the enclosing row item — an ITEM-RELATIVE loop the runtime fills per row.
|
|
316
333
|
if (ctx.loopItem && segs[0] === ctx.loopItem && segs.length > 1) {
|
|
@@ -320,7 +337,7 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
|
|
|
320
337
|
);
|
|
321
338
|
}
|
|
322
339
|
if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
|
|
323
|
-
return itemRelativeLoop(segs.slice(1).join("."), itemName, bodyLines,
|
|
340
|
+
return itemRelativeLoop(segs.slice(1).join("."), itemName, bodyLines, bodyCtx, opts);
|
|
324
341
|
}
|
|
325
342
|
}
|
|
326
343
|
|
|
@@ -387,7 +404,14 @@ function loopOverCollection(rows, name, itemName, bodyLines, ctx, opts) {
|
|
|
387
404
|
// document scope). Slice this page out of the already-filtered/sorted list.
|
|
388
405
|
const current = currentPage(ctx);
|
|
389
406
|
const start = (current - 1) * perPage;
|
|
390
|
-
return staticUnroll(
|
|
407
|
+
return staticUnroll(
|
|
408
|
+
filtered.slice(start, start + perPage),
|
|
409
|
+
itemName,
|
|
410
|
+
bodyLines,
|
|
411
|
+
ctx,
|
|
412
|
+
opts.empty,
|
|
413
|
+
opts.emptyStart
|
|
414
|
+
);
|
|
391
415
|
}
|
|
392
416
|
|
|
393
417
|
/**
|
|
@@ -466,7 +490,8 @@ function loopOverData(rows, itemName, bodyLines, ctx, opts) {
|
|
|
466
490
|
itemName,
|
|
467
491
|
bodyLines,
|
|
468
492
|
ctx,
|
|
469
|
-
opts.empty
|
|
493
|
+
opts.empty,
|
|
494
|
+
opts.emptyStart
|
|
470
495
|
);
|
|
471
496
|
}
|
|
472
497
|
|
|
@@ -476,10 +501,13 @@ function loopOverData(rows, itemName, bodyLines, ctx, opts) {
|
|
|
476
501
|
* @param {string[]} bodyLines
|
|
477
502
|
* @param {Ctx} ctx
|
|
478
503
|
* @param {string[] | null} [empty] Empty-branch body, rendered when 0 rows.
|
|
504
|
+
* @param {number} [emptyStart] 0-based index the empty branch starts at within
|
|
505
|
+
* the loop body, so its nested errors report the true file line.
|
|
479
506
|
* @returns {string}
|
|
480
507
|
*/
|
|
481
|
-
function staticUnroll(rows, itemName, bodyLines, ctx, empty = null) {
|
|
482
|
-
if (rows.length === 0 && empty)
|
|
508
|
+
function staticUnroll(rows, itemName, bodyLines, ctx, empty = null, emptyStart = 0) {
|
|
509
|
+
if (rows.length === 0 && empty)
|
|
510
|
+
return ctx.compileBody(empty, { ...nestedCtx(ctx, emptyStart), loopMeta: true });
|
|
483
511
|
const out = [];
|
|
484
512
|
const count = rows.length;
|
|
485
513
|
for (let i = 0; i < count; i++) {
|
|
@@ -495,7 +523,7 @@ function staticUnroll(rows, itemName, bodyLines, ctx, empty = null) {
|
|
|
495
523
|
loopMeta: true,
|
|
496
524
|
scope: createScope(ctx.scope, { [itemName]: rows[i], ...meta })
|
|
497
525
|
};
|
|
498
|
-
out.push(compileBody(bodyLines, rowCtx));
|
|
526
|
+
out.push(ctx.compileBody(bodyLines, rowCtx));
|
|
499
527
|
}
|
|
500
528
|
return out.join("\n");
|
|
501
529
|
}
|
|
@@ -521,7 +549,7 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
|
521
549
|
loopMeta: true,
|
|
522
550
|
scope: createScope(ctx.scope)
|
|
523
551
|
};
|
|
524
|
-
const templateHtml = compileBody(bodyLines, templateCtx).trim();
|
|
552
|
+
const templateHtml = ctx.compileBody(bodyLines, templateCtx).trim();
|
|
525
553
|
|
|
526
554
|
const { wrapperTag, itemTemplate } = wrapRowTemplate(templateHtml);
|
|
527
555
|
|
|
@@ -577,10 +605,12 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
|
577
605
|
})
|
|
578
606
|
.join("");
|
|
579
607
|
|
|
580
|
-
const emptyTemplate = loopEmptyTemplate(emptyLines, ctx);
|
|
608
|
+
const emptyTemplate = loopEmptyTemplate(emptyLines, ctx, opts?.emptyStart);
|
|
581
609
|
const initialOut =
|
|
582
610
|
count === 0 && emptyLines
|
|
583
|
-
?
|
|
611
|
+
? ctx
|
|
612
|
+
.compileBody(emptyLines, { ...nestedCtx(ctx, opts?.emptyStart ?? 0), loopMeta: true })
|
|
613
|
+
.trim()
|
|
584
614
|
: initial;
|
|
585
615
|
|
|
586
616
|
// `sortable` tags the region for the drag-reorder behavior (emitted only here)
|
|
@@ -615,11 +645,13 @@ function wrapRowTemplate(templateHtml) {
|
|
|
615
645
|
* there is no empty branch). Shared by top-level and item-relative loops.
|
|
616
646
|
* @param {string[] | null} emptyLines
|
|
617
647
|
* @param {Ctx} ctx
|
|
648
|
+
* @param {number} [emptyStart] 0-based index the empty branch starts at within
|
|
649
|
+
* the loop body, so its nested errors report the true file line.
|
|
618
650
|
* @returns {string}
|
|
619
651
|
*/
|
|
620
|
-
function loopEmptyTemplate(emptyLines, ctx) {
|
|
652
|
+
function loopEmptyTemplate(emptyLines, ctx, emptyStart = 0) {
|
|
621
653
|
return emptyLines
|
|
622
|
-
? `<template data-wd-loop-empty>${compileBody(emptyLines, { ...ctx, loopMeta: true }).trim()}</template>`
|
|
654
|
+
? `<template data-wd-loop-empty>${ctx.compileBody(emptyLines, { ...nestedCtx(ctx, emptyStart), loopMeta: true }).trim()}</template>`
|
|
623
655
|
: "";
|
|
624
656
|
}
|
|
625
657
|
|
|
@@ -668,11 +700,11 @@ function itemRelativeLoop(path, itemName, bodyLines, ctx, opts) {
|
|
|
668
700
|
loopMeta: true,
|
|
669
701
|
scope: createScope(ctx.scope)
|
|
670
702
|
};
|
|
671
|
-
const templateHtml = compileBody(bodyLines, templateCtx).trim();
|
|
703
|
+
const templateHtml = ctx.compileBody(bodyLines, templateCtx).trim();
|
|
672
704
|
|
|
673
705
|
const { wrapperTag, itemTemplate } = wrapRowTemplate(templateHtml);
|
|
674
706
|
|
|
675
|
-
const emptyTemplate = loopEmptyTemplate(opts.empty || null, ctx);
|
|
707
|
+
const emptyTemplate = loopEmptyTemplate(opts.empty || null, ctx, opts.emptyStart);
|
|
676
708
|
const attrs = loopClauseAttrs({
|
|
677
709
|
where: opts.where || null,
|
|
678
710
|
sort: opts.sort || null,
|
package/src/compiler/markdown.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
|
-
// Markdown layer: the shared markdown-it instances (
|
|
3
|
-
// `html:
|
|
2
|
+
// Markdown layer: the shared markdown-it instances (strict default + a lazy
|
|
3
|
+
// `html: true` raw-HTML variant), the `wd_binding` inline plugin and `wd_attrs`
|
|
4
4
|
// core plugin, and prose rendering with `{ name.path }` interpolation resolved
|
|
5
5
|
// against the loop item / static scope / declared state.
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
@@ -26,29 +26,33 @@ import {
|
|
|
26
26
|
// with a known language. The callback returns escaped token HTML or `""` to let
|
|
27
27
|
// markdown-it render a plain escaped `<code>` (graceful degradation). Both
|
|
28
28
|
// instances share it so `html: false` pages highlight too.
|
|
29
|
-
const md = new MarkdownIt({ html:
|
|
29
|
+
const md = new MarkdownIt({ html: false, highlight: highlightCode });
|
|
30
30
|
md.use(bindingPlugin);
|
|
31
31
|
md.use(attrsPlugin);
|
|
32
|
+
md.use(anchorPlugin);
|
|
32
33
|
|
|
33
|
-
// Raw HTML in markdown
|
|
34
|
-
//
|
|
35
|
-
//
|
|
34
|
+
// Raw HTML in markdown is escaped by default: a stray `<script>` or `onerror=`
|
|
35
|
+
// attribute in content renders as inert text, so multi-author content (blog
|
|
36
|
+
// collections, contributed docs) is stored-XSS-safe out of the box. A page
|
|
37
|
+
// whose author writes their own HTML opts in with frontmatter `html: true`;
|
|
38
|
+
// that instance is built lazily so the default path stays a single instance.
|
|
36
39
|
/** @type {MarkdownIt | null} */
|
|
37
|
-
let
|
|
40
|
+
let mdRawHtml = null;
|
|
38
41
|
/**
|
|
39
|
-
* Pick the markdown-it instance for a page (raw HTML
|
|
42
|
+
* Pick the markdown-it instance for a page (raw HTML off by default, on with `html: true`).
|
|
40
43
|
* @param {Meta} [meta]
|
|
41
44
|
* @returns {MarkdownIt}
|
|
42
45
|
*/
|
|
43
46
|
export function selectMd(meta) {
|
|
44
47
|
// Frontmatter scalars stay strings (no coercion), so accept both forms.
|
|
45
|
-
if (meta?.html !==
|
|
46
|
-
if (!
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
if (meta?.html !== true && meta?.html !== "true") return md;
|
|
49
|
+
if (!mdRawHtml) {
|
|
50
|
+
mdRawHtml = new MarkdownIt({ html: true, highlight: highlightCode });
|
|
51
|
+
mdRawHtml.use(bindingPlugin);
|
|
52
|
+
mdRawHtml.use(attrsPlugin);
|
|
53
|
+
mdRawHtml.use(anchorPlugin);
|
|
50
54
|
}
|
|
51
|
-
return
|
|
55
|
+
return mdRawHtml;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
/**
|
|
@@ -59,7 +63,8 @@ export function selectMd(meta) {
|
|
|
59
63
|
export function renderProse(text, ctx) {
|
|
60
64
|
text = resolveDestinationBindings(text, ctx);
|
|
61
65
|
return (ctx.md ?? md).render(text, {
|
|
62
|
-
resolveBinding: (/** @type {string} */ expr) => resolveBindingHtml(expr, ctx)
|
|
66
|
+
resolveBinding: (/** @type {string} */ expr) => resolveBindingHtml(expr, ctx),
|
|
67
|
+
headingSlugs: ctx.comp.headingSlugs
|
|
63
68
|
});
|
|
64
69
|
}
|
|
65
70
|
|
|
@@ -197,6 +202,51 @@ export function attrTarget(children, i) {
|
|
|
197
202
|
return null;
|
|
198
203
|
}
|
|
199
204
|
|
|
205
|
+
/**
|
|
206
|
+
* GitHub-style heading slug: lowercase, punctuation stripped (letters, numbers,
|
|
207
|
+
* whitespace, `_`, and `-` survive), each whitespace character becomes a hyphen.
|
|
208
|
+
* Punctuation-only headings fall back to `"section"` so the id is never empty.
|
|
209
|
+
* @param {string} text Plain heading text.
|
|
210
|
+
* @returns {string}
|
|
211
|
+
*/
|
|
212
|
+
export function slugify(text) {
|
|
213
|
+
const slug = text
|
|
214
|
+
.trim()
|
|
215
|
+
.toLowerCase()
|
|
216
|
+
.replace(/[^\p{L}\p{N}\s_-]+/gu, "")
|
|
217
|
+
.replace(/\s/gu, "-");
|
|
218
|
+
return slug || "section";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* markdown-it plugin (core rule): every heading gets a stable slugified `id`
|
|
223
|
+
* so long pages are linkable without any JS. Duplicate slugs dedupe with
|
|
224
|
+
* `-1`/`-2` suffixes; the counters live in `env.headingSlugs` so a page whose
|
|
225
|
+
* prose renders in chunks (directives between headings) still dedupes across
|
|
226
|
+
* the whole document. Bindings and inline HTML inside a heading contribute no
|
|
227
|
+
* slug text — only literal text and inline code do.
|
|
228
|
+
* @param {MarkdownIt} mdInstance
|
|
229
|
+
* @returns {void}
|
|
230
|
+
*/
|
|
231
|
+
function anchorPlugin(mdInstance) {
|
|
232
|
+
mdInstance.core.ruler.push("wd_heading_anchors", (state) => {
|
|
233
|
+
const counts = (state.env.headingSlugs ??= new Map());
|
|
234
|
+
const tokens = state.tokens;
|
|
235
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
236
|
+
if (tokens[i].type !== "heading_open") continue;
|
|
237
|
+
let text = "";
|
|
238
|
+
for (const child of tokens[i + 1].children ?? []) {
|
|
239
|
+
if (child.type === "text" || child.type === "code_inline") text += child.content;
|
|
240
|
+
}
|
|
241
|
+
const base = slugify(text);
|
|
242
|
+
const seen = counts.get(base) ?? 0;
|
|
243
|
+
counts.set(base, seen + 1);
|
|
244
|
+
tokens[i].attrSet("id", seen ? `${base}-${seen}` : base);
|
|
245
|
+
}
|
|
246
|
+
return false;
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
200
250
|
/**
|
|
201
251
|
* Resolve a `{ name.path }` binding to HTML: loop item span, static value, or
|
|
202
252
|
* a reactive bind span. Returns null when nothing in scope matches.
|
|
@@ -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
|
+
}
|