@sorane/core 0.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.
Files changed (38) hide show
  1. package/package.json +42 -0
  2. package/src/ai-disclosure.ts +181 -0
  3. package/src/asset-provenance.ts +141 -0
  4. package/src/associated-media.ts +93 -0
  5. package/src/blog-pages.ts +175 -0
  6. package/src/build.ts +1109 -0
  7. package/src/c2pa-pass.ts +116 -0
  8. package/src/catalog.ts +61 -0
  9. package/src/config.ts +255 -0
  10. package/src/diagrams/compile-d2.ts +70 -0
  11. package/src/diagrams/compile-graphviz.ts +71 -0
  12. package/src/diagrams/compile-mermaid.ts +102 -0
  13. package/src/diagrams/diagram-hash.ts +5 -0
  14. package/src/diagrams/diagram-meta.ts +74 -0
  15. package/src/diagrams/emit-diagram-assets.ts +135 -0
  16. package/src/diagrams/mermaid-head.ts +6 -0
  17. package/src/diagrams/needs-async-compile.ts +12 -0
  18. package/src/diagrams/parse-diagram-fence.ts +109 -0
  19. package/src/diagrams/rehype-diagram-pre.ts +39 -0
  20. package/src/diagrams/remark-inject-built-figures.ts +32 -0
  21. package/src/diagrams/render-async.ts +241 -0
  22. package/src/diagrams/render-body-section.ts +52 -0
  23. package/src/diagrams/validate-diagram-alt.ts +56 -0
  24. package/src/docs.ts +257 -0
  25. package/src/emit-page.ts +87 -0
  26. package/src/index.ts +49 -0
  27. package/src/iptc-xmp-pass.ts +94 -0
  28. package/src/markdown-image-refs.ts +135 -0
  29. package/src/migrate.ts +60 -0
  30. package/src/not-found.ts +64 -0
  31. package/src/og-meta.ts +18 -0
  32. package/src/render.ts +233 -0
  33. package/src/site-labels.ts +97 -0
  34. package/src/site-meta.ts +138 -0
  35. package/src/ssg.ts +676 -0
  36. package/src/static-assets.ts +198 -0
  37. package/src/theme-assets.ts +16 -0
  38. package/src/validate-heading-structure.ts +51 -0
@@ -0,0 +1,74 @@
1
+ import type { Root } from "mdast";
2
+ import { visit } from "unist-util-visit";
3
+ import type { DiagramsConfig } from "../config.ts";
4
+ import { buildMermaidHead } from "./mermaid-head.ts";
5
+ import { isGraphvizLang } from "./compile-graphviz.ts";
6
+
7
+ export interface DiagramRenderMeta {
8
+ readonly mermaid: number;
9
+ readonly d2: number;
10
+ readonly graphviz: number;
11
+ }
12
+
13
+ export function emptyDiagramMeta(): DiagramRenderMeta {
14
+ return { mermaid: 0, d2: 0, graphviz: 0 };
15
+ }
16
+
17
+ export function mergeDiagramMeta(
18
+ a: DiagramRenderMeta,
19
+ b: DiagramRenderMeta,
20
+ ): DiagramRenderMeta {
21
+ return {
22
+ mermaid: a.mermaid + b.mermaid,
23
+ d2: a.d2 + b.d2,
24
+ graphviz: a.graphviz + b.graphviz,
25
+ };
26
+ }
27
+
28
+ export function countDiagramsForConfig(
29
+ tree: Root,
30
+ config: DiagramsConfig,
31
+ ): DiagramRenderMeta {
32
+ let mermaid = 0;
33
+ let d2 = 0;
34
+ let graphviz = 0;
35
+ if (config.enabled === false) return { mermaid, d2, graphviz };
36
+ visit(tree, "code", (node) => {
37
+ if (node.lang === "mermaid" && resolveMermaidMode(config) !== "off") {
38
+ mermaid += 1;
39
+ } else if (node.lang === "d2" && config.d2?.enabled === true) {
40
+ d2 += 1;
41
+ } else if (isGraphvizLang(node.lang) && config.graphviz?.enabled === true) {
42
+ graphviz += 1;
43
+ }
44
+ });
45
+ return { mermaid, d2, graphviz };
46
+ }
47
+
48
+ export type MermaidRenderMode = "client" | "build" | "off";
49
+
50
+ export function resolveMermaidMode(config: DiagramsConfig): MermaidRenderMode {
51
+ if (config.enabled === false) return "off";
52
+ const mode = config.mermaid?.mode ?? "client";
53
+ if (mode === "off") return "off";
54
+ if (mode === "build") return "build";
55
+ return "client";
56
+ }
57
+
58
+ export function diagramHeadForPage(
59
+ meta: DiagramRenderMeta,
60
+ rootPrefix: string,
61
+ config: DiagramsConfig,
62
+ ): string | undefined {
63
+ if (config.enabled === false) return undefined;
64
+ if (meta.mermaid === 0) return undefined;
65
+ if (resolveMermaidMode(config) !== "client") return undefined;
66
+ return buildMermaidHead(rootPrefix);
67
+ }
68
+
69
+ export function contentNeedsMermaidClient(
70
+ hasMermaidFences: boolean,
71
+ config: DiagramsConfig,
72
+ ): boolean {
73
+ return hasMermaidFences && resolveMermaidMode(config) === "client";
74
+ }
@@ -0,0 +1,135 @@
1
+ import {
2
+ copyFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { dirname, join, relative, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { createRequire } from "node:module";
13
+ import type { DiagramsConfig } from "../config.ts";
14
+ import { resolveThemeAssetDir } from "../theme-assets.ts";
15
+ import { contentNeedsMermaidClient, resolveMermaidMode } from "./diagram-meta.ts";
16
+
17
+ const require = createRequire(import.meta.url);
18
+
19
+ export interface EmitDiagramAssetsOptions {
20
+ readonly cwd: string;
21
+ readonly outDir: string;
22
+ readonly config: DiagramsConfig;
23
+ readonly contentHasMermaid?: boolean;
24
+ readonly onProgress?: (message: string) => void;
25
+ }
26
+
27
+ export interface EmitDiagramAssetsResult {
28
+ readonly copied: boolean;
29
+ readonly bytes: number;
30
+ readonly version?: string;
31
+ }
32
+
33
+ export function substituteMermaidVersion(source: string, version: string): string {
34
+ return source.replace(/\{\{\s*MERMAID_VERSION\s*\}\}/g, version);
35
+ }
36
+
37
+ function resolveMermaidPackageRoot(): string | null {
38
+ try {
39
+ const pkgPath = require.resolve("mermaid/package.json");
40
+ return dirname(pkgPath);
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function listMermaidDistFiles(distDir: string): string[] {
47
+ const out: string[] = [];
48
+ const visit = (dir: string): void => {
49
+ for (const entry of readdirSync(dir)) {
50
+ const p = join(dir, entry);
51
+ const s = statSync(p);
52
+ if (s.isDirectory()) {
53
+ visit(p);
54
+ continue;
55
+ }
56
+ if (entry.endsWith(".map") || entry.endsWith(".d.ts")) continue;
57
+ if (entry.endsWith(".mjs") || entry.endsWith(".js")) out.push(p);
58
+ }
59
+ };
60
+ visit(distDir);
61
+ return out;
62
+ }
63
+
64
+ export function emitDiagramAssets(opts: EmitDiagramAssetsOptions): EmitDiagramAssetsResult {
65
+ const log = opts.onProgress ?? (() => {});
66
+ const config = opts.config;
67
+
68
+ if (config.enabled === false) {
69
+ return { copied: false, bytes: 0 };
70
+ }
71
+ if (resolveMermaidMode(config) === "off") {
72
+ return { copied: false, bytes: 0 };
73
+ }
74
+ if (!contentNeedsMermaidClient(opts.contentHasMermaid === true, config)) {
75
+ log("diagrams: no client mermaid needed; skipping asset copy");
76
+ return { copied: false, bytes: 0 };
77
+ }
78
+
79
+ const mermaidRoot = resolveMermaidPackageRoot();
80
+ if (!mermaidRoot) {
81
+ log("diagrams: mermaid package not found; skipping asset copy");
82
+ return { copied: false, bytes: 0 };
83
+ }
84
+
85
+ const version = JSON.parse(
86
+ readFileSync(join(mermaidRoot, "package.json"), "utf8"),
87
+ ).version as string;
88
+ const distDir = join(mermaidRoot, "dist");
89
+ if (!existsSync(distDir)) {
90
+ log(`diagrams: mermaid dist not found at ${distDir}`);
91
+ return { copied: false, bytes: 0 };
92
+ }
93
+
94
+ const assetsOut = join(opts.outDir, "assets", "diagrams");
95
+ const mermaidOut = join(assetsOut, `mermaid-${version}`);
96
+ mkdirSync(mermaidOut, { recursive: true });
97
+
98
+ let bytes = 0;
99
+ for (const src of listMermaidDistFiles(distDir)) {
100
+ const rel = relative(distDir, src);
101
+ const dest = join(mermaidOut, rel);
102
+ mkdirSync(dirname(dest), { recursive: true });
103
+ copyFileSync(src, dest);
104
+ bytes += statSync(dest).size;
105
+ }
106
+
107
+ const loaderTemplate =
108
+ resolveThemeAssetDir(opts.cwd, "diagrams") ??
109
+ resolve(
110
+ dirname(fileURLToPath(import.meta.url)),
111
+ "../../../../templates/default/assets/diagrams",
112
+ );
113
+ const loaderSrc = join(loaderTemplate, "sorane-mermaid-loader.mjs");
114
+ if (existsSync(loaderSrc)) {
115
+ const loaderBody = substituteMermaidVersion(
116
+ readFileSync(loaderSrc, "utf8"),
117
+ version,
118
+ );
119
+ writeFileSync(join(assetsOut, "sorane-mermaid-loader.mjs"), loaderBody, "utf8");
120
+ bytes += Buffer.byteLength(loaderBody, "utf8");
121
+ }
122
+
123
+ log(`diagrams: copied mermaid ${version} (${(bytes / 1024).toFixed(1)} KB)`);
124
+ return { copied: true, bytes, version };
125
+ }
126
+
127
+ const MERMAID_FENCE_RE = /```mermaid\b/m;
128
+
129
+ export function contentHasMermaidFences(mdFiles: readonly string[]): boolean {
130
+ for (const abs of mdFiles) {
131
+ const text = readFileSync(abs, "utf8");
132
+ if (MERMAID_FENCE_RE.test(text)) return true;
133
+ }
134
+ return false;
135
+ }
@@ -0,0 +1,6 @@
1
+ import { escapeHtml } from "../render.ts";
2
+
3
+ export function buildMermaidHead(rootPrefix: string): string {
4
+ const src = `${rootPrefix}assets/diagrams/sorane-mermaid-loader.mjs`;
5
+ return `<script type="module" src="${escapeHtml(src)}"></script>`;
6
+ }
@@ -0,0 +1,12 @@
1
+ import type { DiagramsConfig } from "../config.ts";
2
+ import { isD2CompileEnabled } from "./compile-d2.ts";
3
+ import { isGraphvizCompileEnabled } from "./compile-graphviz.ts";
4
+ import { isMermaidBuildEnabled } from "./compile-mermaid.ts";
5
+
6
+ export function needsAsyncDiagramCompile(config?: DiagramsConfig): boolean {
7
+ return (
8
+ isD2CompileEnabled(config) ||
9
+ isMermaidBuildEnabled(config) ||
10
+ isGraphvizCompileEnabled(config)
11
+ );
12
+ }
@@ -0,0 +1,109 @@
1
+ import type { Code, Root } from "mdast";
2
+ import type { Plugin } from "unified";
3
+ import { visit } from "unist-util-visit";
4
+ import type { DiagramsConfig } from "../config.ts";
5
+ import { isGraphvizLang } from "./compile-graphviz.ts";
6
+
7
+ export type MermaidKind =
8
+ | "flowchart"
9
+ | "sequenceDiagram"
10
+ | "stateDiagram"
11
+ | "erDiagram"
12
+ | "gantt"
13
+ | "pie"
14
+ | "classDiagram";
15
+
16
+ export type MermaidKindOrUnsupported = MermaidKind | "unsupported";
17
+
18
+ const ALT_DOUBLE = /\balt="((?:[^"\\]|\\.)*)"/;
19
+ const ALT_SINGLE = /\balt='((?:[^'\\]|\\.)*)'/;
20
+
21
+ export function parseInfoString(meta: string | null | undefined): { alt?: string } {
22
+ if (meta === null || meta === undefined) return {};
23
+ const m = ALT_DOUBLE.exec(meta) ?? ALT_SINGLE.exec(meta);
24
+ if (m === null) return {};
25
+ const value = m[1] ?? "";
26
+ if (value.length === 0) return {};
27
+ return { alt: value };
28
+ }
29
+
30
+ const ALT_COMMENT = /^\s*%%\s*alt\s*:\s*(.+?)\s*$/m;
31
+
32
+ export function parseAltComment(source: string): string | undefined {
33
+ const m = ALT_COMMENT.exec(source);
34
+ if (m === null) return undefined;
35
+ const value = m[1]!.trim();
36
+ return value.length > 0 ? value : undefined;
37
+ }
38
+
39
+ export function detectDiagramKind(source: string): MermaidKindOrUnsupported {
40
+ for (const rawLine of source.split("\n")) {
41
+ const line = rawLine.trim();
42
+ if (line.length === 0) continue;
43
+ if (line.startsWith("%%")) continue;
44
+ if (line.startsWith("flowchart") || line.startsWith("graph")) return "flowchart";
45
+ if (line.startsWith("sequenceDiagram")) return "sequenceDiagram";
46
+ if (line.startsWith("stateDiagram")) return "stateDiagram";
47
+ if (line.startsWith("erDiagram")) return "erDiagram";
48
+ if (line.startsWith("gantt")) return "gantt";
49
+ if (line.startsWith("pie")) return "pie";
50
+ if (line.startsWith("classDiagram")) return "classDiagram";
51
+ return "unsupported";
52
+ }
53
+ return "unsupported";
54
+ }
55
+
56
+ export function extractAltText(
57
+ meta: string | null | undefined,
58
+ source: string,
59
+ ): string | undefined {
60
+ return parseInfoString(meta).alt ?? parseAltComment(source);
61
+ }
62
+
63
+ export interface SoraneDiagramMeta {
64
+ readonly lang: "mermaid" | "d2" | "graphviz";
65
+ readonly altText?: string;
66
+ readonly kind?: MermaidKindOrUnsupported | "d2" | "graphviz";
67
+ }
68
+
69
+ function annotateDiagramCode(
70
+ node: Code,
71
+ lang: "mermaid" | "d2" | "graphviz",
72
+ ): void {
73
+ const altText = extractAltText(node.meta, node.value);
74
+ const kind =
75
+ lang === "mermaid"
76
+ ? detectDiagramKind(node.value)
77
+ : lang === "d2"
78
+ ? "d2"
79
+ : "graphviz";
80
+ const data = (node.data ?? {}) as Record<string, unknown>;
81
+ node.data = {
82
+ ...data,
83
+ hProperties: { dataSoraneAlt: altText ?? "" },
84
+ soraneDiagram: { lang, altText, kind } satisfies SoraneDiagramMeta,
85
+ } as Code["data"];
86
+ }
87
+
88
+ /** remark プラグイン: mermaid / d2 フェンスに alt と kind メタデータを付与する。 */
89
+ export function remarkDiagramFences(config: DiagramsConfig): Plugin<[], Root> {
90
+ return () => (tree: Root) => {
91
+ if (config.enabled === false) return;
92
+ visit(tree, "code", (node) => {
93
+ if (node.lang === "mermaid") {
94
+ if (config.mermaid?.mode === "off") return;
95
+ annotateDiagramCode(node, "mermaid");
96
+ return;
97
+ }
98
+ if (node.lang === "d2") {
99
+ if (config.d2?.enabled !== true) return;
100
+ annotateDiagramCode(node, "d2");
101
+ return;
102
+ }
103
+ if (isGraphvizLang(node.lang)) {
104
+ if (config.graphviz?.enabled !== true) return;
105
+ annotateDiagramCode(node, "graphviz");
106
+ }
107
+ });
108
+ };
109
+ }
@@ -0,0 +1,39 @@
1
+ import type { Root as HastRoot, Element } from "hast";
2
+ import { visit } from "unist-util-visit";
3
+
4
+ function classNames(value: unknown): string[] {
5
+ if (typeof value === "string") return value.split(/\s+/).filter(Boolean);
6
+ if (Array.isArray(value)) {
7
+ return value.flatMap((v) => (typeof v === "string" ? v.split(/\s+/) : []));
8
+ }
9
+ return [];
10
+ }
11
+
12
+ function isDiagramCode(node: Element): boolean {
13
+ const cls = classNames(node.properties?.className);
14
+ return (
15
+ cls.includes("language-mermaid") ||
16
+ cls.includes("language-d2") ||
17
+ cls.includes("language-graphviz") ||
18
+ cls.includes("language-dot")
19
+ );
20
+ }
21
+
22
+ /** `pre > code.language-mermaid|d2` の alt を親 `pre` の data-sorane-alt へ移す。 */
23
+ export function rehypeDiagramPre() {
24
+ return (tree: HastRoot) => {
25
+ visit(tree, "element", (node) => {
26
+ if (node.tagName !== "pre") return;
27
+ const code = node.children.find(
28
+ (c): c is Element => c.type === "element" && c.tagName === "code",
29
+ );
30
+ if (!code || !isDiagramCode(code)) return;
31
+ const alt = code.properties?.dataSoraneAlt;
32
+ if (typeof alt === "string" && alt.length > 0) {
33
+ node.properties ??= {};
34
+ node.properties.dataSoraneAlt = alt;
35
+ delete code.properties?.dataSoraneAlt;
36
+ }
37
+ });
38
+ };
39
+ }
@@ -0,0 +1,32 @@
1
+ import type { Code, Root } from "mdast";
2
+ import type { Plugin } from "unified";
3
+ import { visit } from "unist-util-visit";
4
+ import { escapeHtml } from "../render.ts";
5
+
6
+ export type BuiltFigureVariant = "d2" | "mermaid" | "graphviz";
7
+
8
+ export interface InjectedBuiltFigure {
9
+ readonly src: string;
10
+ readonly alt: string;
11
+ readonly variant: BuiltFigureVariant;
12
+ }
13
+
14
+ /** ビルド時コンパイル済みフェンスを raw HTML figure に差し替える。 */
15
+ export function remarkInjectBuiltFigures(
16
+ figures: ReadonlyMap<Code, InjectedBuiltFigure>,
17
+ ): Plugin<[], Root> {
18
+ return () => (tree: Root) => {
19
+ visit(tree, "code", (node, index, parent) => {
20
+ if (parent === undefined || index === undefined) return;
21
+ const fig = figures.get(node);
22
+ if (!fig) return;
23
+ parent.children[index] = {
24
+ type: "html",
25
+ value:
26
+ `<figure class="diagram diagram--${fig.variant}" role="img" aria-label="${escapeHtml(fig.alt)}">` +
27
+ `<img src="${escapeHtml(fig.src)}" alt="${escapeHtml(fig.alt)}" loading="lazy" decoding="async" />` +
28
+ `</figure>`,
29
+ };
30
+ });
31
+ };
32
+ }
@@ -0,0 +1,241 @@
1
+ import type { Code, Root } from "mdast";
2
+ import type { DiagramsConfig } from "../config.ts";
3
+ import { DEFAULT_DIAGRAMS_CONFIG } from "../config.ts";
4
+ import {
5
+ countDiagramsForConfig,
6
+ type DiagramRenderMeta,
7
+ } from "./diagram-meta.ts";
8
+ import {
9
+ compileD2ToSvg,
10
+ isD2CompileEnabled,
11
+ resolveD2Binary,
12
+ } from "./compile-d2.ts";
13
+ import {
14
+ compileGraphvizToSvg,
15
+ isGraphvizCompileEnabled,
16
+ isGraphvizLang,
17
+ resolveGraphvizBinary,
18
+ } from "./compile-graphviz.ts";
19
+ import {
20
+ compileMermaidToSvg,
21
+ isMermaidBuildEnabled,
22
+ resolveMmdcBinary,
23
+ } from "./compile-mermaid.ts";
24
+ import { extractAltText } from "./parse-diagram-fence.ts";
25
+ import {
26
+ remarkInjectBuiltFigures,
27
+ type InjectedBuiltFigure,
28
+ } from "./remark-inject-built-figures.ts";
29
+ import { remarkDiagramFences } from "./parse-diagram-fence.ts";
30
+ import { rehypeDiagramPre } from "./rehype-diagram-pre.ts";
31
+ import type {
32
+ RenderOptions,
33
+ RenderedMarkdown,
34
+ TocEntry,
35
+ } from "../render.ts";
36
+ import { rewriteLinks, sanitizeSchema } from "../render.ts";
37
+ import rehypeAutolinkHeadings from "rehype-autolink-headings";
38
+ import rehypeRaw from "rehype-raw";
39
+ import rehypeSanitize from "rehype-sanitize";
40
+ import rehypeStringify from "rehype-stringify";
41
+ import remarkGfm from "remark-gfm";
42
+ import remarkParse from "remark-parse";
43
+ import remarkRehype from "remark-rehype";
44
+ import { unified } from "unified";
45
+ import { SlugLedger } from "@sorane/search";
46
+ import type { Root as HastRoot } from "hast";
47
+ import { visit } from "unist-util-visit";
48
+
49
+ function hastToPlainText(node: {
50
+ type?: string;
51
+ value?: string;
52
+ children?: unknown[];
53
+ }): string {
54
+ if (node.type === "text" && typeof node.value === "string") return node.value;
55
+ if (!node.children) return "";
56
+ return node.children
57
+ .map((child) =>
58
+ hastToPlainText(child as { type?: string; value?: string; children?: unknown[] }),
59
+ )
60
+ .join("");
61
+ }
62
+
63
+ function rehypeHeadingIds() {
64
+ const ledger = new SlugLedger();
65
+ return (tree: HastRoot) => {
66
+ visit(tree, "element", (node) => {
67
+ const m = /^h([1-6])$/.exec(node.tagName);
68
+ if (!m) return;
69
+ const text = hastToPlainText(node).trim();
70
+ if (!text) return;
71
+ node.properties ??= {};
72
+ node.properties.id = ledger.next(text);
73
+ });
74
+ };
75
+ }
76
+
77
+ function rehypeCollectOutline(outline: TocEntry[]) {
78
+ return () => (tree: HastRoot) => {
79
+ visit(tree, "element", (node) => {
80
+ const m = /^h([2-4])$/.exec(node.tagName);
81
+ if (!m) return;
82
+ const id = node.properties?.id;
83
+ if (typeof id !== "string" || id.length === 0) return;
84
+ const text = hastToPlainText(node)
85
+ .replace(/\s*#\s*$/, "")
86
+ .trim();
87
+ if (!text) return;
88
+ outline.push({ depth: Number(m[1]), id, text });
89
+ });
90
+ };
91
+ }
92
+
93
+ export interface AsyncRenderOptions extends RenderOptions {
94
+ readonly rootPrefix?: string;
95
+ readonly d2OutDir?: string;
96
+ readonly mermaidOutDir?: string;
97
+ readonly graphvizOutDir?: string;
98
+ readonly onDiagramWarning?: (message: string) => void;
99
+ }
100
+
101
+ async function compileBuiltFigures(
102
+ tree: Root,
103
+ diagramConfig: DiagramsConfig,
104
+ opts: AsyncRenderOptions,
105
+ ): Promise<ReadonlyMap<Code, InjectedBuiltFigure>> {
106
+ const figures = new Map<Code, InjectedBuiltFigure>();
107
+ const rootPrefix = opts.rootPrefix ?? "./";
108
+ const warn = opts.onDiagramWarning;
109
+
110
+ if (isD2CompileEnabled(diagramConfig) && opts.d2OutDir) {
111
+ const binary = resolveD2Binary(diagramConfig);
112
+ const d2Nodes: Code[] = [];
113
+ visit(tree, "code", (node) => {
114
+ if (node.lang === "d2") d2Nodes.push(node);
115
+ });
116
+ for (const node of d2Nodes) {
117
+ const alt = extractAltText(node.meta, node.value) ?? "Diagram";
118
+ const result = await compileD2ToSvg({
119
+ source: node.value,
120
+ binary,
121
+ outDir: opts.d2OutDir,
122
+ });
123
+ if (!result.ok) {
124
+ warn?.(
125
+ `diagrams: d2 compile failed (${result.hash.slice(0, 8)}…): ${result.warning ?? "unknown error"}`,
126
+ );
127
+ continue;
128
+ }
129
+ figures.set(node, {
130
+ src: `${rootPrefix}assets/diagrams/d2/${result.svgFileName}`,
131
+ alt,
132
+ variant: "d2",
133
+ });
134
+ }
135
+ }
136
+
137
+ if (isMermaidBuildEnabled(diagramConfig) && opts.mermaidOutDir) {
138
+ const binary = resolveMmdcBinary(diagramConfig);
139
+ const mermaidNodes: Code[] = [];
140
+ visit(tree, "code", (node) => {
141
+ if (node.lang === "mermaid") mermaidNodes.push(node);
142
+ });
143
+ for (const node of mermaidNodes) {
144
+ const alt = extractAltText(node.meta, node.value) ?? "Diagram";
145
+ const result = await compileMermaidToSvg({
146
+ source: node.value,
147
+ binary,
148
+ outDir: opts.mermaidOutDir,
149
+ });
150
+ if (!result.ok) {
151
+ warn?.(
152
+ `diagrams: mermaid build failed (${result.hash.slice(0, 8)}…): ${result.warning ?? "unknown error"}`,
153
+ );
154
+ continue;
155
+ }
156
+ figures.set(node, {
157
+ src: `${rootPrefix}assets/diagrams/mermaid/${result.svgFileName}`,
158
+ alt,
159
+ variant: "mermaid",
160
+ });
161
+ }
162
+ }
163
+
164
+ if (isGraphvizCompileEnabled(diagramConfig) && opts.graphvizOutDir) {
165
+ const binary = resolveGraphvizBinary(diagramConfig);
166
+ const gvNodes: Code[] = [];
167
+ visit(tree, "code", (node) => {
168
+ if (isGraphvizLang(node.lang)) gvNodes.push(node);
169
+ });
170
+ for (const node of gvNodes) {
171
+ const alt = extractAltText(node.meta, node.value) ?? "Diagram";
172
+ const result = await compileGraphvizToSvg({
173
+ source: node.value,
174
+ binary,
175
+ outDir: opts.graphvizOutDir,
176
+ });
177
+ if (!result.ok) {
178
+ warn?.(
179
+ `diagrams: graphviz compile failed (${result.hash.slice(0, 8)}…): ${result.warning ?? "unknown error"}`,
180
+ );
181
+ continue;
182
+ }
183
+ figures.set(node, {
184
+ src: `${rootPrefix}assets/diagrams/graphviz/${result.svgFileName}`,
185
+ alt,
186
+ variant: "graphviz",
187
+ });
188
+ }
189
+ }
190
+
191
+ return figures;
192
+ }
193
+
194
+ /** Markdown 本文を非同期で変換する(ビルド時コンパイルバックエンド用)。 */
195
+ export async function renderMarkdownDocumentAsync(
196
+ markdown: string,
197
+ opts?: AsyncRenderOptions,
198
+ ): Promise<RenderedMarkdown> {
199
+ const diagramConfig = opts?.diagrams ?? DEFAULT_DIAGRAMS_CONFIG;
200
+ const outline: TocEntry[] = [];
201
+ const diagrams: DiagramRenderMeta = { mermaid: 0, d2: 0, graphviz: 0 };
202
+
203
+ const tree = unified()
204
+ .use(remarkParse)
205
+ .use(remarkGfm)
206
+ .use(remarkDiagramFences(diagramConfig))
207
+ .parse(rewriteLinks(markdown)) as Root;
208
+
209
+ Object.assign(diagrams, countDiagramsForConfig(tree, diagramConfig));
210
+ const builtFigures = await compileBuiltFigures(tree, diagramConfig, opts ?? {});
211
+
212
+ const processor = unified()
213
+ .use(remarkInjectBuiltFigures(builtFigures))
214
+ .use(remarkRehype, { allowDangerousHtml: true })
215
+ .use(rehypeRaw)
216
+ .use(rehypeDiagramPre)
217
+ .use(rehypeHeadingIds)
218
+ .use(rehypeAutolinkHeadings, {
219
+ behavior: "append",
220
+ properties: {
221
+ className: ["heading-anchor"],
222
+ ariaHidden: "true",
223
+ tabIndex: -1,
224
+ },
225
+ content: { type: "text", value: "#" },
226
+ })
227
+ .use(rehypeCollectOutline(outline))
228
+ .use(rehypeSanitize, sanitizeSchema)
229
+ .use(rehypeStringify);
230
+
231
+ const html = processor
232
+ .stringify(processor.runSync(tree))
233
+ .replace(/\r\n?/g, "\n")
234
+ .trimEnd();
235
+
236
+ return {
237
+ html: html.length > 0 ? `${html}\n` : "",
238
+ outline,
239
+ diagrams,
240
+ };
241
+ }