@jxsuite/parser 1.1.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/dist/markdown.js +10648 -789
- package/dist/serialize.js +991 -579
- package/package.json +7 -4
- package/src/highlight.ts +112 -0
- package/src/md.ts +10 -25
- 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.3.
|
|
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/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/md.ts
CHANGED
|
@@ -20,8 +20,9 @@ import remarkDirective from "remark-directive";
|
|
|
20
20
|
import { readFileSync } from "node:fs";
|
|
21
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
|
*
|
|
@@ -197,7 +176,6 @@ export function processMarkdown(
|
|
|
197
176
|
const frontmatter = (vfileData.frontmatter ?? {}) as Record<string, unknown>;
|
|
198
177
|
const mdTree = tree as unknown as MdastNode;
|
|
199
178
|
const plainText = mdastToString(mdTree);
|
|
200
|
-
const toc = extractToc(tree);
|
|
201
179
|
const excerpt = extractExcerpt(tree);
|
|
202
180
|
const slug = deriveSlug(filePath, config.sourceRoot);
|
|
203
181
|
|
|
@@ -209,6 +187,13 @@ export function processMarkdown(
|
|
|
209
187
|
| string
|
|
210
188
|
)[];
|
|
211
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
|
+
|
|
212
197
|
return {
|
|
213
198
|
$children,
|
|
214
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
|
+
}
|