@markdstage/markdstage 0.1.1

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 (67) hide show
  1. package/README.md +90 -0
  2. package/bin/markdstage.mjs +12 -0
  3. package/package.json +45 -0
  4. package/shared/README.md +1014 -0
  5. package/shared/THIRD-PARTY-NOTICES.md +19 -0
  6. package/shared/deck-state.mjs +105 -0
  7. package/shared/docs/custom-theme-authoring.md +208 -0
  8. package/shared/markdown-deck.mjs +220 -0
  9. package/shared/markdstage-guide.mjs +276 -0
  10. package/shared/presenter-window.mjs +17 -0
  11. package/shared/renderer/architecture-document.mjs +596 -0
  12. package/shared/renderer/architecture-edit.mjs +298 -0
  13. package/shared/renderer/architecture-editor.mjs +449 -0
  14. package/shared/renderer/architecture.mjs +4033 -0
  15. package/shared/renderer/import-path.mjs +11 -0
  16. package/shared/renderer/index.html +106 -0
  17. package/shared/renderer/renderer.js +2082 -0
  18. package/shared/renderer/slides.css +614 -0
  19. package/shared/renderer/speaker-notes.mjs +106 -0
  20. package/shared/renderer/theme.mjs +205 -0
  21. package/shared/runtime/browser.mjs +539 -0
  22. package/shared/runtime/custom-theme.mjs +135 -0
  23. package/shared/runtime/deck-session.mjs +188 -0
  24. package/shared/runtime/errors.mjs +17 -0
  25. package/shared/runtime/output-paths.mjs +159 -0
  26. package/shared/runtime/output.mjs +385 -0
  27. package/shared/runtime/presentation-server.mjs +505 -0
  28. package/shared/runtime/static-files.mjs +70 -0
  29. package/shared/schema/README.md +228 -0
  30. package/shared/schema/architecture-v1.schema.json +664 -0
  31. package/shared/schema/examples/web-app.architecture.json +119 -0
  32. package/shared/schema/theme-metadata-v1.schema.json +75 -0
  33. package/shared/schema/theme-v1.json +84 -0
  34. package/shared/scripts/architecture-assets.mjs +226 -0
  35. package/shared/scripts/asset-paths.mjs +92 -0
  36. package/shared/scripts/atomic-markdown-replace.mjs +46 -0
  37. package/shared/scripts/markdown-blocks.mjs +182 -0
  38. package/shared/scripts/markdown-files.mjs +63 -0
  39. package/shared/scripts/markdown-save-coordinator.mjs +18 -0
  40. package/shared/scripts/markdown-watcher.mjs +80 -0
  41. package/shared/scripts/theme-paths.mjs +108 -0
  42. package/shared/scripts/vendor-assets.mjs +132 -0
  43. package/shared/scripts/workspace-root.mjs +32 -0
  44. package/shared/vendor/highlight.LICENSE +29 -0
  45. package/shared/vendor/highlight.min.js +1244 -0
  46. package/shared/vendor/marked.min.js +6 -0
  47. package/shared/vendor/mermaid.min.js.part-0001 +268 -0
  48. package/shared/vendor/mermaid.min.js.part-0002 +304 -0
  49. package/shared/vendor/mermaid.min.js.part-0003 +324 -0
  50. package/shared/vendor/mermaid.min.js.part-0004 +374 -0
  51. package/shared/vendor/mermaid.min.js.part-0005 +564 -0
  52. package/shared/vendor/mermaid.min.js.part-0006 +1308 -0
  53. package/shared/vendor/mermaid.min.js.part-0007 +269 -0
  54. package/shared/vendor/purify.min.js +3 -0
  55. package/shared/vendor/vendor-assets.lock.json +60 -0
  56. package/src/cli.mjs +347 -0
  57. package/src/commands/capture.mjs +23 -0
  58. package/src/commands/export.mjs +18 -0
  59. package/src/commands/guide.mjs +23 -0
  60. package/src/commands/inspect.mjs +35 -0
  61. package/src/commands/present.mjs +91 -0
  62. package/src/commands/skill.mjs +114 -0
  63. package/src/commands/validate.mjs +79 -0
  64. package/src/deck.mjs +63 -0
  65. package/src/exit.mjs +58 -0
  66. package/src/runtime.mjs +77 -0
  67. package/src/skills.mjs +155 -0
@@ -0,0 +1,182 @@
1
+ // Small utilities for replacing ```architecture fences in slide Markdown.
2
+ //
3
+ // Architecture diagram editing **rewrites the source DSL in place** rather than
4
+ // maintaining a diff against rendered output. The new DSL produced by the editing
5
+ // UI must therefore be restored precisely to the nth ```architecture fence in the source slide.
6
+ //
7
+ // Imported by both production `extension.mjs` and the test harness
8
+ // (`test/harness/server.mjs`). Keep this free of runtime npm dependencies because
9
+ // the extension is distributed as a ZIP.
10
+
11
+ // Opening fence: three or more ` or ~ characters, as in marked; inspect only one info word.
12
+ const FENCE_OPEN = /^([ \t]{0,3})(`{3,}|~{3,})[ \t]*([^\s`~]*)[ \t]*$/;
13
+
14
+ /**
15
+ * Split Markdown into lines. Normalize CRLF / CR to LF for scanning.
16
+ */
17
+ function toLines(markdown) {
18
+ return String(markdown).replace(/\r\n?/g, "\n").split("\n");
19
+ }
20
+
21
+ /**
22
+ * Split Markdown into body text plus the line's newline sequence. This preserves
23
+ * every byte of newline data outside the fence during replacement.
24
+ *
25
+ * Uses the same boundaries as toLines, so line numbers match findArchitectureBlocks results.
26
+ */
27
+ function splitLinesWithEol(markdown) {
28
+ const text = String(markdown);
29
+ const out = [];
30
+ const re = /\r\n|\r|\n/g;
31
+ let last = 0;
32
+ let m;
33
+ while ((m = re.exec(text)) !== null) {
34
+ out.push({ text: text.slice(last, m.index), eol: m[0] });
35
+ last = re.lastIndex;
36
+ }
37
+ out.push({ text: text.slice(last), eol: "" });
38
+ return out;
39
+ }
40
+
41
+ /** Newline sequence for inserted lines; use the document's dominant sequence. */
42
+ function dominantEol(lines) {
43
+ let crlf = 0;
44
+ let lf = 0;
45
+ for (const line of lines) {
46
+ if (line.eol === "\r\n") crlf += 1;
47
+ else if (line.eol === "\n") lf += 1;
48
+ }
49
+ return crlf > lf ? "\r\n" : "\n";
50
+ }
51
+
52
+ /**
53
+ * Find the closing fence line corresponding to an opening fence.
54
+ * Return the end of the document (lines.length) when absent, indicating an unclosed fence.
55
+ */
56
+ function findFenceEnd(lines, start, marker) {
57
+ const close = new RegExp(`^[ \\t]{0,3}[${marker[0]}]{${marker.length},}[ \\t]*$`);
58
+ for (let i = start; i < lines.length; i += 1) {
59
+ if (close.test(lines[i])) return i;
60
+ }
61
+ return lines.length;
62
+ }
63
+
64
+ /**
65
+ * Scan ```architecture fences in Markdown.
66
+ * Each item contains { index, open, end, indent, body }.
67
+ * - index: zero-based sequence among architecture fences only (matching the
68
+ * renderer's `code.language-architecture` occurrence order)
69
+ * - open / end: opening and closing fence line numbers (end is lines.length when unclosed)
70
+ * - body: raw text inside the fence
71
+ */
72
+ export function findArchitectureBlocks(markdown) {
73
+ const lines = toLines(markdown);
74
+ const blocks = [];
75
+ let i = 0;
76
+ let seen = 0;
77
+ while (i < lines.length) {
78
+ const open = FENCE_OPEN.exec(lines[i]);
79
+ if (!open) {
80
+ i += 1;
81
+ continue;
82
+ }
83
+ const [, indent, marker, info] = open;
84
+ const end = findFenceEnd(lines, i + 1, marker);
85
+ if (info.toLowerCase() === "architecture") {
86
+ blocks.push({
87
+ index: seen,
88
+ open: i,
89
+ end,
90
+ indent,
91
+ body: lines.slice(i + 1, end).join("\n"),
92
+ });
93
+ seen += 1;
94
+ }
95
+ i = end + 1;
96
+ }
97
+ return blocks;
98
+ }
99
+
100
+ /**
101
+ * Return Markdown with the contents of the nth ```architecture fence replaced.
102
+ * Return null rather than throwing when the target is absent so the caller can return 404.
103
+ *
104
+ * Preserve the fence lines themselves (` ``` ` and ` ``` `) and replace only the
105
+ * contents. Apply the opening fence's indentation to the new body.
106
+ *
107
+ * Preserve all lines outside the fence, including newline sequences. This prevents
108
+ * saving CRLF Markdown from converting the entire file to LF and changing every line in git diff.
109
+ */
110
+ export function replaceArchitectureBlock(markdown, blockIndex, source) {
111
+ const blocks = findArchitectureBlocks(markdown);
112
+ const target = blocks.find((b) => b.index === blockIndex);
113
+ if (!target) return null;
114
+ const lines = splitLinesWithEol(markdown);
115
+ // Match inserted newlines to the opening fence line. If that line has no newline,
116
+ // as with an unclosed fence at EOF, use the document's dominant sequence.
117
+ const eol = lines[target.open]?.eol || dominantEol(lines);
118
+ // Remove trailing blank lines before insertion so a final JSON newline does not add blank lines.
119
+ const body = String(source).replace(/\r\n?/g, "\n").replace(/\s+$/, "");
120
+ const inserted = body.length
121
+ ? body
122
+ .split("\n")
123
+ .map((line) => ({ text: target.indent && line ? target.indent + line : line, eol }))
124
+ : [];
125
+ const head = lines.slice(0, target.open + 1);
126
+ const tail = lines.slice(target.end);
127
+ // For an unclosed fence (empty tail), preserve the source's lack of a final newline.
128
+ if (!tail.length && inserted.length) inserted[inserted.length - 1].eol = "";
129
+ return [...head, ...inserted, ...tail].map((line) => line.text + line.eol).join("");
130
+ }
131
+
132
+ /**
133
+ * Convert a slide-local architecture block index to its index in the complete imported Markdown.
134
+ * Return null for appended slides absent from the source file, such as an automatically added back cover.
135
+ */
136
+ export function importedArchitectureBlockIndex(slides, slideIndex, blockIndex) {
137
+ if (
138
+ !Array.isArray(slides) ||
139
+ !Number.isInteger(slideIndex) ||
140
+ !Number.isInteger(blockIndex) ||
141
+ slideIndex < 0 ||
142
+ slideIndex >= slides.length ||
143
+ blockIndex < 0
144
+ ) {
145
+ return null;
146
+ }
147
+ const localBlocks = findArchitectureBlocks(slides[slideIndex]);
148
+ if (blockIndex >= localBlocks.length) return null;
149
+ let globalIndex = blockIndex;
150
+ for (let i = 0; i < slideIndex; i += 1) {
151
+ globalIndex += findArchitectureBlocks(slides[i]).length;
152
+ }
153
+ return globalIndex;
154
+ }
155
+
156
+ /**
157
+ * Replace the target fence in imported Markdown only when it matches the current deck.
158
+ * Fail closed with source_changed if external edits moved or changed the target.
159
+ */
160
+ export function replaceImportedArchitectureBlock(
161
+ markdown,
162
+ slides,
163
+ slideIndex,
164
+ blockIndex,
165
+ source,
166
+ expectedMarkdown = null,
167
+ ) {
168
+ if (typeof expectedMarkdown === "string" && markdown !== expectedMarkdown) {
169
+ return { ok: false, reason: "source_changed" };
170
+ }
171
+ const globalIndex = importedArchitectureBlockIndex(slides, slideIndex, blockIndex);
172
+ if (globalIndex === null) return { ok: false, reason: "block_not_found" };
173
+
174
+ const expected = findArchitectureBlocks(slides[slideIndex])[blockIndex];
175
+ const actual = findArchitectureBlocks(markdown)[globalIndex];
176
+ if (!actual) return { ok: false, reason: "source_changed" };
177
+ if (actual.body !== expected.body) return { ok: false, reason: "source_changed" };
178
+
179
+ const next = replaceArchitectureBlock(markdown, globalIndex, source);
180
+ if (next === null) return { ok: false, reason: "block_not_found" };
181
+ return { ok: true, markdown: next, globalIndex };
182
+ }
@@ -0,0 +1,63 @@
1
+ // Utilities for finding Markdown files in the workspace.
2
+ //
3
+ // Used by the canvas 📂 button (Markdown import). These are pure functions with
4
+ // no SDK or canvas-state dependencies so both extension.mjs and the test
5
+ // harness can import them.
6
+
7
+ import { readdir } from "node:fs/promises";
8
+ import { extname, join, relative, resolve, sep } from "node:path";
9
+
10
+ export const MARKDOWN_EXTENSIONS = new Set([".md", ".markdown"]);
11
+ export const MARKDOWN_SCAN_MAX_FILES = 500;
12
+ export const MARKDOWN_SCAN_MAX_DEPTH = 6;
13
+ export const MARKDOWN_MAX_BYTES = 2 * 1024 * 1024;
14
+
15
+ // Directories that commonly accumulate generated output and rarely contain slide sources.
16
+ // Dot-prefixed names such as .git are excluded as a group and are not listed here.
17
+ const SKIP_DIRS = new Set(["node_modules", "vendor", "out", "dist", "build"]);
18
+
19
+ export function isMarkdownPath(path) {
20
+ return MARKDOWN_EXTENSIONS.has(extname(path).toLowerCase());
21
+ }
22
+
23
+ /**
24
+ * Recursively collect Markdown files under rootDir and return `/`-separated relative paths.
25
+ * Limit the result count and depth so traversal cannot run indefinitely in a large repository.
26
+ *
27
+ * @returns {Promise<{ files: string[], truncated: boolean }>}
28
+ */
29
+ export async function listMarkdownFiles(rootDir) {
30
+ const root = resolve(rootDir);
31
+ const files = [];
32
+ let truncated = false;
33
+
34
+ const walk = async (dir, depth) => {
35
+ if (truncated || depth > MARKDOWN_SCAN_MAX_DEPTH) return;
36
+ let entries;
37
+ try {
38
+ entries = await readdir(dir, { withFileTypes: true });
39
+ } catch (_) {
40
+ return;
41
+ }
42
+ for (const entry of entries) {
43
+ if (truncated) return;
44
+ if (entry.name.startsWith(".")) continue;
45
+ const abs = join(dir, entry.name);
46
+ if (entry.isDirectory()) {
47
+ if (SKIP_DIRS.has(entry.name)) continue;
48
+ await walk(abs, depth + 1);
49
+ continue;
50
+ }
51
+ if (!entry.isFile() || !isMarkdownPath(entry.name)) continue;
52
+ if (files.length >= MARKDOWN_SCAN_MAX_FILES) {
53
+ truncated = true;
54
+ return;
55
+ }
56
+ files.push(relative(root, abs).split(sep).join("/"));
57
+ }
58
+ };
59
+
60
+ await walk(root, 0);
61
+ files.sort((a, b) => a.localeCompare(b));
62
+ return { files, truncated };
63
+ }
@@ -0,0 +1,18 @@
1
+ import { resolve } from "node:path";
2
+
3
+ const queues = new Map();
4
+
5
+ function keyFor(path) {
6
+ const resolved = resolve(path);
7
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
8
+ }
9
+
10
+ export function serializeMarkdownSave(path, operation) {
11
+ const key = keyFor(path);
12
+ const previous = queues.get(key) ?? Promise.resolve();
13
+ const current = previous.catch(() => {}).then(operation);
14
+ queues.set(key, current);
15
+ return current.finally(() => {
16
+ if (queues.get(key) === current) queues.delete(key);
17
+ });
18
+ }
@@ -0,0 +1,80 @@
1
+ import { watch } from "node:fs";
2
+ import { basename, dirname, resolve } from "node:path";
3
+
4
+ export const MARKDOWN_WATCH_DEBOUNCE_MS = 120;
5
+
6
+ function sameFilename(left, right) {
7
+ if (process.platform === "win32") {
8
+ return left.toLocaleLowerCase("en-US") === right.toLocaleLowerCase("en-US");
9
+ }
10
+ return left === right;
11
+ }
12
+
13
+ export function createMarkdownWatcher({
14
+ path,
15
+ onChange,
16
+ onError,
17
+ debounceMs = MARKDOWN_WATCH_DEBOUNCE_MS,
18
+ watchFactory = watch,
19
+ } = {}) {
20
+ if (typeof path !== "string" || !path) throw new TypeError("path is required");
21
+ if (typeof onChange !== "function") throw new TypeError("onChange is required");
22
+
23
+ const target = resolve(path);
24
+ const targetName = basename(target);
25
+ let timer = null;
26
+ let running = false;
27
+ let queued = false;
28
+ let closed = false;
29
+
30
+ const reportError = (error) => {
31
+ if (!closed && typeof onError === "function") onError(error);
32
+ };
33
+
34
+ const run = async () => {
35
+ timer = null;
36
+ if (closed) return;
37
+ if (running) {
38
+ queued = true;
39
+ return;
40
+ }
41
+ running = true;
42
+ try {
43
+ await onChange();
44
+ } catch (error) {
45
+ reportError(error);
46
+ } finally {
47
+ running = false;
48
+ if (queued && !closed) {
49
+ queued = false;
50
+ schedule();
51
+ }
52
+ }
53
+ };
54
+
55
+ const schedule = () => {
56
+ if (closed) return;
57
+ if (timer) clearTimeout(timer);
58
+ timer = setTimeout(run, Math.max(0, debounceMs));
59
+ };
60
+
61
+ const watcher = watchFactory(dirname(target), { persistent: false }, (_eventType, filename) => {
62
+ const changedName = filename == null ? "" : String(filename);
63
+ if (changedName && !sameFilename(changedName, targetName)) return;
64
+ schedule();
65
+ });
66
+ watcher.on("error", reportError);
67
+
68
+ return {
69
+ refresh: schedule,
70
+ close() {
71
+ if (closed) return;
72
+ closed = true;
73
+ if (timer) {
74
+ clearTimeout(timer);
75
+ timer = null;
76
+ }
77
+ watcher.close();
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,108 @@
1
+ import { realpath, stat } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
3
+
4
+ import { isPathInside } from "./asset-paths.mjs";
5
+
6
+ function fail(code, message) {
7
+ const error = new Error(message);
8
+ error.code = code;
9
+ throw error;
10
+ }
11
+
12
+ function pathKey(path) {
13
+ const normalized = normalize(resolve(path));
14
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
15
+ }
16
+
17
+ function themeRootCandidates(workspaceRoot, sourcePath = "") {
18
+ const workspace = resolve(workspaceRoot);
19
+ const roots = [];
20
+ const seen = new Set();
21
+ const add = (path) => {
22
+ const root = resolve(path);
23
+ const key = pathKey(root);
24
+ if (seen.has(key)) return;
25
+ seen.add(key);
26
+ roots.push(root);
27
+ };
28
+
29
+ if (typeof sourcePath === "string" && sourcePath.trim()) {
30
+ const source = resolve(workspace, sourcePath.trim());
31
+ if (!isPathInside(workspace, source)) {
32
+ fail("theme_source_outside_workspace", "The theme source must stay inside the workspace.");
33
+ }
34
+ add(dirname(source));
35
+ }
36
+ add(workspace);
37
+ return roots;
38
+ }
39
+
40
+ function safeJoin(rootDir, rel) {
41
+ if (typeof rel !== "string" || !rel.trim() || isAbsolute(rel) || rel.includes("\0")) {
42
+ return null;
43
+ }
44
+ const root = resolve(rootDir);
45
+ const candidate = normalize(join(root, rel.trim()));
46
+ return isPathInside(root, candidate) ? candidate : null;
47
+ }
48
+
49
+ export function themeFileCandidates(workspaceRoot, sourcePath, themePath) {
50
+ const roots = themeRootCandidates(workspaceRoot, sourcePath);
51
+ return roots.map((root) => {
52
+ const candidate = safeJoin(root, themePath);
53
+ if (!candidate) {
54
+ fail(
55
+ "invalid_theme_path",
56
+ "The theme path must be relative and stay inside a theme search root.",
57
+ );
58
+ }
59
+ return candidate;
60
+ });
61
+ }
62
+
63
+ export async function resolveThemeFile(workspaceRoot, sourcePath, themePath) {
64
+ const roots = themeRootCandidates(workspaceRoot, sourcePath);
65
+ const candidates = themeFileCandidates(workspaceRoot, sourcePath, themePath);
66
+ let canonicalWorkspace;
67
+ try {
68
+ canonicalWorkspace = await realpath(resolve(workspaceRoot));
69
+ } catch (_) {
70
+ fail("workspace_not_found", "The workspace root is unavailable.");
71
+ }
72
+
73
+ for (let index = 0; index < roots.length; index += 1) {
74
+ let canonicalRoot;
75
+ try {
76
+ canonicalRoot = await realpath(roots[index]);
77
+ } catch (error) {
78
+ if (error?.code === "ENOENT") continue;
79
+ fail("theme_root_unavailable", "A theme search root is unavailable.");
80
+ }
81
+ if (!isPathInside(canonicalWorkspace, canonicalRoot)) {
82
+ fail("theme_root_outside_workspace", "Theme search roots must resolve inside the workspace.");
83
+ }
84
+
85
+ let canonicalTheme;
86
+ try {
87
+ canonicalTheme = await realpath(candidates[index]);
88
+ } catch (error) {
89
+ if (error?.code === "ENOENT") continue;
90
+ fail("theme_file_unavailable", "The requested theme file is unavailable.");
91
+ }
92
+ if (
93
+ !isPathInside(canonicalWorkspace, canonicalTheme) ||
94
+ !isPathInside(canonicalRoot, canonicalTheme)
95
+ ) {
96
+ fail(
97
+ "theme_file_outside_workspace",
98
+ "The requested theme file must resolve inside its theme search root.",
99
+ );
100
+ }
101
+ const info = await stat(canonicalTheme);
102
+ if (!info.isFile()) {
103
+ fail("theme_file_unavailable", "The requested theme path is not a file.");
104
+ }
105
+ return canonicalTheme;
106
+ }
107
+ return null;
108
+ }
@@ -0,0 +1,132 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ readFile,
4
+ writeFile,
5
+ mkdir,
6
+ } from "node:fs/promises";
7
+ import { join, resolve } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ export const DEFAULT_CHUNK_SIZE = 512 * 1024;
11
+ export const MANIFEST_NAME = "vendor-assets.lock.json";
12
+
13
+ function sha256(buffer) {
14
+ return createHash("sha256").update(buffer).digest("hex");
15
+ }
16
+
17
+ function chunkName(sourceName, index) {
18
+ return `${sourceName}.part-${String(index + 1).padStart(4, "0")}`;
19
+ }
20
+
21
+ export async function readManifest(manifestPath) {
22
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
23
+ if (!manifest || manifest.schemaVersion !== 1 || !manifest.assets) {
24
+ throw new Error(`Invalid vendor asset manifest: ${manifestPath}`);
25
+ }
26
+ return manifest;
27
+ }
28
+
29
+ export async function reconstructAsset(vendorDir, assetName, manifestPath) {
30
+ const manifest = await readManifest(manifestPath);
31
+ const asset = manifest.assets[assetName];
32
+ if (!asset || !Array.isArray(asset.chunks) || asset.chunks.length === 0) {
33
+ throw new Error(`Missing manifest entry for vendor asset: ${assetName}`);
34
+ }
35
+ const chunks = [];
36
+ let total = 0;
37
+ for (const [index, entry] of asset.chunks.entries()) {
38
+ if (entry.index !== index + 1 || typeof entry.file !== "string") {
39
+ throw new Error(`Invalid chunk ordering for ${assetName}`);
40
+ }
41
+ const data = await readFile(join(vendorDir, entry.file));
42
+ if (data.length !== entry.size || sha256(data) !== entry.sha256) {
43
+ throw new Error(`Integrity check failed for ${entry.file}`);
44
+ }
45
+ if (data.length > manifest.chunkSize) {
46
+ throw new Error(`Chunk exceeds configured size: ${entry.file}`);
47
+ }
48
+ chunks.push(data);
49
+ total += data.length;
50
+ }
51
+ const result = Buffer.concat(chunks);
52
+ if (total !== asset.size || result.length !== asset.size || sha256(result) !== asset.sha256) {
53
+ throw new Error(`Integrity check failed for reconstructed ${assetName}`);
54
+ }
55
+ return result;
56
+ }
57
+
58
+ async function splitAsset(sourcePath, vendorDir, manifestPath, assetName, upstreamVersion) {
59
+ const source = await readFile(sourcePath);
60
+ const chunkSize = DEFAULT_CHUNK_SIZE;
61
+ const chunks = [];
62
+ await mkdir(vendorDir, { recursive: true });
63
+ for (let offset = 0, index = 0; offset < source.length; offset += chunkSize, index += 1) {
64
+ const data = source.subarray(offset, Math.min(offset + chunkSize, source.length));
65
+ const file = chunkName(assetName, index);
66
+ await writeFile(join(vendorDir, file), data);
67
+ chunks.push({
68
+ index: index + 1,
69
+ file,
70
+ size: data.length,
71
+ sha256: sha256(data),
72
+ });
73
+ }
74
+ const manifest = {
75
+ schemaVersion: 1,
76
+ chunkSize,
77
+ assets: {
78
+ [assetName]: {
79
+ source: assetName,
80
+ size: source.length,
81
+ sha256: sha256(source),
82
+ upstream: {
83
+ name: "mermaid",
84
+ version: upstreamVersion,
85
+ source: "https://www.npmjs.com/package/mermaid",
86
+ },
87
+ chunks,
88
+ },
89
+ },
90
+ };
91
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
92
+ }
93
+
94
+ async function verifyManifest(vendorDir, manifestPath) {
95
+ const manifest = await readManifest(manifestPath);
96
+ for (const assetName of Object.keys(manifest.assets)) {
97
+ await reconstructAsset(vendorDir, assetName, manifestPath);
98
+ }
99
+ return manifest;
100
+ }
101
+
102
+ function usage() {
103
+ console.error(
104
+ "Usage: node vendor-assets.mjs split <source> <vendor-dir> <manifest> [upstream-version]\n" +
105
+ " node vendor-assets.mjs verify <vendor-dir> <manifest>",
106
+ );
107
+ }
108
+
109
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
110
+ const [, , command, ...args] = process.argv;
111
+ try {
112
+ if (command === "split" && args.length >= 3) {
113
+ await splitAsset(
114
+ resolve(args[0]),
115
+ resolve(args[1]),
116
+ resolve(args[2]),
117
+ "mermaid.min.js",
118
+ args[3] || "unknown",
119
+ );
120
+ console.log(`Split mermaid.min.js into ${DEFAULT_CHUNK_SIZE}-byte chunks.`);
121
+ } else if (command === "verify" && args.length === 2) {
122
+ const manifest = await verifyManifest(resolve(args[0]), resolve(args[1]));
123
+ console.log(`Verified ${Object.keys(manifest.assets).length} vendor asset(s).`);
124
+ } else {
125
+ usage();
126
+ process.exitCode = 2;
127
+ }
128
+ } catch (error) {
129
+ console.error(error.message);
130
+ process.exitCode = 1;
131
+ }
132
+ }
@@ -0,0 +1,32 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+
5
+ export function resolveWorkspaceRoot(workingDirectory, fallbackRoot) {
6
+ const fallback = resolve(fallbackRoot);
7
+ if (!workingDirectory) return fallback;
8
+
9
+ const workspace = resolve(workingDirectory);
10
+ if (!existsSync(workspace)) return fallback;
11
+
12
+ try {
13
+ const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
14
+ cwd: workspace,
15
+ encoding: "utf8",
16
+ stdio: ["ignore", "pipe", "ignore"],
17
+ }).trim();
18
+ if (root) return resolve(root);
19
+ } catch (_) {
20
+ /* not a Git repository / Git unavailable — inspect parent markers */
21
+ }
22
+
23
+ let directory = workspace;
24
+ for (;;) {
25
+ if (existsSync(join(directory, ".git"))) return directory;
26
+ const parent = dirname(directory);
27
+ if (parent === directory) break;
28
+ directory = parent;
29
+ }
30
+
31
+ return workspace;
32
+ }
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2006, Ivan Sagalaev.
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ * Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ * Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ * Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.