@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
|
@@ -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,14 +7,14 @@
|
|
|
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 {
|
|
15
14
|
escapeHtml,
|
|
16
15
|
getPath,
|
|
17
16
|
lookupPath,
|
|
17
|
+
lookupVar,
|
|
18
18
|
resolveStateKey,
|
|
19
19
|
stripQuotes,
|
|
20
20
|
unescapeHtml
|
|
@@ -30,15 +30,15 @@ import { compilePredicate, evalPredicate } from "./predicates.js";
|
|
|
30
30
|
|
|
31
31
|
// The fixed corrective suggestion shown for any malformed @loop header.
|
|
32
32
|
const LOOP_USAGE =
|
|
33
|
-
"Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N] [sortable]";
|
|
33
|
+
"Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N] [paginate N] [sortable]";
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
36
|
* Parse the optional clause tail of a `@loop` header in FIXED order:
|
|
37
|
-
* `[where P] [sort by key [asc|desc]] [reverse] [offset N] [limit N] [sortable]`.
|
|
37
|
+
* `[where P] [sort by key [asc|desc]] [reverse] [offset N] [limit N] [paginate N] [sortable]`.
|
|
38
38
|
* @param {string} tail Everything after `@loop src into item`.
|
|
39
39
|
* @param {string} itemName
|
|
40
40
|
* @param {Ctx} ctx
|
|
41
|
-
* @returns {{ where: string|null, sort: import("./context.js").LoopOpts["sort"], reverse: boolean, offset: NumArg|null, limit: NumArg|null, refsState: boolean, sortable: boolean }}
|
|
41
|
+
* @returns {{ where: string|null, sort: import("./context.js").LoopOpts["sort"], reverse: boolean, offset: NumArg|null, limit: NumArg|null, refsState: boolean, sortable: boolean, paginate: number|null }}
|
|
42
42
|
*/
|
|
43
43
|
function parseLoopClauses(tail, itemName, ctx) {
|
|
44
44
|
// Peel clauses off the END in reverse fixed-order (limit, offset, reverse,
|
|
@@ -53,6 +53,7 @@ function parseLoopClauses(tail, itemName, ctx) {
|
|
|
53
53
|
/** @type {NumArg|null} */ let limit = null;
|
|
54
54
|
let refsState = false;
|
|
55
55
|
let sortable = false;
|
|
56
|
+
/** @type {number|null} */ let paginate = null;
|
|
56
57
|
/** @returns {never} */
|
|
57
58
|
const bad = () => {
|
|
58
59
|
throw new Error(`Malformed @loop clause in ${ctx.file}: "${tail.trim()}". ${LOOP_USAGE}`);
|
|
@@ -69,6 +70,20 @@ function parseLoopClauses(tail, itemName, ctx) {
|
|
|
69
70
|
.trim();
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
// `paginate N` slices the listing into static pages. Peel it position-
|
|
74
|
+
// independently (like `sortable`) so `paginate 2 limit 1` parses and handleLoop
|
|
75
|
+
// can reject the combination with a precise message rather than failing the
|
|
76
|
+
// fixed-order parse. The count is a positive integer literal — paginating "by a
|
|
77
|
+
// state value" makes no sense for a build-time route split.
|
|
78
|
+
const pm = s.match(/(^|\s)paginate\s+(\d+)(?=\s|$)/);
|
|
79
|
+
if (pm) {
|
|
80
|
+
paginate = Number(pm[2]);
|
|
81
|
+
if (paginate < 1) bad();
|
|
82
|
+
s = (s.slice(0, pm.index) + s.slice((pm.index ?? 0) + pm[0].length))
|
|
83
|
+
.replace(/\s+/g, " ")
|
|
84
|
+
.trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
72
87
|
let m = s.match(/(^|\s)limit\s+(\d+|[A-Za-z_$][\w$]*)$/);
|
|
73
88
|
if (m) {
|
|
74
89
|
limit = parseNumArg(m[2], ctx);
|
|
@@ -109,7 +124,7 @@ function parseLoopClauses(tail, itemName, ctx) {
|
|
|
109
124
|
// predicate means a clause was written before `where` finished (wrong order).
|
|
110
125
|
if (/(^|\s)(sort\s+by\s|reverse(\s|$))/.test(where)) bad();
|
|
111
126
|
}
|
|
112
|
-
return { where, sort, reverse, offset, limit, refsState, sortable };
|
|
127
|
+
return { where, sort, reverse, offset, limit, refsState, sortable, paginate };
|
|
113
128
|
}
|
|
114
129
|
|
|
115
130
|
/**
|
|
@@ -197,9 +212,12 @@ function numArgAttr(arg) {
|
|
|
197
212
|
* @param {string[]} bodyLines
|
|
198
213
|
* @param {string[] | null} emptyLines
|
|
199
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.
|
|
200
218
|
* @returns {string}
|
|
201
219
|
*/
|
|
202
|
-
export function handleLoop(line, bodyLines, emptyLines, ctx) {
|
|
220
|
+
export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart = 0) {
|
|
203
221
|
const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(\s+.+?)?\s*$/);
|
|
204
222
|
if (!match) throw new Error(`Malformed @loop in ${ctx.file}: ${line}. ${LOOP_USAGE}`);
|
|
205
223
|
const source = stripQuotes(match[1].trim());
|
|
@@ -213,18 +231,32 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
|
|
|
213
231
|
offset: null,
|
|
214
232
|
limit: null,
|
|
215
233
|
refsState: false,
|
|
216
|
-
sortable: false
|
|
234
|
+
sortable: false,
|
|
235
|
+
paginate: null
|
|
217
236
|
};
|
|
218
237
|
const where = clauses.where ? compilePredicate(clauses.where, itemName, ctx) : null;
|
|
219
238
|
// `sortable` reorders the list directly, so it can't combine with a clause that
|
|
220
239
|
// re-derives the visible order (where/sort/reverse) or shows a partial view
|
|
221
|
-
// (offset/limit) — those would break the 1:1 DOM↔array index mapping.
|
|
240
|
+
// (offset/limit/paginate) — those would break the 1:1 DOM↔array index mapping.
|
|
222
241
|
if (
|
|
223
242
|
clauses.sortable &&
|
|
224
|
-
(where ||
|
|
243
|
+
(where ||
|
|
244
|
+
clauses.sort ||
|
|
245
|
+
clauses.reverse ||
|
|
246
|
+
clauses.offset ||
|
|
247
|
+
clauses.limit ||
|
|
248
|
+
clauses.paginate)
|
|
225
249
|
) {
|
|
226
250
|
throw new Error(
|
|
227
|
-
`@loop sortable cannot combine with where/sort/reverse/offset/limit in ${ctx.file}. ${LOOP_USAGE}`
|
|
251
|
+
`@loop sortable cannot combine with where/sort/reverse/offset/limit/paginate in ${ctx.file}. ${LOOP_USAGE}`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
// `paginate N` OWNS the page slice, so an explicit offset/limit alongside it is
|
|
255
|
+
// a conflict (which one wins?). Reject it with a clear hint rather than silently
|
|
256
|
+
// letting one override the other.
|
|
257
|
+
if (clauses.paginate && (clauses.offset || clauses.limit)) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`@loop paginate cannot combine with offset/limit in ${ctx.file} — paginate already slices each page. ${LOOP_USAGE}`
|
|
228
260
|
);
|
|
229
261
|
}
|
|
230
262
|
/** @type {LoopOpts} */
|
|
@@ -235,9 +267,14 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
|
|
|
235
267
|
offset: clauses.offset,
|
|
236
268
|
limit: clauses.limit,
|
|
237
269
|
empty: emptyLines,
|
|
270
|
+
emptyStart,
|
|
238
271
|
clauseRefsState: clauses.refsState,
|
|
239
|
-
sortable: clauses.sortable
|
|
272
|
+
sortable: clauses.sortable,
|
|
273
|
+
paginate: clauses.paginate
|
|
240
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);
|
|
241
278
|
|
|
242
279
|
if (
|
|
243
280
|
source.startsWith("/") ||
|
|
@@ -245,20 +282,30 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
|
|
|
245
282
|
source.startsWith("../") ||
|
|
246
283
|
source.endsWith(".json")
|
|
247
284
|
) {
|
|
285
|
+
if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
|
|
248
286
|
const dataFile = resolveInclude(source, ctx.file, ctx.context, true);
|
|
287
|
+
ctx.comp.deps.add(dataFile);
|
|
249
288
|
const rows = JSON.parse(fs.readFileSync(dataFile, "utf8"));
|
|
250
289
|
if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
|
|
251
|
-
return loopOverData(rows, itemName, bodyLines,
|
|
290
|
+
return loopOverData(rows, itemName, bodyLines, bodyCtx, opts);
|
|
252
291
|
}
|
|
253
292
|
|
|
254
293
|
const resolved = lookupPath(source, ctx);
|
|
255
294
|
if (resolved.found) {
|
|
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
|
+
}
|
|
256
301
|
if (!Array.isArray(resolved.value)) {
|
|
257
302
|
throw new Error(
|
|
258
|
-
`@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.`
|
|
259
306
|
);
|
|
260
307
|
}
|
|
261
|
-
return loopOverData(resolved.value, itemName, bodyLines,
|
|
308
|
+
return loopOverData(resolved.value, itemName, bodyLines, bodyCtx, opts);
|
|
262
309
|
}
|
|
263
310
|
|
|
264
311
|
// A bare name OR a dotted path resolving to declared :state (e.g. team.members).
|
|
@@ -269,8 +316,17 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
|
|
|
269
316
|
}
|
|
270
317
|
const key = resolveStateKey(segs[0], ctx);
|
|
271
318
|
if (key) {
|
|
319
|
+
if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
|
|
272
320
|
const fullKey = segs.length > 1 ? `${key}.${segs.slice(1).join(".")}` : key;
|
|
273
|
-
return reactiveLoop(fullKey, itemName, bodyLines,
|
|
321
|
+
return reactiveLoop(fullKey, itemName, bodyLines, bodyCtx, opts);
|
|
322
|
+
}
|
|
323
|
+
// A bare name matching a collection (any `site/pages/<name>/` subdir) resolves
|
|
324
|
+
// to its entry rows at build time and static-unrolls — zero JS. This is checked
|
|
325
|
+
// after state so a declared `:state` of the same name still wins.
|
|
326
|
+
const collection = segs.length === 1 ? ctx.comp.collections.get(source) : undefined;
|
|
327
|
+
if (collection) {
|
|
328
|
+
ctx.comp.collectionsUsed.add(source);
|
|
329
|
+
return loopOverCollection(collection, source, itemName, bodyLines, bodyCtx, opts);
|
|
274
330
|
}
|
|
275
331
|
// Inside a reactive loop, `@loop <outerItem>.<path> into x` loops a field of
|
|
276
332
|
// the enclosing row item — an ITEM-RELATIVE loop the runtime fills per row.
|
|
@@ -280,15 +336,100 @@ export function handleLoop(line, bodyLines, emptyLines, ctx) {
|
|
|
280
336
|
`@loop sortable is not supported on a nested (item-relative) loop in ${ctx.file}. ${LOOP_USAGE}`
|
|
281
337
|
);
|
|
282
338
|
}
|
|
283
|
-
|
|
339
|
+
if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
|
|
340
|
+
return itemRelativeLoop(segs.slice(1).join("."), itemName, bodyLines, bodyCtx, opts);
|
|
284
341
|
}
|
|
285
342
|
}
|
|
286
343
|
|
|
287
|
-
throw new Error(
|
|
288
|
-
|
|
344
|
+
throw new Error(unresolvedSourceError(source, ctx));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The "source not found" error for an unresolved `@loop` bare name. Lists the
|
|
349
|
+
* valid collection names (the `site/pages/<name>/` subdirs) so a typo'd
|
|
350
|
+
* collection reference is immediately actionable.
|
|
351
|
+
* @param {string} source
|
|
352
|
+
* @param {Ctx} ctx
|
|
353
|
+
* @returns {string}
|
|
354
|
+
*/
|
|
355
|
+
function unresolvedSourceError(source, ctx) {
|
|
356
|
+
const names = [...ctx.comp.collections.keys()].sort();
|
|
357
|
+
const collectionsHint = names.length ? ` Available collections: ${names.join(", ")}.` : "";
|
|
358
|
+
return (
|
|
359
|
+
`@loop source "${source}" in ${ctx.file} was not found. Loop over a collection ` +
|
|
360
|
+
`(a site/pages/<name>/ subdirectory, by its bare name), a JSON file ` +
|
|
361
|
+
`(@loop /data.json into row), an in-scope value, or a :state list.${collectionsHint}`
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The error for `paginate` on a non-collection source: pagination multiplies
|
|
367
|
+
* static routes, which only makes sense for a build-time collection listing.
|
|
368
|
+
* @param {Ctx} ctx
|
|
369
|
+
* @returns {string}
|
|
370
|
+
*/
|
|
371
|
+
function paginateOnlyCollections(ctx) {
|
|
372
|
+
return `@loop paginate requires a collection source (a site/pages/<name>/ subdirectory) in ${ctx.file}. ${LOOP_USAGE}`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Loop over a build-time collection's entry rows. With no `paginate`, this is a
|
|
377
|
+
* plain static unroll (filter/sort/reverse/offset/limit at build time, zero JS).
|
|
378
|
+
* With `paginate N`, it records the pagination intent (so the builder multiplies
|
|
379
|
+
* routes), then renders only the current page's slice using the injected `page`
|
|
380
|
+
* pager — `offset = (page.current - 1) * N`, `limit = N`.
|
|
381
|
+
* @param {import("./collections.js").CollectionRow[]} rows
|
|
382
|
+
* @param {string} name Collection name, for the pagination record.
|
|
383
|
+
* @param {string} itemName
|
|
384
|
+
* @param {string[]} bodyLines
|
|
385
|
+
* @param {Ctx} ctx
|
|
386
|
+
* @param {LoopOpts} opts
|
|
387
|
+
* @returns {string}
|
|
388
|
+
*/
|
|
389
|
+
function loopOverCollection(rows, name, itemName, bodyLines, ctx, opts) {
|
|
390
|
+
if (!opts.paginate) {
|
|
391
|
+
// No pagination → behave exactly like a JSON-file/in-scope source: a pure
|
|
392
|
+
// listing static-unrolls (zero JS); a `where` that reads :state turns it into
|
|
393
|
+
// a reactive baked-rows loop. `loopOverData` makes that call.
|
|
394
|
+
return loopOverData(rows, itemName, bodyLines, ctx, opts);
|
|
395
|
+
}
|
|
396
|
+
// Count rows AFTER where/sort/reverse but BEFORE the page slice, so `total`
|
|
397
|
+
// reflects what the reader will actually page through. Reuse pipelineRows with
|
|
398
|
+
// offset/limit stripped for the count.
|
|
399
|
+
const filtered = pipelineRows(rows, opts.where, { ...opts, offset: null, limit: null }, ctx);
|
|
400
|
+
const perPage = opts.paginate;
|
|
401
|
+
const total = Math.max(1, Math.ceil(filtered.length / perPage));
|
|
402
|
+
ctx.comp.pagination = { perPage, total, collection: name };
|
|
403
|
+
// `page.current` is the 1-based page the builder is rendering (it seeds the
|
|
404
|
+
// document scope). Slice this page out of the already-filtered/sorted list.
|
|
405
|
+
const current = currentPage(ctx);
|
|
406
|
+
const start = (current - 1) * perPage;
|
|
407
|
+
return staticUnroll(
|
|
408
|
+
filtered.slice(start, start + perPage),
|
|
409
|
+
itemName,
|
|
410
|
+
bodyLines,
|
|
411
|
+
ctx,
|
|
412
|
+
opts.empty,
|
|
413
|
+
opts.emptyStart
|
|
289
414
|
);
|
|
290
415
|
}
|
|
291
416
|
|
|
417
|
+
/**
|
|
418
|
+
* The 1-based page the builder is currently rendering, read from the injected
|
|
419
|
+
* `page.current` scope var. Defaults to page 1 (the discovery compile, before the
|
|
420
|
+
* builder knows the total, runs with `page.current = 1`).
|
|
421
|
+
* @param {Ctx} ctx
|
|
422
|
+
* @returns {number}
|
|
423
|
+
*/
|
|
424
|
+
function currentPage(ctx) {
|
|
425
|
+
const page = lookupVar(ctx.scope, "page");
|
|
426
|
+
if (page.found && page.value && typeof page.value === "object") {
|
|
427
|
+
const current = /** @type {Record<string, unknown>} */ (page.value).current;
|
|
428
|
+
if (typeof current === "number" && current >= 1) return current;
|
|
429
|
+
}
|
|
430
|
+
return 1;
|
|
431
|
+
}
|
|
432
|
+
|
|
292
433
|
// A static source (JSON file / in-scope value). No `where`, or a `where` that
|
|
293
434
|
// only reads the loop item → filter at build time and stay zero-JS. A `where`
|
|
294
435
|
// that reads :state → becomes a reactive filtered loop with the rows baked in.
|
|
@@ -349,7 +490,8 @@ function loopOverData(rows, itemName, bodyLines, ctx, opts) {
|
|
|
349
490
|
itemName,
|
|
350
491
|
bodyLines,
|
|
351
492
|
ctx,
|
|
352
|
-
opts.empty
|
|
493
|
+
opts.empty,
|
|
494
|
+
opts.emptyStart
|
|
353
495
|
);
|
|
354
496
|
}
|
|
355
497
|
|
|
@@ -359,10 +501,13 @@ function loopOverData(rows, itemName, bodyLines, ctx, opts) {
|
|
|
359
501
|
* @param {string[]} bodyLines
|
|
360
502
|
* @param {Ctx} ctx
|
|
361
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.
|
|
362
506
|
* @returns {string}
|
|
363
507
|
*/
|
|
364
|
-
function staticUnroll(rows, itemName, bodyLines, ctx, empty = null) {
|
|
365
|
-
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 });
|
|
366
511
|
const out = [];
|
|
367
512
|
const count = rows.length;
|
|
368
513
|
for (let i = 0; i < count; i++) {
|
|
@@ -378,7 +523,7 @@ function staticUnroll(rows, itemName, bodyLines, ctx, empty = null) {
|
|
|
378
523
|
loopMeta: true,
|
|
379
524
|
scope: createScope(ctx.scope, { [itemName]: rows[i], ...meta })
|
|
380
525
|
};
|
|
381
|
-
out.push(compileBody(bodyLines, rowCtx));
|
|
526
|
+
out.push(ctx.compileBody(bodyLines, rowCtx));
|
|
382
527
|
}
|
|
383
528
|
return out.join("\n");
|
|
384
529
|
}
|
|
@@ -404,7 +549,7 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
|
404
549
|
loopMeta: true,
|
|
405
550
|
scope: createScope(ctx.scope)
|
|
406
551
|
};
|
|
407
|
-
const templateHtml = compileBody(bodyLines, templateCtx).trim();
|
|
552
|
+
const templateHtml = ctx.compileBody(bodyLines, templateCtx).trim();
|
|
408
553
|
|
|
409
554
|
const { wrapperTag, itemTemplate } = wrapRowTemplate(templateHtml);
|
|
410
555
|
|
|
@@ -440,7 +585,8 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
|
440
585
|
: limit,
|
|
441
586
|
empty: null,
|
|
442
587
|
clauseRefsState: false,
|
|
443
|
-
sortable: false
|
|
588
|
+
sortable: false,
|
|
589
|
+
paginate: null
|
|
444
590
|
},
|
|
445
591
|
ctx
|
|
446
592
|
);
|
|
@@ -459,10 +605,12 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
|
459
605
|
})
|
|
460
606
|
.join("");
|
|
461
607
|
|
|
462
|
-
const emptyTemplate = loopEmptyTemplate(emptyLines, ctx);
|
|
608
|
+
const emptyTemplate = loopEmptyTemplate(emptyLines, ctx, opts?.emptyStart);
|
|
463
609
|
const initialOut =
|
|
464
610
|
count === 0 && emptyLines
|
|
465
|
-
?
|
|
611
|
+
? ctx
|
|
612
|
+
.compileBody(emptyLines, { ...nestedCtx(ctx, opts?.emptyStart ?? 0), loopMeta: true })
|
|
613
|
+
.trim()
|
|
466
614
|
: initial;
|
|
467
615
|
|
|
468
616
|
// `sortable` tags the region for the drag-reorder behavior (emitted only here)
|
|
@@ -497,11 +645,13 @@ function wrapRowTemplate(templateHtml) {
|
|
|
497
645
|
* there is no empty branch). Shared by top-level and item-relative loops.
|
|
498
646
|
* @param {string[] | null} emptyLines
|
|
499
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.
|
|
500
650
|
* @returns {string}
|
|
501
651
|
*/
|
|
502
|
-
function loopEmptyTemplate(emptyLines, ctx) {
|
|
652
|
+
function loopEmptyTemplate(emptyLines, ctx, emptyStart = 0) {
|
|
503
653
|
return emptyLines
|
|
504
|
-
? `<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>`
|
|
505
655
|
: "";
|
|
506
656
|
}
|
|
507
657
|
|
|
@@ -550,11 +700,11 @@ function itemRelativeLoop(path, itemName, bodyLines, ctx, opts) {
|
|
|
550
700
|
loopMeta: true,
|
|
551
701
|
scope: createScope(ctx.scope)
|
|
552
702
|
};
|
|
553
|
-
const templateHtml = compileBody(bodyLines, templateCtx).trim();
|
|
703
|
+
const templateHtml = ctx.compileBody(bodyLines, templateCtx).trim();
|
|
554
704
|
|
|
555
705
|
const { wrapperTag, itemTemplate } = wrapRowTemplate(templateHtml);
|
|
556
706
|
|
|
557
|
-
const emptyTemplate = loopEmptyTemplate(opts.empty || null, ctx);
|
|
707
|
+
const emptyTemplate = loopEmptyTemplate(opts.empty || null, ctx, opts.emptyStart);
|
|
558
708
|
const attrs = loopClauseAttrs({
|
|
559
709
|
where: opts.where || null,
|
|
560
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.
|