@jxsuite/parser 1.0.0 → 1.2.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/README.md +107 -66
- package/dist/content-loader.js +16 -2
- package/dist/markdown.js +10650 -777
- package/dist/serialize.js +991 -579
- package/package.json +7 -4
- package/src/Csv.class.json +1 -1
- package/src/content-loader.ts +23 -4
- package/src/highlight.ts +112 -0
- package/src/markdown.ts +7 -0
- package/src/md.ts +38 -27
- package/src/transpile.ts +102 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/parser",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Jx markdown parser and external class integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -48,8 +48,10 @@
|
|
|
48
48
|
"upgrade": "bunx npm-check-updates -u && bun install"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@jxsuite/markup": "^0.
|
|
52
|
-
"@jxsuite/schema": "^1.
|
|
51
|
+
"@jxsuite/markup": "^0.3.1",
|
|
52
|
+
"@jxsuite/schema": "^1.2.0",
|
|
53
|
+
"@shikijs/langs": "^4.3.1",
|
|
54
|
+
"@shikijs/themes": "^4.3.1",
|
|
53
55
|
"glob": "^13.0.6",
|
|
54
56
|
"mdast-util-to-string": "^4.0.0",
|
|
55
57
|
"remark-directive": "^4.0.0",
|
|
@@ -58,13 +60,14 @@
|
|
|
58
60
|
"remark-parse": "^11.0.0",
|
|
59
61
|
"remark-parse-frontmatter": "^1.0.3",
|
|
60
62
|
"remark-stringify": "^11.0.0",
|
|
63
|
+
"shiki": "^4.3.1",
|
|
61
64
|
"unified": "^11.0.5",
|
|
62
65
|
"unist-util-visit": "^5.1.0",
|
|
63
66
|
"yaml": "^2.9.0"
|
|
64
67
|
},
|
|
65
68
|
"devDependencies": {
|
|
66
69
|
"@happy-dom/global-registrator": "^20.10.6",
|
|
67
|
-
"@jxsuite/runtime": "^1.
|
|
70
|
+
"@jxsuite/runtime": "^1.2.0",
|
|
68
71
|
"@types/mdast": "^4.0.4",
|
|
69
72
|
"ajv": "^8.20.0"
|
|
70
73
|
},
|
package/src/Csv.class.json
CHANGED
package/src/content-loader.ts
CHANGED
|
@@ -269,12 +269,15 @@ async function loadContentType(
|
|
|
269
269
|
})) as string[])
|
|
270
270
|
: [resolvedSource];
|
|
271
271
|
|
|
272
|
+
// Directory sources pass their resolved root so formats can derive path-based ids for nested files
|
|
273
|
+
const sourceRoot = extname(source) ? undefined : resolvedSource;
|
|
272
274
|
const entries: ContentLoaderEntry[] = [];
|
|
273
275
|
for (const filePath of files) {
|
|
274
276
|
entries.push(
|
|
275
277
|
...((await entry.call("load", filePath, {
|
|
276
278
|
directiveOptions,
|
|
277
279
|
schema,
|
|
280
|
+
...(sourceRoot !== undefined && { sourceRoot }),
|
|
278
281
|
})) as ContentLoaderEntry[]),
|
|
279
282
|
);
|
|
280
283
|
}
|
|
@@ -374,7 +377,7 @@ function refTargetName(ref: string | undefined): string | undefined {
|
|
|
374
377
|
* field `$ref` is to-one: a blog post's `author: "jane-doe"` with a schema `$ref` of
|
|
375
378
|
* `#/content/authors` gets replaced by the full author entry. An array field whose `items` carry
|
|
376
379
|
* the `$ref` is to-many: each id in the stored array is replaced by its entry. Unresolvable ids are
|
|
377
|
-
* left untouched.
|
|
380
|
+
* left untouched, with a warning naming the entry and the missing target.
|
|
378
381
|
*
|
|
379
382
|
* @param {Map<string, ContentLoaderEntry[]>} contentTypes - All loaded content types
|
|
380
383
|
* @param {ContentSection} section - The `content` section value the types were loaded from
|
|
@@ -403,6 +406,9 @@ export function resolveContentTypeRefs(
|
|
|
403
406
|
}
|
|
404
407
|
const refEntries = contentTypes.get(target);
|
|
405
408
|
if (!refEntries) {
|
|
409
|
+
console.warn(
|
|
410
|
+
`Content relationships: "${name}" field "${field}" references unknown content type "${target}"`,
|
|
411
|
+
);
|
|
406
412
|
continue;
|
|
407
413
|
}
|
|
408
414
|
|
|
@@ -412,11 +418,24 @@ export function resolveContentTypeRefs(
|
|
|
412
418
|
const resolved = refEntries.find((e) => e.id === value);
|
|
413
419
|
if (resolved) {
|
|
414
420
|
entry.data[field] = resolved;
|
|
421
|
+
} else {
|
|
422
|
+
console.warn(
|
|
423
|
+
`Content relationships: "${name}/${entry.id}" field "${field}" references missing "${target}" entry "${value}"`,
|
|
424
|
+
);
|
|
415
425
|
}
|
|
416
426
|
} else if (toMany && Array.isArray(value)) {
|
|
417
|
-
entry.data[field] = value.map((id) =>
|
|
418
|
-
typeof id
|
|
419
|
-
|
|
427
|
+
entry.data[field] = value.map((id) => {
|
|
428
|
+
if (typeof id !== "string") {
|
|
429
|
+
return id;
|
|
430
|
+
}
|
|
431
|
+
const resolved = refEntries.find((e) => e.id === id);
|
|
432
|
+
if (!resolved) {
|
|
433
|
+
console.warn(
|
|
434
|
+
`Content relationships: "${name}/${entry.id}" field "${field}" references missing "${target}" entry "${id}"`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
return resolved ?? id;
|
|
438
|
+
});
|
|
420
439
|
}
|
|
421
440
|
}
|
|
422
441
|
}
|
package/src/highlight.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build-time syntax highlighting for fenced code blocks (node-only).
|
|
3
|
+
*
|
|
4
|
+
* A synchronous Shiki core (JavaScript regex engine — no WASM, no async) tokenizes a fixed
|
|
5
|
+
* grammar set against a light + dark GitHub theme pair. Tokens are emitted as span elements
|
|
6
|
+
* carrying both colors as CSS custom properties (--shiki-light / --shiki-dark); the page's
|
|
7
|
+
* stylesheet decides which one paints, so highlighting follows the color-scheme contract
|
|
8
|
+
* (spec §9.5) for auto and forced schemes alike.
|
|
9
|
+
*
|
|
10
|
+
* Lives outside the browser-safe transpile module: only the compile-time markdown path
|
|
11
|
+
* (processMarkdown in md.ts) pays for the grammars.
|
|
12
|
+
*
|
|
13
|
+
* @docs framework/site/jx-markdown
|
|
14
|
+
* @module @jxsuite/parser/highlight
|
|
15
|
+
* @license MIT
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHighlighterCoreSync } from "shiki/core";
|
|
19
|
+
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
|
|
20
|
+
import githubDark from "@shikijs/themes/github-dark-default";
|
|
21
|
+
import githubLight from "@shikijs/themes/github-light-default";
|
|
22
|
+
import css from "@shikijs/langs/css";
|
|
23
|
+
import html from "@shikijs/langs/html";
|
|
24
|
+
import javascript from "@shikijs/langs/javascript";
|
|
25
|
+
import json from "@shikijs/langs/json";
|
|
26
|
+
import markdown from "@shikijs/langs/markdown";
|
|
27
|
+
import shellscript from "@shikijs/langs/shellscript";
|
|
28
|
+
import typescript from "@shikijs/langs/typescript";
|
|
29
|
+
import yaml from "@shikijs/langs/yaml";
|
|
30
|
+
import type { JxElement } from "@jxsuite/schema/types";
|
|
31
|
+
import type { HighlighterCore } from "shiki/core";
|
|
32
|
+
|
|
33
|
+
const THEMES = { dark: "github-dark-default", light: "github-light-default" } as const;
|
|
34
|
+
|
|
35
|
+
let _highlighter: HighlighterCore | null = null;
|
|
36
|
+
|
|
37
|
+
/** Lazy singleton — grammar/theme setup only runs when a fence is actually highlighted. */
|
|
38
|
+
function getHighlighter(): HighlighterCore {
|
|
39
|
+
_highlighter ??= createHighlighterCoreSync({
|
|
40
|
+
engine: createJavaScriptRegexEngine(),
|
|
41
|
+
langs: [json, typescript, javascript, markdown, html, shellscript, css, yaml],
|
|
42
|
+
themes: [githubLight, githubDark],
|
|
43
|
+
});
|
|
44
|
+
return _highlighter;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Tokenize a fenced code block into span elements with dual-theme color variables. Returns null for
|
|
49
|
+
* unknown languages (callers keep the plain-text fallback).
|
|
50
|
+
*
|
|
51
|
+
* @param {string} code
|
|
52
|
+
* @param {string} lang - Fence info string (grammar name or registered alias)
|
|
53
|
+
* @returns {(JxElement | string)[] | null}
|
|
54
|
+
*/
|
|
55
|
+
export function highlightFence(code: string, lang: string): (JxElement | string)[] | null {
|
|
56
|
+
const h = getHighlighter();
|
|
57
|
+
if (!h.getLoadedLanguages().includes(lang)) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const { tokens } = h.codeToTokens(code, { defaultColor: false, lang, themes: THEMES });
|
|
61
|
+
const out: (JxElement | string)[] = [];
|
|
62
|
+
for (const [i, line] of tokens.entries()) {
|
|
63
|
+
if (i > 0) {
|
|
64
|
+
out.push("\n");
|
|
65
|
+
}
|
|
66
|
+
for (const token of line) {
|
|
67
|
+
const span: JxElement = { tagName: "span", textContent: token.content };
|
|
68
|
+
if (token.htmlStyle && Object.keys(token.htmlStyle).length > 0) {
|
|
69
|
+
span.style = token.htmlStyle as Record<string, string>;
|
|
70
|
+
}
|
|
71
|
+
out.push(span);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Walk a transpiled Jx tree and highlight every `pre > code.language-*` block in place. Unknown
|
|
79
|
+
* languages and bare fences keep their plain textContent.
|
|
80
|
+
*
|
|
81
|
+
* @param {(JxElement | string)[]} nodes
|
|
82
|
+
*/
|
|
83
|
+
export function highlightCodeBlocks(nodes: (JxElement | string)[]): void {
|
|
84
|
+
for (const node of nodes) {
|
|
85
|
+
if (!node || typeof node !== "object") {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (node.tagName === "pre" && Array.isArray(node.children)) {
|
|
89
|
+
for (const child of node.children) {
|
|
90
|
+
if (!child || typeof child !== "object" || child.tagName !== "code") {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const lang = /^language-(\S+)/.exec(child.className ?? "")?.[1];
|
|
94
|
+
const code = child.textContent;
|
|
95
|
+
if (!lang || typeof code !== "string" || code.length === 0) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const spans = highlightFence(code, lang);
|
|
99
|
+
if (!spans) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
delete child.textContent;
|
|
103
|
+
child.children = spans;
|
|
104
|
+
child.className = `${child.className} shiki`;
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (Array.isArray(node.children)) {
|
|
109
|
+
highlightCodeBlocks(node.children as (JxElement | string)[]);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/markdown.ts
CHANGED
|
@@ -26,6 +26,12 @@ export interface MarkdownLoadOptions {
|
|
|
26
26
|
directiveOptions?: unknown;
|
|
27
27
|
/** Enable remark-directive parsing. Implied by directiveOptions. */
|
|
28
28
|
directives?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Resolved content-source root directory. When set, entries in subdirectories get path-based ids
|
|
31
|
+
* (`studio/canvas` for `studio/canvas.md`, `/index` stripped); files directly at the root keep
|
|
32
|
+
* basename ids.
|
|
33
|
+
*/
|
|
34
|
+
sourceRoot?: string;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/**
|
|
@@ -83,6 +89,7 @@ export class Markdown {
|
|
|
83
89
|
...(options.directiveOptions !== undefined && {
|
|
84
90
|
directiveOptions: options.directiveOptions,
|
|
85
91
|
}),
|
|
92
|
+
...(options.sourceRoot !== undefined && { sourceRoot: options.sourceRoot }),
|
|
86
93
|
});
|
|
87
94
|
const _meta: ContentLoaderEntry["_meta"] = {};
|
|
88
95
|
if (result.$excerpt != null) {
|
package/src/md.ts
CHANGED
|
@@ -18,10 +18,11 @@ import remarkParseFrontmatter from "remark-parse-frontmatter";
|
|
|
18
18
|
import remarkGfm from "remark-gfm";
|
|
19
19
|
import remarkDirective from "remark-directive";
|
|
20
20
|
import { readFileSync } from "node:fs";
|
|
21
|
-
import { basename, extname, resolve as resolvePath } from "node:path";
|
|
21
|
+
import { basename, extname, relative, resolve as resolvePath } from "node:path";
|
|
22
22
|
import { globSync } from "glob";
|
|
23
|
-
import { mdastNodeToJx } from "./transpile.ts";
|
|
24
|
-
import
|
|
23
|
+
import { assignHeadingIds, mdastNodeToJx } from "./transpile.ts";
|
|
24
|
+
import { highlightCodeBlocks } from "./highlight.ts";
|
|
25
|
+
import type { MarkdownFileResult, MdastNode, UnifiedProcessor } from "./types.ts";
|
|
25
26
|
import type { JxElement } from "@jxsuite/schema/types";
|
|
26
27
|
|
|
27
28
|
// ─── Tree utilities (inline to avoid Bun ESM resolution issues with unist-util-*) ──
|
|
@@ -92,28 +93,6 @@ function readingTime(text: string) {
|
|
|
92
93
|
return Math.max(1, Math.ceil(words / 200));
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
/**
|
|
96
|
-
* Extract table of contents entries from an mdast tree.
|
|
97
|
-
*
|
|
98
|
-
* @param {object} tree - Mdast AST
|
|
99
|
-
* @returns {{ depth: number; text: string; id: string }[]}
|
|
100
|
-
*/
|
|
101
|
-
function extractToc(tree: MdastNode) {
|
|
102
|
-
/** @type {{ depth: number; text: string; id: string }[]} */
|
|
103
|
-
const entries: TocEntry[] = [];
|
|
104
|
-
visit(tree, "heading", (node: MdastNode) => {
|
|
105
|
-
const text = mdastToString(node);
|
|
106
|
-
const id = text
|
|
107
|
-
.toLowerCase()
|
|
108
|
-
.replaceAll(/[^\w\s-]/g, "")
|
|
109
|
-
.replaceAll(/\s+/g, "-")
|
|
110
|
-
.replaceAll(/-+/g, "-")
|
|
111
|
-
.replaceAll(/^-|-$/g, "");
|
|
112
|
-
entries.push({ depth: node.depth as number, id, text });
|
|
113
|
-
});
|
|
114
|
-
return entries;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
96
|
/**
|
|
118
97
|
* Extract first paragraph as a JX text string from an mdast tree.
|
|
119
98
|
*
|
|
@@ -133,6 +112,30 @@ function extractExcerpt(tree: MdastNode) {
|
|
|
133
112
|
return mdastToString(firstParagraph);
|
|
134
113
|
}
|
|
135
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Derive an entry slug from a file path. Without a source root (or for files directly at the root),
|
|
117
|
+
* the slug is the basename — the historical behavior every flat collection relies on. Files in
|
|
118
|
+
* subdirectories of the root get path-based slugs with POSIX separators and a trailing `/index`
|
|
119
|
+
* stripped, so `studio/canvas.md` and `studio/canvas/index.md` both yield `studio/canvas`.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} filePath - Absolute path to the markdown file
|
|
122
|
+
* @param {string} [sourceRoot] - Resolved content-source root directory
|
|
123
|
+
* @returns {string} The entry slug
|
|
124
|
+
*/
|
|
125
|
+
function deriveSlug(filePath: string, sourceRoot?: string): string {
|
|
126
|
+
if (sourceRoot) {
|
|
127
|
+
const rel = relative(sourceRoot, filePath).split("\\").join("/");
|
|
128
|
+
if (rel && !rel.startsWith("..") && rel.includes("/")) {
|
|
129
|
+
let slug = rel.slice(0, rel.length - extname(rel).length);
|
|
130
|
+
if (slug.endsWith("/index")) {
|
|
131
|
+
slug = slug.slice(0, -"/index".length);
|
|
132
|
+
}
|
|
133
|
+
return slug;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return basename(filePath, extname(filePath));
|
|
137
|
+
}
|
|
138
|
+
|
|
136
139
|
/**
|
|
137
140
|
* Process a single markdown source string into a MarkdownFileResult.
|
|
138
141
|
*
|
|
@@ -143,6 +146,7 @@ function extractExcerpt(tree: MdastNode) {
|
|
|
143
146
|
* @param {object} config - Processing options
|
|
144
147
|
* @param {boolean} [config.directives] - Enable directive support
|
|
145
148
|
* @param {unknown} [config.directiveOptions] - Directive plugin options
|
|
149
|
+
* @param {string} [config.sourceRoot] - Content-source root; files below it get path-based slugs
|
|
146
150
|
* @returns {MarkdownFileResult}
|
|
147
151
|
*/
|
|
148
152
|
export function processMarkdown(
|
|
@@ -151,6 +155,7 @@ export function processMarkdown(
|
|
|
151
155
|
config: {
|
|
152
156
|
directives?: boolean;
|
|
153
157
|
directiveOptions?: unknown;
|
|
158
|
+
sourceRoot?: string;
|
|
154
159
|
} = {},
|
|
155
160
|
) {
|
|
156
161
|
let processor = (unified as unknown as () => UnifiedProcessor)()
|
|
@@ -171,9 +176,8 @@ export function processMarkdown(
|
|
|
171
176
|
const frontmatter = (vfileData.frontmatter ?? {}) as Record<string, unknown>;
|
|
172
177
|
const mdTree = tree as unknown as MdastNode;
|
|
173
178
|
const plainText = mdastToString(mdTree);
|
|
174
|
-
const toc = extractToc(tree);
|
|
175
179
|
const excerpt = extractExcerpt(tree);
|
|
176
|
-
const slug =
|
|
180
|
+
const slug = deriveSlug(filePath, config.sourceRoot);
|
|
177
181
|
|
|
178
182
|
const bodyNodes = tree.children!.filter(
|
|
179
183
|
(n: MdastNode) => n.type !== "yaml" && n.type !== "toml",
|
|
@@ -183,6 +187,13 @@ export function processMarkdown(
|
|
|
183
187
|
| string
|
|
184
188
|
)[];
|
|
185
189
|
|
|
190
|
+
// Build-time syntax highlighting: fence text becomes dual-theme token spans in place.
|
|
191
|
+
highlightCodeBlocks($children);
|
|
192
|
+
|
|
193
|
+
// One walk assigns deduplicated heading ids AND builds $toc, so rendered anchors and the
|
|
194
|
+
// Table of contents agree by construction (specs/parser.md).
|
|
195
|
+
const toc = assignHeadingIds($children);
|
|
196
|
+
|
|
186
197
|
return {
|
|
187
198
|
$children,
|
|
188
199
|
$excerpt: excerpt,
|
package/src/transpile.ts
CHANGED
|
@@ -19,7 +19,13 @@ import remarkGfm from "remark-gfm";
|
|
|
19
19
|
import remarkDirective from "remark-directive";
|
|
20
20
|
import { htmlToJx } from "@jxsuite/markup/html-to-jx";
|
|
21
21
|
import type { MdastNode, UnifiedProcessor } from "./types.ts";
|
|
22
|
-
import type {
|
|
22
|
+
import type {
|
|
23
|
+
JsonValue,
|
|
24
|
+
JxAttributeValue,
|
|
25
|
+
JxDocument,
|
|
26
|
+
JxElement,
|
|
27
|
+
TocEntry,
|
|
28
|
+
} from "@jxsuite/schema/types";
|
|
23
29
|
|
|
24
30
|
export { htmlToJx } from "@jxsuite/markup/html-to-jx";
|
|
25
31
|
|
|
@@ -458,6 +464,16 @@ export function mdastNodeToJx(node: MdastNode) {
|
|
|
458
464
|
|
|
459
465
|
case "table": {
|
|
460
466
|
const rows = convertChildren(node.children ?? []);
|
|
467
|
+
// GFM's first table row is the header row, but mdast does not mark its cells — retag them
|
|
468
|
+
// To <th> so the <thead> carries real column headers (assistive tech relies on this).
|
|
469
|
+
const [headerRow] = rows;
|
|
470
|
+
if (headerRow && typeof headerRow === "object" && Array.isArray(headerRow.children)) {
|
|
471
|
+
for (const cell of headerRow.children) {
|
|
472
|
+
if (cell && typeof cell === "object" && (cell as JxElement).tagName === "td") {
|
|
473
|
+
(cell as JxElement).tagName = "th";
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
461
477
|
const thead = rows.length > 0 ? { children: [rows[0]], tagName: "thead" } : null;
|
|
462
478
|
const tbody = rows.length > 1 ? { children: rows.slice(1), tagName: "tbody" } : null;
|
|
463
479
|
el.children = [thead, tbody].filter(Boolean) as JxElement[];
|
|
@@ -705,3 +721,88 @@ export function transpileJxMarkdown(source: string) {
|
|
|
705
721
|
|
|
706
722
|
return doc;
|
|
707
723
|
}
|
|
724
|
+
|
|
725
|
+
// ─── Heading anchors ─────────────────────────────────────────────────────────
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Slugify a heading's text into an anchor id: lowercase, punctuation stripped, spaces collapsed to
|
|
729
|
+
* single hyphens. The one algorithm behind rendered heading `id`s, `$toc` entries, and search deep
|
|
730
|
+
* links (specs/parser.md).
|
|
731
|
+
*
|
|
732
|
+
* @param {string} text
|
|
733
|
+
* @returns {string}
|
|
734
|
+
*/
|
|
735
|
+
export function slugifyHeading(text: string) {
|
|
736
|
+
return text
|
|
737
|
+
.toLowerCase()
|
|
738
|
+
.replaceAll(/[^\w\s-]/g, "")
|
|
739
|
+
.replaceAll(/\s+/g, "-")
|
|
740
|
+
.replaceAll(/-+/g, "-")
|
|
741
|
+
.replaceAll(/^-|-$/g, "");
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Concatenated text of a Jx node: own textContent plus string/element children, depth-first.
|
|
748
|
+
*
|
|
749
|
+
* @param {JxElement | string} node
|
|
750
|
+
* @returns {string}
|
|
751
|
+
*/
|
|
752
|
+
function jxNodeText(node: JxElement | string): string {
|
|
753
|
+
if (typeof node === "string") {
|
|
754
|
+
return node;
|
|
755
|
+
}
|
|
756
|
+
let out = typeof node.textContent === "string" ? node.textContent : "";
|
|
757
|
+
if (Array.isArray(node.children)) {
|
|
758
|
+
for (const child of node.children) {
|
|
759
|
+
out += jxNodeText(child as JxElement | string);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return out;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Assign deduplicated slug ids to `h1`–`h6` elements (depth-first, document order) and return the
|
|
767
|
+
* matching table-of-contents entries. The first occurrence of a slug is unsuffixed; repeats get
|
|
768
|
+
* `-2`, `-3`, …. Pre-existing ids are respected and still claim their slug. Because ids and entries
|
|
769
|
+
* come from one walk, rendered anchors and `$toc` agree by construction.
|
|
770
|
+
*
|
|
771
|
+
* @param {(JxElement | string)[] | undefined} children - Jx nodes, mutated in place
|
|
772
|
+
* @returns {TocEntry[]}
|
|
773
|
+
*/
|
|
774
|
+
export function assignHeadingIds(children?: (JxElement | string)[]): TocEntry[] {
|
|
775
|
+
const entries: TocEntry[] = [];
|
|
776
|
+
const seen = new Map<string, number>();
|
|
777
|
+
|
|
778
|
+
function walk(nodes: (JxElement | string)[]) {
|
|
779
|
+
for (const node of nodes) {
|
|
780
|
+
if (typeof node !== "object" || node === null) {
|
|
781
|
+
continue;
|
|
782
|
+
}
|
|
783
|
+
const tag = node.tagName;
|
|
784
|
+
if (tag && HEADING_TAGS.has(tag)) {
|
|
785
|
+
const text = jxNodeText(node).trim();
|
|
786
|
+
let id = typeof node.id === "string" && node.id.length > 0 ? node.id : "";
|
|
787
|
+
if (id) {
|
|
788
|
+
seen.set(id, (seen.get(id) ?? 0) + 1);
|
|
789
|
+
} else {
|
|
790
|
+
const base = slugifyHeading(text) || "section";
|
|
791
|
+
const count = seen.get(base) ?? 0;
|
|
792
|
+
id = count === 0 ? base : `${base}-${count + 1}`;
|
|
793
|
+
seen.set(base, count + 1);
|
|
794
|
+
node.id = id;
|
|
795
|
+
}
|
|
796
|
+
entries.push({ depth: Number(tag.slice(1)), id, text });
|
|
797
|
+
}
|
|
798
|
+
if (Array.isArray(node.children)) {
|
|
799
|
+
walk(node.children as (JxElement | string)[]);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (children) {
|
|
805
|
+
walk(children);
|
|
806
|
+
}
|
|
807
|
+
return entries;
|
|
808
|
+
}
|