@dooc-dev/dooc 0.0.0-stage → 0.0.2
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 +26 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +311 -0
- package/dist/code.global.js +285 -0
- package/dist/compiler/bundle-urls.d.ts +3 -0
- package/dist/compiler/bundle-urls.js +294 -0
- package/dist/compiler/bundle.d.ts +2 -0
- package/dist/compiler/bundle.js +100 -0
- package/dist/compiler/check-prose.d.ts +11 -0
- package/dist/compiler/check-prose.js +138 -0
- package/dist/compiler/check-types.d.ts +44 -0
- package/dist/compiler/check-types.js +1 -0
- package/dist/compiler/check.d.ts +5 -0
- package/dist/compiler/check.js +450 -0
- package/dist/compiler/code-blocks.d.ts +17 -0
- package/dist/compiler/code-blocks.js +198 -0
- package/dist/compiler/code-languages.d.ts +10 -0
- package/dist/compiler/code-languages.js +109 -0
- package/dist/compiler/compile.d.ts +95 -0
- package/dist/compiler/compile.js +410 -0
- package/dist/compiler/config.d.ts +7 -0
- package/dist/compiler/config.js +52 -0
- package/dist/compiler/dialect.d.ts +24 -0
- package/dist/compiler/dialect.js +198 -0
- package/dist/compiler/drawio.d.ts +26 -0
- package/dist/compiler/drawio.js +176 -0
- package/dist/compiler/figures.d.ts +14 -0
- package/dist/compiler/figures.js +221 -0
- package/dist/compiler/file-cache.d.ts +14 -0
- package/dist/compiler/file-cache.js +61 -0
- package/dist/compiler/files.d.ts +29 -0
- package/dist/compiler/files.js +431 -0
- package/dist/compiler/fonts.d.ts +10 -0
- package/dist/compiler/fonts.js +54 -0
- package/dist/compiler/format.d.ts +1 -0
- package/dist/compiler/format.js +42 -0
- package/dist/compiler/from-mdast.d.ts +16 -0
- package/dist/compiler/from-mdast.js +142 -0
- package/dist/compiler/icons.d.ts +14 -0
- package/dist/compiler/icons.js +85 -0
- package/dist/compiler/language-codes.d.ts +7 -0
- package/dist/compiler/language-codes.js +17 -0
- package/dist/compiler/locales.d.ts +7 -0
- package/dist/compiler/locales.js +67 -0
- package/dist/compiler/manifest.d.ts +6 -0
- package/dist/compiler/manifest.js +30 -0
- package/dist/compiler/ops.d.ts +44 -0
- package/dist/compiler/ops.js +362 -0
- package/dist/compiler/public-file.d.ts +2 -0
- package/dist/compiler/public-file.js +93 -0
- package/dist/compiler/serialize.d.ts +3 -0
- package/dist/compiler/serialize.js +16 -0
- package/dist/compiler/terms.d.ts +9 -0
- package/dist/compiler/terms.js +44 -0
- package/dist/compiler/textfile-remote.d.ts +12 -0
- package/dist/compiler/textfile-remote.js +124 -0
- package/dist/compiler/textfile.d.ts +9 -0
- package/dist/compiler/textfile.js +113 -0
- package/dist/compiler/theme-variants.d.ts +4 -0
- package/dist/compiler/theme-variants.js +22 -0
- package/dist/compiler/zip.d.ts +6 -0
- package/dist/compiler/zip.js +48 -0
- package/dist/core/blocks.d.ts +99 -0
- package/dist/core/blocks.js +29 -0
- package/dist/core/code-file.d.ts +12 -0
- package/dist/core/code-file.js +68 -0
- package/dist/core/content-metadata.d.ts +48 -0
- package/dist/core/content-metadata.js +264 -0
- package/dist/core/content-types.d.ts +45 -0
- package/dist/core/content-types.js +1 -0
- package/dist/core/extension-package.d.ts +34 -0
- package/dist/core/extension-package.js +147 -0
- package/dist/core/figure-frame.d.ts +12 -0
- package/dist/core/figure-frame.js +108 -0
- package/dist/core/file-embed.d.ts +13 -0
- package/dist/core/file-embed.js +43 -0
- package/dist/core/files.d.ts +15 -0
- package/dist/core/files.js +59 -0
- package/dist/core/flow-base-css.generated.d.ts +1 -0
- package/dist/core/flow-base-css.generated.js +4 -0
- package/dist/core/flow-css.d.ts +9 -0
- package/dist/core/flow-css.js +54 -0
- package/dist/core/graph-common.d.ts +32 -0
- package/dist/core/graph-common.js +113 -0
- package/dist/core/graph.d.ts +117 -0
- package/dist/core/graph.js +480 -0
- package/dist/core/html.d.ts +29 -0
- package/dist/core/html.js +444 -0
- package/dist/core/icons.d.ts +2 -0
- package/dist/core/icons.js +2 -0
- package/dist/core/inline.d.ts +27 -0
- package/dist/core/inline.js +42 -0
- package/dist/core/language.d.ts +41 -0
- package/dist/core/language.js +44 -0
- package/dist/core/languages/ja.d.ts +2 -0
- package/dist/core/languages/ja.js +5 -0
- package/dist/core/markdown.d.ts +27 -0
- package/dist/core/markdown.js +154 -0
- package/dist/core/previewers.d.ts +37 -0
- package/dist/core/previewers.js +103 -0
- package/dist/core/routing.d.ts +20 -0
- package/dist/core/routing.js +64 -0
- package/dist/core/schema-comments.d.ts +14 -0
- package/dist/core/schema-comments.js +52 -0
- package/dist/core/schema-graph-flow.d.ts +9 -0
- package/dist/core/schema-graph-flow.js +379 -0
- package/dist/core/schema-graph-mount.d.ts +9 -0
- package/dist/core/schema-graph-mount.js +105 -0
- package/dist/core/schema-graph.d.ts +254 -0
- package/dist/core/schema-graph.js +981 -0
- package/dist/core/table-ui.d.ts +2 -0
- package/dist/core/table-ui.js +180 -0
- package/dist/core/taxonomy.d.ts +12 -0
- package/dist/core/taxonomy.js +49 -0
- package/dist/core/term-reference.d.ts +5 -0
- package/dist/core/term-reference.js +19 -0
- package/dist/core/terms-ui.d.ts +6 -0
- package/dist/core/terms-ui.js +22 -0
- package/dist/core/textfile.d.ts +10 -0
- package/dist/core/textfile.js +39 -0
- package/dist/core/theme-variants.d.ts +3 -0
- package/dist/core/theme-variants.js +37 -0
- package/dist/core/toc-ui.d.ts +2 -0
- package/dist/core/toc-ui.js +161 -0
- package/dist/core/video-ui.d.ts +2 -0
- package/dist/core/video-ui.js +253 -0
- package/dist/core/workspace.d.ts +15 -0
- package/dist/core/workspace.js +28 -0
- package/dist/edit.global.js +306 -0
- package/dist/flow.global.js +4959 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +31 -0
- package/dist/mcp.d.ts +24 -0
- package/dist/mcp.js +467 -0
- package/dist/plugins/actions.d.ts +45 -0
- package/dist/plugins/actions.js +125 -0
- package/dist/plugins/blocks.d.ts +93 -0
- package/dist/plugins/blocks.js +100 -0
- package/dist/plugins/index.d.ts +4 -0
- package/dist/plugins/index.js +1 -0
- package/dist/plugins/local-storage.d.ts +18 -0
- package/dist/plugins/local-storage.js +115 -0
- package/dist/plugins/oauth.d.ts +14 -0
- package/dist/plugins/oauth.js +170 -0
- package/dist/plugins/runtime.d.ts +26 -0
- package/dist/plugins/runtime.js +395 -0
- package/dist/plugins/types.d.ts +59 -0
- package/dist/plugins/types.js +1 -0
- package/dist/plugins/view.d.ts +7 -0
- package/dist/plugins/view.js +21 -0
- package/dist/ui.global.js +78 -0
- package/dist/viewer/assets.d.ts +2 -0
- package/dist/viewer/assets.js +3 -0
- package/dist/viewer/build.d.ts +12 -0
- package/dist/viewer/build.js +55 -0
- package/dist/viewer/bundle-network.d.ts +2 -0
- package/dist/viewer/bundle-network.js +88 -0
- package/dist/viewer/code-file.d.ts +20 -0
- package/dist/viewer/code-file.js +42 -0
- package/dist/viewer/editor-tools.d.ts +38 -0
- package/dist/viewer/editor-tools.js +211 -0
- package/dist/viewer/extension-pack.d.ts +2 -0
- package/dist/viewer/extension-pack.js +36 -0
- package/dist/viewer/extensions.d.ts +10 -0
- package/dist/viewer/extensions.js +302 -0
- package/dist/viewer/file-references.d.ts +14 -0
- package/dist/viewer/file-references.js +38 -0
- package/dist/viewer/font-picker.d.ts +4 -0
- package/dist/viewer/font-picker.js +145 -0
- package/dist/viewer/font-settings.d.ts +11 -0
- package/dist/viewer/font-settings.js +50 -0
- package/dist/viewer/fonts/LICENSE.txt +119 -0
- package/dist/viewer/fonts/udev-gothic-regular.woff2 +0 -0
- package/dist/viewer/fonts.d.ts +2 -0
- package/dist/viewer/fonts.js +13 -0
- package/dist/viewer/git-diff.d.ts +3 -0
- package/dist/viewer/git-diff.js +33 -0
- package/dist/viewer/git-types.d.ts +73 -0
- package/dist/viewer/git-types.js +1 -0
- package/dist/viewer/git.d.ts +5 -0
- package/dist/viewer/git.js +337 -0
- package/dist/viewer/hmr.d.ts +53 -0
- package/dist/viewer/hmr.js +337 -0
- package/dist/viewer/i18n.d.ts +6 -0
- package/dist/viewer/i18n.js +65 -0
- package/dist/viewer/ja-lint.d.ts +3 -0
- package/dist/viewer/ja-lint.js +44 -0
- package/dist/viewer/language-picker.d.ts +2 -0
- package/dist/viewer/language-picker.js +233 -0
- package/dist/viewer/languages.d.ts +11 -0
- package/dist/viewer/languages.js +20 -0
- package/dist/viewer/locales/eng.json +317 -0
- package/dist/viewer/locales/jpn.json +317 -0
- package/dist/viewer/locales/zho.json +317 -0
- package/dist/viewer/nav-focus.d.ts +2 -0
- package/dist/viewer/nav-focus.js +64 -0
- package/dist/viewer/plugin-blocks.d.ts +3 -0
- package/dist/viewer/plugin-blocks.js +115 -0
- package/dist/viewer/plugin-config.d.ts +35 -0
- package/dist/viewer/plugin-config.js +57 -0
- package/dist/viewer/plugin-view.d.ts +2 -0
- package/dist/viewer/plugin-view.js +6 -0
- package/dist/viewer/plugins.d.ts +12 -0
- package/dist/viewer/plugins.js +267 -0
- package/dist/viewer/resize.d.ts +4 -0
- package/dist/viewer/resize.js +238 -0
- package/dist/viewer/resource-library.d.ts +29 -0
- package/dist/viewer/resource-library.js +104 -0
- package/dist/viewer/search.d.ts +16 -0
- package/dist/viewer/search.js +358 -0
- package/dist/viewer/serve.d.ts +210 -0
- package/dist/viewer/serve.js +3312 -0
- package/dist/viewer/site-icon.d.ts +8 -0
- package/dist/viewer/site-icon.js +56 -0
- package/dist/viewer/textfile.d.ts +9 -0
- package/dist/viewer/textfile.js +50 -0
- package/dist/viewer/theme-picker.d.ts +2 -0
- package/dist/viewer/theme-picker.js +135 -0
- package/dist/viewer/workspace.d.ts +4 -0
- package/dist/viewer/workspace.js +47 -0
- package/package.json +128 -5
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Root } from "mdast";
|
|
2
|
+
import type { TermEntry } from "../core/content-types.js";
|
|
3
|
+
import type { PreviewSettings } from "../core/previewers.js";
|
|
4
|
+
import { type Frontmatter } from "../core/taxonomy.js";
|
|
5
|
+
export { parseMdast } from "../core/markdown.js";
|
|
6
|
+
export interface ParsedDoc {
|
|
7
|
+
terms: TermEntry[];
|
|
8
|
+
frontmatter: Frontmatter | undefined;
|
|
9
|
+
/** Body text without the front-matter (becomes the manifest's body). */
|
|
10
|
+
body: string;
|
|
11
|
+
/** Source passed to parse; root position offsets use it. */
|
|
12
|
+
source: string;
|
|
13
|
+
root: Root;
|
|
14
|
+
errors: string[];
|
|
15
|
+
}
|
|
16
|
+
export declare const parseDoc: (input: string, settings?: PreviewSettings, file?: string) => ParsedDoc;
|
|
17
|
+
/**
|
|
18
|
+
* Collect files referenced by Markdown links and embeds for validation and export.
|
|
19
|
+
*/
|
|
20
|
+
export declare const collectRefs: (root: Root, settings?: PreviewSettings) => {
|
|
21
|
+
figures: string[];
|
|
22
|
+
terms: string[];
|
|
23
|
+
textfiles: string[];
|
|
24
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { visit } from "unist-util-visit";
|
|
2
|
+
import { parse as parseYaml } from "yaml";
|
|
3
|
+
import { parseFigureLink, parseFileEmbed } from "../core/file-embed.js";
|
|
4
|
+
import { isLangTag } from "../core/language.js";
|
|
5
|
+
import { contentLinks, embeddedImages, parseMdast } from "../core/markdown.js";
|
|
6
|
+
import { parseDocLink } from "../core/routing.js";
|
|
7
|
+
import { FM_OPTIONAL, FM_REQUIRED } from "../core/taxonomy.js";
|
|
8
|
+
import { parseTermReference } from "../core/term-reference.js";
|
|
9
|
+
import { parseTextfileRange } from "../core/textfile.js";
|
|
10
|
+
import { parseContentCode } from "./code-blocks.js";
|
|
11
|
+
export { parseMdast } from "../core/markdown.js";
|
|
12
|
+
// Unsupported nodes are reported before conversion so source content is never silently dropped.
|
|
13
|
+
const ALLOWED_TYPES = new Set([
|
|
14
|
+
"root",
|
|
15
|
+
"yaml",
|
|
16
|
+
"paragraph",
|
|
17
|
+
"heading",
|
|
18
|
+
"list",
|
|
19
|
+
"listItem",
|
|
20
|
+
"code",
|
|
21
|
+
"inlineCode",
|
|
22
|
+
"table",
|
|
23
|
+
"tableRow",
|
|
24
|
+
"tableCell",
|
|
25
|
+
"text",
|
|
26
|
+
"link",
|
|
27
|
+
"image",
|
|
28
|
+
"strong",
|
|
29
|
+
"emphasis",
|
|
30
|
+
"blockquote",
|
|
31
|
+
]);
|
|
32
|
+
const line = (n) => (n.position ? `L${n.position.start.line}` : "L?");
|
|
33
|
+
const checkFrontmatter = (raw, errors) => {
|
|
34
|
+
if (typeof raw !== "object" || raw == null || Array.isArray(raw)) {
|
|
35
|
+
errors.push("front-matter: a YAML object is required");
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
const fm = raw;
|
|
39
|
+
const required = FM_REQUIRED;
|
|
40
|
+
const allowed = [...FM_REQUIRED, ...FM_OPTIONAL];
|
|
41
|
+
for (const key of Object.keys(fm)) {
|
|
42
|
+
if (!allowed.includes(key)) {
|
|
43
|
+
errors.push(`front-matter: unknown key "${key}" (allowed: ${allowed.join(", ")})`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
for (const key of required) {
|
|
47
|
+
if (typeof fm[key] !== "string" || fm[key] === "") {
|
|
48
|
+
errors.push(`front-matter: "${key}" (string) is required`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (fm.lang !== undefined && !(typeof fm.lang === "string" && isLangTag(fm.lang))) {
|
|
52
|
+
errors.push('front-matter: "lang" must be a BCP 47 language tag (e.g. "en", "ja", "pt-BR")');
|
|
53
|
+
}
|
|
54
|
+
return errors.length === 0 ? fm : undefined;
|
|
55
|
+
};
|
|
56
|
+
const checkHeading = (n, errors) => {
|
|
57
|
+
if (n.depth < 1 || n.depth > 3) {
|
|
58
|
+
errors.push(`${line(n)}: only # / ## / ### headings are allowed (not h${n.depth})`);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
// Quotes retain their block structure even when nested inside list items.
|
|
62
|
+
const checkListItem = (n, errors) => {
|
|
63
|
+
for (const child of n.children) {
|
|
64
|
+
if (child.type !== "paragraph" && child.type !== "list" && child.type !== "blockquote") {
|
|
65
|
+
errors.push(`${line(child)}: ${child.type} is not allowed directly under listItem (paragraph / list / quote only)`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const checkLink = (n, errors) => {
|
|
70
|
+
try {
|
|
71
|
+
parseDocLink(n.url);
|
|
72
|
+
parseFigureLink(n.url);
|
|
73
|
+
parseTermReference(n.url);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
errors.push(`${line(n)}: ${error instanceof Error ? error.message : error}`);
|
|
77
|
+
}
|
|
78
|
+
if (n.url.startsWith("doc:"))
|
|
79
|
+
errors.push(`${line(n)}: use a Markdown file link instead of doc:`);
|
|
80
|
+
if (n.url === "") {
|
|
81
|
+
errors.push(`${line(n)}: link with an empty URL`);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
export const parseDoc = (input, settings, file) => {
|
|
85
|
+
const errors = [];
|
|
86
|
+
const raw = input;
|
|
87
|
+
const root = parseMdast(raw);
|
|
88
|
+
const embeds = new Set(embeddedImages(root));
|
|
89
|
+
const terms = [];
|
|
90
|
+
const yamlNode = root.children[0];
|
|
91
|
+
let frontmatter;
|
|
92
|
+
let body = input;
|
|
93
|
+
if (yamlNode?.type === "yaml") {
|
|
94
|
+
try {
|
|
95
|
+
frontmatter = checkFrontmatter(parseYaml(yamlNode.value), errors);
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
errors.push(`front-matter: YAML parse failed (${e instanceof Error ? e.message : e})`);
|
|
99
|
+
}
|
|
100
|
+
const end = yamlNode.position?.end.offset;
|
|
101
|
+
body = end != null ? input.slice(end).replace(/^\n+/, "") : raw;
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
errors.push("front-matter (---) is required at the top");
|
|
105
|
+
}
|
|
106
|
+
visit(root, (node, _index, parent) => {
|
|
107
|
+
if (node.type === "text" &&
|
|
108
|
+
/(?:^|\n)[ \t]*:{2,}[a-z]+\b/.test(raw.slice(node.position?.start.offset, node.position?.end.offset)))
|
|
109
|
+
errors.push(`${line(node)}: removed directive; use Markdown file links or callouth / schemath / graphth / termth code fences`);
|
|
110
|
+
if (!ALLOWED_TYPES.has(node.type)) {
|
|
111
|
+
const hint = node.type === "html" ? ' (put "<" inside backticks or escape it as "\\<")' : "";
|
|
112
|
+
errors.push(`${line(node)}: ${node.type} is outside the dialect${hint}`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
switch (node.type) {
|
|
116
|
+
case "code": {
|
|
117
|
+
const parsed = parseContentCode(node, raw, file);
|
|
118
|
+
if (!parsed)
|
|
119
|
+
break;
|
|
120
|
+
if (parent?.type !== "root") {
|
|
121
|
+
errors.push(`${line(node)}: ${node.lang} must be a top-level code block`);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
for (const error of parsed.errors)
|
|
125
|
+
errors.push(`L${(node.position?.start.line ?? 0) + 1 + error.line}: ${node.lang} ${error.message}`);
|
|
126
|
+
for (const block of parsed.blocks)
|
|
127
|
+
if (block.type === "term")
|
|
128
|
+
terms.push(block.entry);
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
case "image": {
|
|
132
|
+
if (!embeds.has(node)) {
|
|
133
|
+
errors.push(`${line(node)}: file previews must occupy a standalone paragraph`);
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const { range } = parseFileEmbed(node.url, settings);
|
|
138
|
+
if (range !== undefined)
|
|
139
|
+
parseTextfileRange(range);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
errors.push(`${line(node)}: ${error instanceof Error ? error.message : error}`);
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
case "heading":
|
|
147
|
+
checkHeading(node, errors);
|
|
148
|
+
break;
|
|
149
|
+
case "listItem":
|
|
150
|
+
checkListItem(node, errors);
|
|
151
|
+
break;
|
|
152
|
+
case "link":
|
|
153
|
+
checkLink(node, errors);
|
|
154
|
+
break;
|
|
155
|
+
default:
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
return { frontmatter, body, source: raw, root, errors, terms };
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* Collect files referenced by Markdown links and embeds for validation and export.
|
|
163
|
+
*/
|
|
164
|
+
export const collectRefs = (root, settings) => {
|
|
165
|
+
const figures = [];
|
|
166
|
+
const terms = [];
|
|
167
|
+
const textfiles = [];
|
|
168
|
+
for (const image of embeddedImages(root)) {
|
|
169
|
+
try {
|
|
170
|
+
const { file, kind } = parseFileEmbed(image.url, settings);
|
|
171
|
+
if (kind === "textfile")
|
|
172
|
+
textfiles.push(file);
|
|
173
|
+
else if (kind !== "external")
|
|
174
|
+
figures.push(file);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// Invalid references are reported by parseDoc and must not reach file I/O.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const node of contentLinks(root)) {
|
|
181
|
+
try {
|
|
182
|
+
const file = parseTermReference(node.url);
|
|
183
|
+
if (file)
|
|
184
|
+
terms.push(file);
|
|
185
|
+
const figure = parseFigureLink(node.url);
|
|
186
|
+
if (figure)
|
|
187
|
+
figures.push(figure.file);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
/* Validation reports invalid file paths. */
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
figures: [...new Set(figures)],
|
|
195
|
+
terms: [...new Set(terms)],
|
|
196
|
+
textfiles: [...new Set(textfiles)],
|
|
197
|
+
};
|
|
198
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract the mxfile XML from the root <svg>'s content attribute.
|
|
3
|
+
* A prolog / DOCTYPE may precede it, so cut the start tag from the `<svg` position (the first `>`
|
|
4
|
+
* belongs to the prolog).
|
|
5
|
+
*/
|
|
6
|
+
export declare const extractMxfile: (raw: string) => string | undefined;
|
|
7
|
+
/** The mxfile's first <diagram name="..."> = the draw.io page name (= figure description). */
|
|
8
|
+
export declare const mxfilePageName: (mxfile: string) => string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* Whether an svg in figures/ was exported by draw.io. To avoid catching hand-written figures, it
|
|
11
|
+
* checks for traces only draw.io emits: the mxfile copy (content attribute) / the drawio.com
|
|
12
|
+
* fallback <switch> / draw.io CSS variables (`--ge-*`) / an inline `color-scheme` on the root.
|
|
13
|
+
*/
|
|
14
|
+
export declare const isDrawioSvg: (raw: string) => boolean;
|
|
15
|
+
export interface DrawioNormalized {
|
|
16
|
+
markup: string;
|
|
17
|
+
/** draw.io page name = figure description (for <desc>). Absent if exported without the copy. */
|
|
18
|
+
desc?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Normalize a draw.io-exported SVG into an SVG figures.ts can handle. `name` is the figure's name
|
|
22
|
+
* (= the name in figures/<name>.svg, used for <title>Fig.<name></title>).
|
|
23
|
+
* The returned markup is still a raw <svg>…</svg> string; figures.ts goes on to do the theme
|
|
24
|
+
* injection and structural validation.
|
|
25
|
+
*/
|
|
26
|
+
export declare const normalizeDrawioSvg: (name: string, raw: string) => DrawioNormalized;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Normalization of SVGs exported from draw.io = this module is the SoT.
|
|
2
|
+
//
|
|
3
|
+
// Two ways to place them (see also the top of figures.ts):
|
|
4
|
+
// - figures/<name>.drawio.svg = File > Save as (Editable SVG). Carries a copy of the mxfile, so
|
|
5
|
+
// one file is both the editing source and the output (open in draw.io and Save to
|
|
6
|
+
// update it). Recommended
|
|
7
|
+
// - figures/<name>.svg = File > Export as > SVG (the editing source can live anywhere).
|
|
8
|
+
// Drift between the two (only the svg is stale) is handled by people re-exporting;
|
|
9
|
+
// the build doesn't verify the relationship (= no machinery beyond "make draw.io
|
|
10
|
+
// easy to work with locally")
|
|
11
|
+
// figures.ts passes .drawio.svg through here unconditionally, and .svg only when it comes from
|
|
12
|
+
// draw.io (`isDrawioSvg`), then feeds it to the same parseSvg as hand-written SVGs.
|
|
13
|
+
//
|
|
14
|
+
// **Colors stay in draw.io's scheme (not mapped to the var(--doc-*) of hand-written figures).**
|
|
15
|
+
// Because draw.io itself emits both light and dark values via CSS light-dark() on export:
|
|
16
|
+
// stroke: light-dark(rgb(0,0,0), rgb(255,255,255))
|
|
17
|
+
// fill: light-dark(rgb(218,232,252), rgb(29,41,59)) ← auto dark version of chosen color
|
|
18
|
+
// --ge-adaptive-bg: light-dark(#ffffff, var(--ge-dark-color, #121212))
|
|
19
|
+
// Figures are fully self-contained in terms of color. Mapping them onto the docs palette (=
|
|
20
|
+
// erroring on colors not in the table) is rejected: it takes away drawing freedom and would
|
|
21
|
+
// duplicate draw.io's own dark support. Some mismatch with the docs color scheme is acceptable.
|
|
22
|
+
//
|
|
23
|
+
// Only four things happen here:
|
|
24
|
+
// 1. Drop the XML prolog / DOCTYPE (figures.ts requires a single root <svg>; if left in, the
|
|
25
|
+
// theme injection breaks the prolog)
|
|
26
|
+
// 2. Drop the content attribute (the mxfile copy) from rendered output — it's editing data, not
|
|
27
|
+
// needed for distribution. Works whether "include a copy of the diagram" is ON or OFF at
|
|
28
|
+
// export
|
|
29
|
+
// 3. Drop the trailing "Text is not SVG - cannot display" fallback — it embeds an external
|
|
30
|
+
// link to drawio.com, and shipped figures shouldn't keep needless external links
|
|
31
|
+
// 4. Adjust the root's inline style so resolving light-dark() is left to the docs (below)
|
|
32
|
+
//
|
|
33
|
+
// What 4 does = hand only the "control over switching" light/dark to the docs:
|
|
34
|
+
// - draw.io writes `color-scheme: light dark` inline on the root. Inline beats the
|
|
35
|
+
// [data-doc-figure] style injected by figures.ts, so as-is the figure alone would follow the
|
|
36
|
+
// **OS setting** for light/dark and not the docs theme toggle (data-theme="dark"). So only
|
|
37
|
+
// the root's color-scheme declaration is removed, leaving it to figures.ts's theme injection.
|
|
38
|
+
// - For the background, point `--ge-dark-color` (the variable draw.io uses for dark surfaces)
|
|
39
|
+
// at the docs panel color. draw.io reads it through the variable, so this is a "stable
|
|
40
|
+
// override point".
|
|
41
|
+
//
|
|
42
|
+
// title / desc follow the hand-written figure convention: title is fixed to `Fig.<name>`
|
|
43
|
+
// (injected mechanically); desc = the figure description comes from the mxfile's <diagram name>
|
|
44
|
+
// (= the draw.io page name) — only available when "include a copy of the diagram" was ON at
|
|
45
|
+
// export. If OFF, there's no desc (not an error).
|
|
46
|
+
/** Unescape XML entities, numeric ones included (to extract the mxfile from content). */
|
|
47
|
+
const unescapeXml = (s) => s
|
|
48
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
|
|
49
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(Number.parseInt(h, 16)))
|
|
50
|
+
.replace(/</g, "<")
|
|
51
|
+
.replace(/>/g, ">")
|
|
52
|
+
.replace(/"/g, '"')
|
|
53
|
+
.replace(/'/g, "'")
|
|
54
|
+
.replace(/&/g, "&");
|
|
55
|
+
/** draw.io's default page names — meaningless as a description, so not turned into <desc>. */
|
|
56
|
+
const DEFAULT_PAGE_NAMES = new Set(["page-1", "ページ-1", "page 1", "untitled", ""]);
|
|
57
|
+
// The content attribute is "one long attribute carrying the mxfile".
|
|
58
|
+
const CONTENT_ATTR = /\scontent="(?:[^"\\]|\\.)*"/;
|
|
59
|
+
const rootHead = (raw) => {
|
|
60
|
+
const start = raw.indexOf("<svg");
|
|
61
|
+
if (start < 0) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const end = raw.indexOf(">", start);
|
|
65
|
+
return raw.slice(start, end < 0 ? undefined : end + 1);
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Extract the mxfile XML from the root <svg>'s content attribute.
|
|
69
|
+
* A prolog / DOCTYPE may precede it, so cut the start tag from the `<svg` position (the first `>`
|
|
70
|
+
* belongs to the prolog).
|
|
71
|
+
*/
|
|
72
|
+
export const extractMxfile = (raw) => {
|
|
73
|
+
const head = rootHead(raw);
|
|
74
|
+
const m = head ? /\scontent="([^"]*)"/.exec(head) : null;
|
|
75
|
+
const xml = m?.[1] ? unescapeXml(m[1]) : undefined;
|
|
76
|
+
return xml?.includes("<mxfile") || xml?.includes("<mxGraphModel") ? xml : undefined;
|
|
77
|
+
};
|
|
78
|
+
/** The mxfile's first <diagram name="..."> = the draw.io page name (= figure description). */
|
|
79
|
+
export const mxfilePageName = (mxfile) => {
|
|
80
|
+
const m = /<diagram[^>]*\sname="([^"]*)"/.exec(mxfile);
|
|
81
|
+
const name = m?.[1] ? unescapeXml(m[1]).trim() : "";
|
|
82
|
+
return name ? name : undefined;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Whether an svg in figures/ was exported by draw.io. To avoid catching hand-written figures, it
|
|
86
|
+
* checks for traces only draw.io emits: the mxfile copy (content attribute) / the drawio.com
|
|
87
|
+
* fallback <switch> / draw.io CSS variables (`--ge-*`) / an inline `color-scheme` on the root.
|
|
88
|
+
*/
|
|
89
|
+
export const isDrawioSvg = (raw) => {
|
|
90
|
+
if (extractMxfile(raw)) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
if (raw.includes("Text is not SVG - cannot display") || raw.includes("--ge-")) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
const head = rootHead(raw);
|
|
97
|
+
return !!head && /\sstyle="[^"]*color-scheme\s*:/.test(head);
|
|
98
|
+
};
|
|
99
|
+
// Strip the whole leading prolog. draw.io emits `<?xml?>` → `<!-- Do not edit this file with
|
|
100
|
+
// editors other than draw.io -->` → `<!DOCTYPE>` in that order (the .drawio.svg Save format inserts
|
|
101
|
+
// the comment), so peel items off until the root <svg> comes first, without assuming kind or order.
|
|
102
|
+
const PROLOG_ITEM = /^\s*(?:<\?xml[^>]*\?>|<!DOCTYPE[^>[]*(?:\[[\s\S]*?\])?>|<!--[\s\S]*?-->)\s*/i;
|
|
103
|
+
const stripProlog = (s) => {
|
|
104
|
+
let out = s.replace(/^\uFEFF/, "");
|
|
105
|
+
for (let m = PROLOG_ITEM.exec(out); m; m = PROLOG_ITEM.exec(out)) {
|
|
106
|
+
out = out.slice(m[0].length);
|
|
107
|
+
}
|
|
108
|
+
return out.trim();
|
|
109
|
+
};
|
|
110
|
+
// For renderers that can't draw foreignObject, draw.io appends a <switch> embedding an <a> link
|
|
111
|
+
// to drawio.com. Drop it so shipped figures don't keep a needless external link.
|
|
112
|
+
const stripFallbackLink = (s) => s.replace(/<switch>\s*<g\s+requiredFeatures="[^"]*"\s*\/>\s*<a\b[^>]*>[\s\S]*?<\/a>\s*<\/switch>/g, "");
|
|
113
|
+
/** Background the docs side can override (the variable draw.io uses for dark surfaces). */
|
|
114
|
+
const DARK_SURFACE = "--ge-dark-color: var(--doc-panel, #161b22)";
|
|
115
|
+
/**
|
|
116
|
+
* Remove the `color-scheme` declaration from the root <svg>'s inline style and point only the
|
|
117
|
+
* dark surface color at the docs. If color-scheme stayed, its inline strength would beat
|
|
118
|
+
* figures.ts's theme injection and the figure alone would follow the OS light/dark setting
|
|
119
|
+
* (= not follow the docs theme toggle).
|
|
120
|
+
*/
|
|
121
|
+
const adaptRootStyle = (markup) => {
|
|
122
|
+
const open = markup.indexOf(">");
|
|
123
|
+
if (!markup.startsWith("<svg") || open < 0) {
|
|
124
|
+
return markup;
|
|
125
|
+
}
|
|
126
|
+
const head = markup.slice(0, open);
|
|
127
|
+
const rest = markup.slice(open);
|
|
128
|
+
const m = /\sstyle="([^"]*)"/.exec(head);
|
|
129
|
+
if (!m) {
|
|
130
|
+
return `${head} style="${DARK_SURFACE}"${rest}`;
|
|
131
|
+
}
|
|
132
|
+
const decls = (m[1] ?? "")
|
|
133
|
+
.split(";")
|
|
134
|
+
.map((d) => d.trim())
|
|
135
|
+
.filter((d) => d && !/^color-scheme\s*:/i.test(d));
|
|
136
|
+
decls.push(DARK_SURFACE);
|
|
137
|
+
return `${head.slice(0, m.index)} style="${decls.join("; ")}"${head.slice(m.index + m[0].length)}${rest}`;
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* Normalize a draw.io-exported SVG into an SVG figures.ts can handle. `name` is the figure's name
|
|
141
|
+
* (= the name in figures/<name>.svg, used for <title>Fig.<name></title>).
|
|
142
|
+
* The returned markup is still a raw <svg>…</svg> string; figures.ts goes on to do the theme
|
|
143
|
+
* injection and structural validation.
|
|
144
|
+
*/
|
|
145
|
+
export const normalizeDrawioSvg = (name, raw) => {
|
|
146
|
+
const mxfile = extractMxfile(raw);
|
|
147
|
+
let markup = stripProlog(raw);
|
|
148
|
+
// Drop the content attribute — editing data doesn't go into the rendered output.
|
|
149
|
+
const headEnd = markup.indexOf(">");
|
|
150
|
+
if (headEnd >= 0) {
|
|
151
|
+
markup = markup.slice(0, headEnd).replace(CONTENT_ATTR, "") + markup.slice(headEnd);
|
|
152
|
+
}
|
|
153
|
+
markup = stripFallbackLink(markup).trim();
|
|
154
|
+
markup = adaptRootStyle(markup);
|
|
155
|
+
// Follow the same convention as hand-written figures: <title> fixed to Fig.<name>, <desc> is
|
|
156
|
+
// the description. The description is the draw.io page name (tab name) — only available from
|
|
157
|
+
// the mxfile copy included in the export.
|
|
158
|
+
//
|
|
159
|
+
// A default page name / no copy is **not an error**. <desc> is optional for figures/*.svg too
|
|
160
|
+
// (parseSvg accepts it missing); a figure without one just falls back to a caption of
|
|
161
|
+
// "Fig.<name>" and still renders = it isn't "unshippable".
|
|
162
|
+
const page = mxfile ? mxfilePageName(mxfile) : undefined;
|
|
163
|
+
const desc = page && !DEFAULT_PAGE_NAMES.has(page.toLowerCase()) ? page : undefined;
|
|
164
|
+
const esc = (v) => v.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
165
|
+
const open = markup.indexOf(">");
|
|
166
|
+
if (open >= 0) {
|
|
167
|
+
// draw.io doesn't write <title>/<desc>, but drop any existing ones to avoid duplicates.
|
|
168
|
+
const body = markup
|
|
169
|
+
.slice(open + 1)
|
|
170
|
+
.replace(/^\s*<title[^>]*>[\s\S]*?<\/title>/, "")
|
|
171
|
+
.replace(/^\s*<desc[^>]*>[\s\S]*?<\/desc>/, "");
|
|
172
|
+
const meta = `<title>Fig.${esc(name)}</title>${desc ? `<desc>${esc(desc)}</desc>` : ""}`;
|
|
173
|
+
markup = `${markup.slice(0, open + 1)}${meta}${body}`;
|
|
174
|
+
}
|
|
175
|
+
return { markup, desc };
|
|
176
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FigureEntry } from "../core/content-types.js";
|
|
2
|
+
/** File relative to figures/ → the figure's registered name (stem without the extension). */
|
|
3
|
+
export declare const figureName: (file: string) => string;
|
|
4
|
+
/**
|
|
5
|
+
* Original file name of a figure added from the editing UI → a name following the figures/
|
|
6
|
+
* convention (svg / drawio.svg / html / raster). The stem is coerced to lowercase alphanumerics
|
|
7
|
+
* and _-, or "figure" if nothing remains. Unsupported extensions yield undefined.
|
|
8
|
+
*/
|
|
9
|
+
export declare const figureUploadName: (raw: string) => string | undefined;
|
|
10
|
+
export type { FigureEntry } from "../core/content-types.js";
|
|
11
|
+
export declare const loadFigures: (figuresDir: string, extraFiles?: string[]) => {
|
|
12
|
+
figures: FigureEntry[];
|
|
13
|
+
errors: string[];
|
|
14
|
+
};
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
// figures/ = the SoT for HTML-based Figs (figures). Two formats:
|
|
3
|
+
// - <name>.svg : self-contained SVG (xmlns + viewBox + <title>Fig.<name></title> +
|
|
4
|
+
// optional <desc>description</desc>)
|
|
5
|
+
// - <name>.html : self-contained HTML fragment (leading <!--fig title/desc --> meta comment,
|
|
6
|
+
// styles such as CSS animations bundled in <style>, no scripts)
|
|
7
|
+
// Both use var(--doc-*, fallback) for colors (follows the theme + works standalone). Content
|
|
8
|
+
// references them via `` (png / webp / jpeg become
|
|
9
|
+
// data-URI <img>s).
|
|
10
|
+
//
|
|
11
|
+
// Figures drawn in draw.io live in the same figures/. Two ways to place them; in both, name is
|
|
12
|
+
// the stem without the extension:
|
|
13
|
+
// - <name>.drawio.svg : output of draw.io's File > Save as (Editable SVG). Carries an mxfile
|
|
14
|
+
// copy (content=), so **one file is both editing source and output**
|
|
15
|
+
// (open in draw.io and Save to update; no re-export needed). Error if
|
|
16
|
+
// there's no mxfile
|
|
17
|
+
// - <name>.svg : output of File > Export as > SVG (the editing source can live
|
|
18
|
+
// anywhere). isDrawioSvg tells whether it's from draw.io (it coexists
|
|
19
|
+
// with hand-written figures)
|
|
20
|
+
// Both go through drawio.ts normalization (stripping prolog / mxfile copy / external links,
|
|
21
|
+
// injecting title) before the same validation as hand-written SVGs. Colors stay in draw.io's
|
|
22
|
+
// scheme (see the top of drawio.ts).
|
|
23
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
24
|
+
import { dirname } from "node:path";
|
|
25
|
+
import { extractMxfile, isDrawioSvg, normalizeDrawioSvg } from "./drawio.js";
|
|
26
|
+
import { textfilePath } from "./textfile.js";
|
|
27
|
+
const EXT_RE = /\.(drawio\.svg|svg|html|png|webp|gif|mp4|webm|jpe?g)$/;
|
|
28
|
+
const FILE_RE = /^[a-z0-9][a-z0-9_-]*\.(drawio\.svg|svg|html|png|webp|gif|mp4|webm|jpe?g)$/;
|
|
29
|
+
const IMAGE_MIME = {
|
|
30
|
+
png: "image/png",
|
|
31
|
+
webp: "image/webp",
|
|
32
|
+
gif: "image/gif",
|
|
33
|
+
jpg: "image/jpeg",
|
|
34
|
+
jpeg: "image/jpeg",
|
|
35
|
+
};
|
|
36
|
+
/** File relative to figures/ → the figure's registered name (stem without the extension). */
|
|
37
|
+
export const figureName = (file) => !FILE_RE.test(file.replace(/^figures\//, ""))
|
|
38
|
+
? `file-${createHash("sha256").update(file).digest("hex").slice(0, 24)}`
|
|
39
|
+
: file.replace(/^figures\//, "").replace(EXT_RE, "");
|
|
40
|
+
/**
|
|
41
|
+
* Original file name of a figure added from the editing UI → a name following the figures/
|
|
42
|
+
* convention (svg / drawio.svg / html / raster). The stem is coerced to lowercase alphanumerics
|
|
43
|
+
* and _-, or "figure" if nothing remains. Unsupported extensions yield undefined.
|
|
44
|
+
*/
|
|
45
|
+
export const figureUploadName = (raw) => {
|
|
46
|
+
const lower = raw.toLowerCase().split(/[\\/]/).pop() ?? "";
|
|
47
|
+
const ext = /\.(drawio\.svg|svg|html|png|webp|gif|mp4|webm|jpe?g)$/.exec(lower)?.[1];
|
|
48
|
+
if (ext == null) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const stem = lower
|
|
52
|
+
.slice(0, -ext.length - 1)
|
|
53
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
54
|
+
.replace(/^[^a-z0-9]+|-+$/g, "");
|
|
55
|
+
return `${stem || "figure"}.${ext}`;
|
|
56
|
+
};
|
|
57
|
+
const LIGHT_VARS = "--doc-bg:#ffffff;--doc-bg-2:#f6f8fb;--doc-bg-3:#eef2f7;--doc-panel:#ffffff;--doc-panel-2:#f1f5fa;--doc-rail:#f3f6fa;--doc-border:#dde3eb;--doc-border-2:#c4ccd8;--doc-fg:#000000;--doc-fg-2:#2a3140;--doc-fg-3:#5b6271;--doc-fg-4:#9099a8;--doc-accent:#1b4994;--doc-accent-2:#0277bd;--doc-accent-fg:#ffffff;--doc-ok:#1f7a32;--doc-ok-2:#2ea043;--doc-warn:#b97600;--doc-warn-2:#f59f00;--doc-danger:#b53030;--doc-danger-2:#f04848;--doc-info:#0277bd;--doc-info-2:#4a8bff;";
|
|
58
|
+
const DARK_VARS = "--doc-bg:#000000;--doc-bg-2:#0a0d12;--doc-bg-3:#11161c;--doc-panel:#161b22;--doc-panel-2:#1e2530;--doc-rail:#0d1117;--doc-border:#222831;--doc-border-2:#323a47;--doc-fg:#ffffff;--doc-fg-2:#c9d1d9;--doc-fg-3:#8b939e;--doc-fg-4:#525a66;--doc-accent:#4a8bff;--doc-accent-2:#1b4994;--doc-accent-fg:#ffffff;--doc-ok:#2ea043;--doc-ok-2:#56d364;--doc-warn:#f59f00;--doc-warn-2:#ffc14d;--doc-danger:#f04848;--doc-danger-2:#ff7a7a;--doc-info:#4a8bff;--doc-info-2:#79b0ff;";
|
|
59
|
+
const FIGURE_THEME_STYLE = `<style data-doc-figure-theme>[data-doc-figure]{color-scheme:light;${LIGHT_VARS}}@media (prefers-color-scheme: dark){[data-doc-figure]{color-scheme:dark;${DARK_VARS}}}:root[data-theme="light"] [data-doc-figure],[data-doc-theme="light"] [data-doc-figure],[data-doc-figure][data-doc-theme="light"]{color-scheme:light;${LIGHT_VARS}}:root[data-theme="dark"] [data-doc-figure],[data-doc-theme="dark"] [data-doc-figure],[data-doc-figure][data-doc-theme="dark"]{color-scheme:dark;${DARK_VARS}}</style>`;
|
|
60
|
+
const firstTag = (svg, tag) => {
|
|
61
|
+
const m = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`).exec(svg);
|
|
62
|
+
return m ? m[1]?.replace(/</g, "<").replace(/>/g, ">").trim() : undefined;
|
|
63
|
+
};
|
|
64
|
+
const HTML_META_RE = /^<!--fig\n([\s\S]*?)-->\n?/;
|
|
65
|
+
const normalizeHardcodedSurfaces = (markup) => markup
|
|
66
|
+
.replace(/background-color:\s*#fff(?:fff)?(?=[;}])/gi, "background-color:var(--doc-panel, #ffffff)")
|
|
67
|
+
.replace(/background:\s*#fff(?:fff)?(?=[;}])/gi, "background:var(--doc-panel, #ffffff)");
|
|
68
|
+
const withSvgTheme = (markup) => {
|
|
69
|
+
const headEnd = markup.indexOf(">");
|
|
70
|
+
if (headEnd < 0) {
|
|
71
|
+
return markup;
|
|
72
|
+
}
|
|
73
|
+
const head = markup.slice(0, headEnd);
|
|
74
|
+
const taggedHead = head.includes("data-doc-figure") ? head : `${head} data-doc-figure=""`;
|
|
75
|
+
return `${taggedHead}>${FIGURE_THEME_STYLE}${markup.slice(headEnd + 1)}`;
|
|
76
|
+
};
|
|
77
|
+
const withHtmlTheme = (markup) => {
|
|
78
|
+
const themed = normalizeHardcodedSurfaces(markup);
|
|
79
|
+
if (themed.includes("data-doc-figure") || !themed.startsWith("<")) {
|
|
80
|
+
return themed;
|
|
81
|
+
}
|
|
82
|
+
// A fragment can start with <style>; keep the theme outside authored elements.
|
|
83
|
+
return `<div data-doc-figure="">${FIGURE_THEME_STYLE}${themed}</div>`;
|
|
84
|
+
};
|
|
85
|
+
const parseSvg = (file, raw, errors) => {
|
|
86
|
+
const markup = withSvgTheme(normalizeHardcodedSurfaces(raw.trim()));
|
|
87
|
+
if (!markup.startsWith("<svg") || !markup.endsWith("</svg>")) {
|
|
88
|
+
errors.push(`${file}: a single root <svg>…</svg> is required`);
|
|
89
|
+
}
|
|
90
|
+
const head = markup.slice(0, markup.indexOf(">"));
|
|
91
|
+
if (!head.includes("xmlns=")) {
|
|
92
|
+
errors.push(`${file}: root <svg> requires xmlns (for standalone publishing)`);
|
|
93
|
+
}
|
|
94
|
+
if (!head.includes("viewBox=")) {
|
|
95
|
+
errors.push(`${file}: root <svg> requires viewBox (to scale with the width)`);
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
kind: "svg",
|
|
99
|
+
markup,
|
|
100
|
+
title: firstTag(markup, "title"),
|
|
101
|
+
desc: firstTag(markup, "desc"),
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
const parseHtml = (file, raw, errors) => {
|
|
105
|
+
const m = HTML_META_RE.exec(raw);
|
|
106
|
+
if (!m) {
|
|
107
|
+
errors.push(`${file}: a <!--fig\\ntitle: ...\\n--> meta comment is required at the top`);
|
|
108
|
+
return { kind: "html", markup: raw.trim() };
|
|
109
|
+
}
|
|
110
|
+
const meta = new Map((m[1] ?? "")
|
|
111
|
+
.split("\n")
|
|
112
|
+
.filter((l) => l.includes(":"))
|
|
113
|
+
.map((l) => [l.slice(0, l.indexOf(":")).trim(), l.slice(l.indexOf(":") + 1).trim()]));
|
|
114
|
+
const markup = withHtmlTheme(raw.slice(m[0].length).trim());
|
|
115
|
+
if (!meta.get("title")) {
|
|
116
|
+
errors.push(`${file}: the meta comment requires title:`);
|
|
117
|
+
}
|
|
118
|
+
if (!markup.startsWith("<")) {
|
|
119
|
+
errors.push(`${file}: the meta comment must be followed by an HTML fragment`);
|
|
120
|
+
}
|
|
121
|
+
if (/<script/i.test(markup)) {
|
|
122
|
+
errors.push(`${file}: <script> is not allowed (self-contained static fragments only)`);
|
|
123
|
+
}
|
|
124
|
+
return { kind: "html", markup, title: meta.get("title"), desc: meta.get("desc") };
|
|
125
|
+
};
|
|
126
|
+
export const loadFigures = (figuresDir, extraFiles = []) => {
|
|
127
|
+
const errors = [];
|
|
128
|
+
const figures = [];
|
|
129
|
+
let files = [];
|
|
130
|
+
try {
|
|
131
|
+
files = readdirSync(figuresDir)
|
|
132
|
+
.filter((f) => /\.(svg|html|png|webp|gif|mp4|webm|jpe?g)$/.test(f))
|
|
133
|
+
.sort();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// A missing figures/ is valid.
|
|
137
|
+
}
|
|
138
|
+
files = [...new Set([...files.map((file) => `figures/${file}`), ...extraFiles])];
|
|
139
|
+
const keyframeOwner = new Map();
|
|
140
|
+
for (const file of files) {
|
|
141
|
+
if (file.startsWith("figures/") &&
|
|
142
|
+
!file.slice(8).includes("/") &&
|
|
143
|
+
!FILE_RE.test(file.slice(8))) {
|
|
144
|
+
errors.push(`${file}: file name must be <name>.svg|.drawio.svg|.html|.png|.webp|.jpg|.jpeg ([a-z0-9_-])`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const name = figureName(file);
|
|
148
|
+
const dup = figures.find((f) => f.name === name);
|
|
149
|
+
if (dup) {
|
|
150
|
+
errors.push(`${file}: duplicate name "${name}" (also used by ${dup.file})`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (/\.(mp4|webm)$/.test(file)) {
|
|
154
|
+
const data = readFileSync(file.startsWith("figures/")
|
|
155
|
+
? textfilePath(figuresDir, file.slice(8))
|
|
156
|
+
: textfilePath(dirname(figuresDir), file));
|
|
157
|
+
figures.push({
|
|
158
|
+
name,
|
|
159
|
+
file,
|
|
160
|
+
kind: "video",
|
|
161
|
+
markup: `<video data-dooc-video controls playsinline preload="metadata" aria-label="Video preview" src="data:video/${file.endsWith(".mp4") ? "mp4" : "webm"};base64,${data.toString("base64")}"></video>`,
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const mime = IMAGE_MIME[file.slice(file.lastIndexOf(".") + 1)];
|
|
166
|
+
if (mime != null) {
|
|
167
|
+
// Rasters are inlined as data URIs; Markdown embeds supply their alternative text.
|
|
168
|
+
const data = readFileSync(file.startsWith("figures/")
|
|
169
|
+
? textfilePath(figuresDir, file.slice(8))
|
|
170
|
+
: textfilePath(dirname(figuresDir), file)).toString("base64");
|
|
171
|
+
figures.push({
|
|
172
|
+
name,
|
|
173
|
+
file,
|
|
174
|
+
kind: "image",
|
|
175
|
+
markup: `<img src="data:${mime};base64,${data}" alt="${name}">`,
|
|
176
|
+
});
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const raw = readFileSync(file.startsWith("figures/")
|
|
180
|
+
? textfilePath(figuresDir, file.slice(8))
|
|
181
|
+
: textfilePath(dirname(figuresDir), file), "utf8");
|
|
182
|
+
// The point of .drawio.svg is "can be reopened in draw.io", so the mxfile copy is required
|
|
183
|
+
// (rejects an Export as > SVG output without the copy that was merely renamed).
|
|
184
|
+
const editable = file.endsWith(".drawio.svg");
|
|
185
|
+
if (editable && !extractMxfile(raw)) {
|
|
186
|
+
errors.push(`${file}: .drawio.svg doubles as the editable source, so it requires a copy of the mxfile (content=) (save it as .drawio.svg via File > Save as in draw.io)`);
|
|
187
|
+
}
|
|
188
|
+
const parsed = file.endsWith(".svg")
|
|
189
|
+
? parseSvg(file, editable || isDrawioSvg(raw) ? normalizeDrawioSvg(name, raw).markup : raw, errors)
|
|
190
|
+
: parseHtml(file, raw, errors);
|
|
191
|
+
if (/var\(--doc-[a-z0-9-]+\)/.test(parsed.markup)) {
|
|
192
|
+
errors.push(`${file}: contains var(--doc-*) without a fallback (use the var(--doc-x, #hex) form)`);
|
|
193
|
+
}
|
|
194
|
+
// @keyframes names must not collide when several figures are inlined on one page.
|
|
195
|
+
// The same name with an identical body is harmless (e.g. xyflow's dashdraw, present in
|
|
196
|
+
// every capture figure), so that's allowed.
|
|
197
|
+
for (const km of parsed.markup.matchAll(/@keyframes\s+([A-Za-z0-9_-]+)\s*\{/g)) {
|
|
198
|
+
const kname = km[1] ?? "";
|
|
199
|
+
let depth = 1;
|
|
200
|
+
let i = (km.index ?? 0) + km[0].length;
|
|
201
|
+
while (i < parsed.markup.length && depth > 0) {
|
|
202
|
+
const ch = parsed.markup[i];
|
|
203
|
+
depth += ch === "{" ? 1 : ch === "}" ? -1 : 0;
|
|
204
|
+
i += 1;
|
|
205
|
+
}
|
|
206
|
+
const body = parsed.markup.slice((km.index ?? 0) + km[0].length, i).replace(/\s+/g, "");
|
|
207
|
+
const key = `${kname}`;
|
|
208
|
+
const owner = keyframeOwner.get(key);
|
|
209
|
+
if (owner && owner.file !== file && owner.body !== body) {
|
|
210
|
+
errors.push(`${file}: @keyframes "${kname}" conflicts with a different definition in ${owner.file}`);
|
|
211
|
+
}
|
|
212
|
+
keyframeOwner.set(key, { file, body });
|
|
213
|
+
}
|
|
214
|
+
figures.push({
|
|
215
|
+
name,
|
|
216
|
+
file,
|
|
217
|
+
...parsed,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return { figures, errors };
|
|
221
|
+
};
|