@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,119 @@
1
+ {
2
+ "$schema": "../architecture-v1.schema.json",
3
+ "version": 1,
4
+ "canvas": { "width": 1600, "height": 900 },
5
+ "title": "Web application overview",
6
+ "description": "A three-tier web application: clients, the application platform, and stored state.",
7
+ "elements": [
8
+ {
9
+ "type": "group",
10
+ "id": "clients",
11
+ "title": "Clients",
12
+ "x": 80,
13
+ "y": 220,
14
+ "width": 360,
15
+ "height": 420,
16
+ "layout": { "type": "column", "gap": 36, "padding": 54 },
17
+ "children": [
18
+ {
19
+ "type": "node",
20
+ "id": "browser",
21
+ "shape": "rounded-rect",
22
+ "icon": "user",
23
+ "text": "Browser"
24
+ },
25
+ {
26
+ "type": "node",
27
+ "id": "mobile",
28
+ "shape": "rounded-rect",
29
+ "icon": "user",
30
+ "text": "Mobile app"
31
+ }
32
+ ]
33
+ },
34
+ {
35
+ "type": "group",
36
+ "id": "platform",
37
+ "title": "Application platform",
38
+ "x": 560,
39
+ "y": 180,
40
+ "width": 480,
41
+ "height": 500,
42
+ "layout": { "type": "column", "gap": 36, "padding": 54 },
43
+ "style": { "stroke": "accentLine" },
44
+ "children": [
45
+ {
46
+ "type": "node",
47
+ "id": "gateway",
48
+ "shape": "rounded-rect",
49
+ "icon": "api",
50
+ "text": "API gateway"
51
+ },
52
+ {
53
+ "type": "node",
54
+ "id": "service",
55
+ "shape": "rounded-rect",
56
+ "icon": "server",
57
+ "text": "Order service"
58
+ },
59
+ {
60
+ "type": "node",
61
+ "id": "worker",
62
+ "shape": "rounded-rect",
63
+ "icon": "cloud",
64
+ "text": "Background worker"
65
+ }
66
+ ]
67
+ },
68
+ {
69
+ "type": "node",
70
+ "id": "database",
71
+ "shape": "rounded-rect",
72
+ "icon": "database",
73
+ "text": "Managed database",
74
+ "ariaLabel": "Managed relational database",
75
+ "x": 1160,
76
+ "y": 300,
77
+ "width": 320,
78
+ "height": 160,
79
+ "style": { "fill": "accentSoft" }
80
+ },
81
+ {
82
+ "type": "connector",
83
+ "from": "browser",
84
+ "to": "gateway",
85
+ "label": "HTTPS",
86
+ "routing": "orthogonal"
87
+ },
88
+ {
89
+ "type": "connector",
90
+ "from": "mobile",
91
+ "to": "gateway",
92
+ "label": "HTTPS",
93
+ "routing": "orthogonal",
94
+ "lane": 1
95
+ },
96
+ {
97
+ "type": "connector",
98
+ "from": "gateway",
99
+ "to": "service",
100
+ "routing": "straight"
101
+ },
102
+ {
103
+ "type": "connector",
104
+ "from": "service",
105
+ "to": "database",
106
+ "label": "SQL",
107
+ "routing": "orthogonal"
108
+ },
109
+ {
110
+ "type": "connector",
111
+ "from": "worker",
112
+ "to": "database",
113
+ "label": "batch",
114
+ "routing": "orthogonal",
115
+ "lane": -1,
116
+ "style": { "dash": "8 6" }
117
+ }
118
+ ]
119
+ }
@@ -0,0 +1,75 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/runceel/markdstage/schema/theme-metadata-v1.schema.json",
4
+ "title": "Presentation custom theme metadata v1",
5
+ "description": "Optional theme.json manifest stored beside a custom theme CSS file.",
6
+ "type": "object",
7
+ "required": ["version"],
8
+ "properties": {
9
+ "$schema": {
10
+ "type": "string"
11
+ },
12
+ "version": {
13
+ "const": 1
14
+ },
15
+ "cover": {
16
+ "type": "object",
17
+ "properties": {
18
+ "background": {
19
+ "$ref": "#/$defs/decorativeImage"
20
+ },
21
+ "logo": {
22
+ "$ref": "#/$defs/meaningfulImage"
23
+ }
24
+ },
25
+ "additionalProperties": false
26
+ },
27
+ "backcover": {
28
+ "type": "object",
29
+ "properties": {
30
+ "logo": {
31
+ "$ref": "#/$defs/meaningfulImage"
32
+ },
33
+ "copyright": {
34
+ "type": "string"
35
+ }
36
+ },
37
+ "additionalProperties": false
38
+ }
39
+ },
40
+ "additionalProperties": false,
41
+ "$defs": {
42
+ "assetPath": {
43
+ "type": "string",
44
+ "maxLength": 200,
45
+ "pattern": "^assets/(?:[A-Za-z0-9][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*/)*[A-Za-z0-9][A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*\\.(?:svg|png|webp|jpg|jpeg)$"
46
+ },
47
+ "decorativeImage": {
48
+ "type": "object",
49
+ "required": ["image"],
50
+ "properties": {
51
+ "image": {
52
+ "$ref": "#/$defs/assetPath"
53
+ },
54
+ "alt": {
55
+ "type": "string"
56
+ }
57
+ },
58
+ "additionalProperties": false
59
+ },
60
+ "meaningfulImage": {
61
+ "type": "object",
62
+ "required": ["image", "alt"],
63
+ "properties": {
64
+ "image": {
65
+ "$ref": "#/$defs/assetPath"
66
+ },
67
+ "alt": {
68
+ "type": "string",
69
+ "minLength": 1
70
+ }
71
+ },
72
+ "additionalProperties": false
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,84 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/runceel/markdstage/schema/theme-v1.json",
4
+ "title": "MarkdStage custom theme v1",
5
+ "description": "Machine-readable catalog for CSS custom properties supported by the MarkdStage canvas custom theme. Optional cover/backcover assets live in a sibling theme.json manifest.",
6
+ "type": "object",
7
+ "required": [
8
+ "version",
9
+ "variables"
10
+ ],
11
+ "properties": {
12
+ "version": {
13
+ "const": 1
14
+ },
15
+ "variables": {
16
+ "type": "object",
17
+ "description": "CSS custom property declarations. The runtime accepts any name matching the pattern.",
18
+ "properties": {
19
+ "--bg": { "type": "string", "description": "Standard slide background" },
20
+ "--fg": { "type": "string", "description": "Headings and primary text" },
21
+ "--muted": { "type": "string", "description": "Secondary text" },
22
+ "--body": { "type": "string", "description": "Body text" },
23
+ "--accent": { "type": "string", "description": "Primary accent" },
24
+ "--accent-strong": { "type": "string", "description": "Strong accent" },
25
+ "--accent-soft": { "type": "string", "description": "Subtle accent background" },
26
+ "--accent-line": { "type": "string", "description": "Accent line" },
27
+ "--surface": { "type": "string", "description": "Cards and surfaces" },
28
+ "--code": { "type": "string", "description": "Code block background" },
29
+ "--code-fg": { "type": "string", "description": "Code block text" },
30
+ "--border": { "type": "string", "description": "Border" },
31
+ "--syntax-comment": { "type": "string", "description": "Code comments" },
32
+ "--syntax-keyword": { "type": "string", "description": "Code keywords" },
33
+ "--syntax-string": { "type": "string", "description": "Code strings" },
34
+ "--syntax-number": { "type": "string", "description": "Code numbers" },
35
+ "--syntax-title": { "type": "string", "description": "Code titles" },
36
+ "--syntax-type": { "type": "string", "description": "Code types" },
37
+ "--syntax-meta": { "type": "string", "description": "Code metadata" },
38
+ "--syntax-variable": { "type": "string", "description": "Code variables" },
39
+ "--syntax-addition": { "type": "string", "description": "Added-line text color" },
40
+ "--syntax-addition-bg": { "type": "string", "description": "Added-line background" },
41
+ "--syntax-deletion": { "type": "string", "description": "Deleted-line text color" },
42
+ "--syntax-deletion-bg": { "type": "string", "description": "Deleted-line background" },
43
+ "--glow-1": { "type": "string", "description": "Background glow 1" },
44
+ "--glow-2": { "type": "string", "description": "Background glow 2" },
45
+ "--topbar": { "type": "string", "description": "Top bar background" },
46
+ "--kicker-mark": { "type": "string", "description": "Kicker mark" },
47
+ "--cover-bg": { "type": "string", "description": "Cover background" },
48
+ "--cover-topbar": { "type": "string", "description": "Top bar background used only on the cover" },
49
+ "--cover-text-align": { "type": "string", "description": "Cover body text alignment" },
50
+ "--cover-content-align": { "type": "string", "description": "Horizontal alignment within the cover body" },
51
+ "--cover-content-self": { "type": "string", "description": "Cover body area alignment" },
52
+ "--cover-content-width": { "type": "string", "description": "Cover body area width" },
53
+ "--cover-logo-width": { "type": "string", "description": "Cover logo width" },
54
+ "--section-bg": { "type": "string", "description": "Section-divider background" },
55
+ "--backcover-bg": { "type": "string", "description": "Back-cover background" },
56
+ "--backcover-logo-width": { "type": "string", "description": "Back-cover logo width" },
57
+ "--print-slide-bg": { "type": "string", "description": "Standard PDF page background" },
58
+ "--print-cover-bg": { "type": "string", "description": "PDF cover background" },
59
+ "--print-section-bg": { "type": "string", "description": "PDF section-divider background" },
60
+ "--ms-font": { "type": "string", "description": "Font for Microsoft-style themes" },
61
+ "--ms-red": { "type": "string", "description": "Microsoft red" },
62
+ "--ms-green": { "type": "string", "description": "Microsoft green" },
63
+ "--ms-blue": { "type": "string", "description": "Microsoft blue" },
64
+ "--ms-yellow": { "type": "string", "description": "Microsoft yellow" },
65
+ "--deck-pad-y": { "type": "string", "description": "Vertical slide padding" },
66
+ "--deck-pad-x": { "type": "string", "description": "Horizontal slide padding" },
67
+ "--slide-h1-size": { "type": "string", "description": "H1 size" },
68
+ "--slide-h2-size": { "type": "string", "description": "H2 size" },
69
+ "--slide-h3-size": { "type": "string", "description": "H3 size" },
70
+ "--slide-body-size": { "type": "string", "description": "Body text size" },
71
+ "--slide-code-size": { "type": "string", "description": "Code text size" }
72
+ },
73
+ "patternProperties": {
74
+ "^--[A-Za-z0-9_-]+$": { "type": "string" }
75
+ },
76
+ "additionalProperties": false,
77
+ "minProperties": 1
78
+ }
79
+ },
80
+ "additionalProperties": false,
81
+ "x-theme-file-format": "CSS custom property declarations, optionally wrapped in one :root block.",
82
+ "x-value-syntax": "Any non-empty CSS value except selectors, @import, url(), javascript:, expression(), and style tags.",
83
+ "x-theme-metadata": "When present, theme.json beside the CSS file must conform to theme-metadata-v1.schema.json."
84
+ }
@@ -0,0 +1,226 @@
1
+ import { open, mkdir, readdir, realpath, stat, unlink } from "node:fs/promises";
2
+ import { extname, join, relative, resolve, sep } from "node:path";
3
+
4
+ import {
5
+ ASSET_EXTENSIONS,
6
+ ASSET_PATH_PATTERN,
7
+ } from "../renderer/architecture.mjs";
8
+ import {
9
+ assetRootCandidates,
10
+ isPathInside,
11
+ } from "./asset-paths.mjs";
12
+
13
+ export const ARCHITECTURE_ASSET_MAX_BYTES = 10 * 1024 * 1024;
14
+ export const ARCHITECTURE_ASSET_LIST_MAX = 1_000;
15
+
16
+ const CONTENT_TYPES = Object.freeze({
17
+ svg: new Set(["image/svg+xml", "text/xml", "application/xml"]),
18
+ png: new Set(["image/png"]),
19
+ webp: new Set(["image/webp"]),
20
+ jpg: new Set(["image/jpeg"]),
21
+ jpeg: new Set(["image/jpeg"]),
22
+ });
23
+
24
+ function fail(code, message) {
25
+ const error = new Error(message);
26
+ error.code = code;
27
+ throw error;
28
+ }
29
+
30
+ async function canonicalAssetRoot(
31
+ workspaceRoot,
32
+ { create = false, requestedAssets = "" } = {},
33
+ ) {
34
+ let canonicalWorkspace;
35
+ try {
36
+ canonicalWorkspace = await realpath(resolve(workspaceRoot));
37
+ } catch (_) {
38
+ fail("workspace_not_found", "The workspace root is unavailable.");
39
+ }
40
+ const assetsPath = requestedAssets || join(canonicalWorkspace, "assets");
41
+ if (create) await mkdir(assetsPath, { recursive: true });
42
+ let canonicalAssets;
43
+ try {
44
+ canonicalAssets = await realpath(assetsPath);
45
+ } catch (error) {
46
+ if (!create && error?.code === "ENOENT") {
47
+ return { workspace: canonicalWorkspace, assets: null };
48
+ }
49
+ fail("asset_root_unavailable", "The workspace assets folder is unavailable.");
50
+ }
51
+ if (!isPathInside(canonicalWorkspace, canonicalAssets)) {
52
+ fail(
53
+ "asset_root_outside_workspace",
54
+ "The assets folder must resolve inside the workspace.",
55
+ );
56
+ }
57
+ const info = await stat(canonicalAssets);
58
+ if (!info.isDirectory()) {
59
+ fail("asset_root_unavailable", "The workspace assets path is not a directory.");
60
+ }
61
+ return { workspace: canonicalWorkspace, assets: canonicalAssets };
62
+ }
63
+
64
+ function supportedExtension(filename) {
65
+ const extension = extname(filename).slice(1).toLowerCase();
66
+ return ASSET_EXTENSIONS.includes(extension) ? extension : "";
67
+ }
68
+
69
+ function assertContentType(extension, contentType) {
70
+ const normalized = String(contentType || "")
71
+ .split(";", 1)[0]
72
+ .trim()
73
+ .toLowerCase();
74
+ if (!normalized || normalized === "application/octet-stream") return;
75
+ if (!CONTENT_TYPES[extension]?.has(normalized)) {
76
+ fail(
77
+ "asset_content_type_mismatch",
78
+ `The ${normalized} content type does not match .${extension}.`,
79
+ );
80
+ }
81
+ }
82
+
83
+ function hasSupportedSignature(content, extension) {
84
+ if (extension === "png") {
85
+ return (
86
+ content.length >= 8 &&
87
+ content.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
88
+ );
89
+ }
90
+ if (extension === "jpg" || extension === "jpeg") {
91
+ return (
92
+ content.length >= 3 &&
93
+ content[0] === 0xff &&
94
+ content[1] === 0xd8 &&
95
+ content[2] === 0xff
96
+ );
97
+ }
98
+ if (extension === "webp") {
99
+ return (
100
+ content.length >= 12 &&
101
+ content.subarray(0, 4).toString("ascii") === "RIFF" &&
102
+ content.subarray(8, 12).toString("ascii") === "WEBP"
103
+ );
104
+ }
105
+ if (extension === "svg") {
106
+ const source = content.subarray(0, Math.min(content.length, 16 * 1024)).toString("utf8");
107
+ if (/<!doctype/i.test(source)) return false;
108
+ return /^\uFEFF?\s*(?:<\?xml[^>]*>\s*)?(?:<!--[\s\S]*?-->\s*)*<svg(?:\s|>)/i.test(
109
+ source,
110
+ );
111
+ }
112
+ return false;
113
+ }
114
+
115
+ export function normalizeArchitectureAssetName(filename) {
116
+ const extension = supportedExtension(filename);
117
+ if (!extension) {
118
+ fail(
119
+ "unsupported_asset_type",
120
+ "Only SVG, PNG, WebP, JPG, and JPEG images can be imported.",
121
+ );
122
+ }
123
+ const rawStem = String(filename || "").slice(0, -extname(filename).length);
124
+ const stem =
125
+ rawStem
126
+ .normalize("NFKD")
127
+ .replace(/[\u0300-\u036f]/g, "")
128
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
129
+ .replace(/^[^A-Za-z0-9]+/, "")
130
+ .replace(/[-_.]+$/, "")
131
+ .slice(0, 160) || "image";
132
+ return { stem, extension };
133
+ }
134
+
135
+ export async function listArchitectureAssets(workspaceRoot, sourcePath = "") {
136
+ const assets = [];
137
+ const seen = new Set();
138
+
139
+ const walk = async (root, directory, relativeDirectory = "") => {
140
+ if (assets.length >= ARCHITECTURE_ASSET_LIST_MAX) return;
141
+ let entries = await readdir(directory, { withFileTypes: true });
142
+ entries = entries.sort((left, right) => left.name.localeCompare(right.name, "en"));
143
+ for (const entry of entries) {
144
+ if (assets.length >= ARCHITECTURE_ASSET_LIST_MAX) break;
145
+ if (entry.isSymbolicLink()) continue;
146
+ const relativePath = relativeDirectory
147
+ ? `${relativeDirectory}/${entry.name}`
148
+ : entry.name;
149
+ const candidate = join(directory, entry.name);
150
+ if (entry.isDirectory()) {
151
+ await walk(root, candidate, relativePath);
152
+ continue;
153
+ }
154
+ if (!entry.isFile() || !supportedExtension(entry.name)) continue;
155
+ const assetPath = `assets/${relativePath.split(sep).join("/")}`;
156
+ if (!ASSET_PATH_PATTERN.test(assetPath)) continue;
157
+ let canonicalFile;
158
+ try {
159
+ canonicalFile = await realpath(candidate);
160
+ } catch (_) {
161
+ continue;
162
+ }
163
+ if (!isPathInside(root.workspace, canonicalFile) || !isPathInside(root.assets, canonicalFile)) {
164
+ continue;
165
+ }
166
+ const info = await stat(canonicalFile);
167
+ if (!info.isFile() || info.size > ARCHITECTURE_ASSET_MAX_BYTES) continue;
168
+ const assetKey = process.platform === "win32" ? assetPath.toLowerCase() : assetPath;
169
+ if (seen.has(assetKey)) continue;
170
+ seen.add(assetKey);
171
+ assets.push({ path: assetPath, size: info.size });
172
+ }
173
+ };
174
+
175
+ for (const requestedAssets of assetRootCandidates(workspaceRoot, sourcePath)) {
176
+ const root = await canonicalAssetRoot(workspaceRoot, { requestedAssets });
177
+ if (root.assets) await walk(root, root.assets);
178
+ }
179
+ return assets;
180
+ }
181
+
182
+ export async function importArchitectureAsset(
183
+ workspaceRoot,
184
+ { filename, contentType, content },
185
+ ) {
186
+ if (!Buffer.isBuffer(content) || content.length === 0) {
187
+ fail("empty_asset", "Choose a non-empty image file.");
188
+ }
189
+ if (content.length > ARCHITECTURE_ASSET_MAX_BYTES) {
190
+ fail("asset_too_large", "Images must be 10 MB or smaller.");
191
+ }
192
+ const { stem, extension } = normalizeArchitectureAssetName(filename);
193
+ assertContentType(extension, contentType);
194
+ if (!hasSupportedSignature(content, extension)) {
195
+ fail(
196
+ "asset_signature_mismatch",
197
+ `The file contents do not match the .${extension} image format.`,
198
+ );
199
+ }
200
+ const root = await canonicalAssetRoot(workspaceRoot, { create: true });
201
+ for (let index = 1; index < 10_000; index += 1) {
202
+ const suffix = index === 1 ? "" : `-${index}`;
203
+ const name = `${stem}${suffix}.${extension}`;
204
+ const assetPath = `assets/${name}`;
205
+ if (!ASSET_PATH_PATTERN.test(assetPath)) {
206
+ fail("invalid_asset_name", "The imported image name is not safe for Architecture DSL.");
207
+ }
208
+ const target = join(root.assets, name);
209
+ let handle;
210
+ try {
211
+ handle = await open(target, "wx");
212
+ await handle.writeFile(content);
213
+ await handle.sync();
214
+ await handle.close();
215
+ return { path: assetPath, size: content.length };
216
+ } catch (error) {
217
+ if (handle) {
218
+ await handle.close().catch(() => {});
219
+ await unlink(target).catch(() => {});
220
+ }
221
+ if (error?.code === "EEXIST") continue;
222
+ fail("asset_write_failed", error?.message || "The image could not be imported.");
223
+ }
224
+ }
225
+ fail("asset_name_exhausted", "A unique filename could not be allocated.");
226
+ }
@@ -0,0 +1,92 @@
1
+ import { realpath, stat } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
3
+
4
+ function fail(code, message) {
5
+ const error = new Error(message);
6
+ error.code = code;
7
+ throw error;
8
+ }
9
+
10
+ export function isPathInside(root, candidate) {
11
+ const rel = relative(resolve(root), resolve(candidate));
12
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
13
+ }
14
+
15
+ function pathKey(path) {
16
+ const normalized = normalize(resolve(path));
17
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
18
+ }
19
+
20
+ export function assetRootCandidates(workspaceRoot, sourcePath = "") {
21
+ const workspace = resolve(workspaceRoot);
22
+ const roots = [];
23
+ const seen = new Set();
24
+ const add = (path) => {
25
+ const key = pathKey(path);
26
+ if (seen.has(key)) return;
27
+ seen.add(key);
28
+ roots.push(resolve(path));
29
+ };
30
+
31
+ if (typeof sourcePath === "string" && sourcePath.trim()) {
32
+ const source = resolve(workspace, sourcePath.trim());
33
+ if (!isPathInside(workspace, source)) {
34
+ fail("asset_source_outside_workspace", "The asset source must stay inside the workspace.");
35
+ }
36
+ add(join(dirname(source), "assets"));
37
+ }
38
+ add(join(workspace, "assets"));
39
+ return roots;
40
+ }
41
+
42
+ function safeJoin(rootDir, rel) {
43
+ if (typeof rel !== "string" || !rel || isAbsolute(rel) || rel.includes("\0")) return null;
44
+ const root = resolve(rootDir);
45
+ const candidate = normalize(join(root, rel));
46
+ return isPathInside(root, candidate) ? candidate : null;
47
+ }
48
+
49
+ export async function resolveAssetFile(workspaceRoot, sourcePath, assetPath) {
50
+ const requestedRoots = assetRootCandidates(workspaceRoot, sourcePath);
51
+ let canonicalWorkspace;
52
+ try {
53
+ canonicalWorkspace = await realpath(resolve(workspaceRoot));
54
+ } catch (_) {
55
+ fail("workspace_not_found", "The workspace root is unavailable.");
56
+ }
57
+
58
+ for (const requestedRoot of requestedRoots) {
59
+ const candidate = safeJoin(requestedRoot, assetPath);
60
+ if (!candidate) {
61
+ fail("invalid_asset_path", "The asset path must stay inside an assets folder.");
62
+ }
63
+
64
+ let canonicalRoot;
65
+ try {
66
+ canonicalRoot = await realpath(requestedRoot);
67
+ } catch (error) {
68
+ if (error?.code === "ENOENT") continue;
69
+ fail("asset_root_unavailable", "An assets folder is unavailable.");
70
+ }
71
+ if (!isPathInside(canonicalWorkspace, canonicalRoot)) {
72
+ fail("asset_root_outside_workspace", "Assets folders must resolve inside the workspace.");
73
+ }
74
+
75
+ let canonicalAsset;
76
+ try {
77
+ canonicalAsset = await realpath(candidate);
78
+ } catch (error) {
79
+ if (error?.code === "ENOENT") continue;
80
+ fail("asset_unavailable", "The requested asset is unavailable.");
81
+ }
82
+ if (
83
+ !isPathInside(canonicalWorkspace, canonicalAsset) ||
84
+ !isPathInside(canonicalRoot, canonicalAsset)
85
+ ) {
86
+ fail("asset_outside_workspace", "The requested asset must resolve inside its assets folder.");
87
+ }
88
+ const info = await stat(canonicalAsset);
89
+ if (info.isFile()) return canonicalAsset;
90
+ }
91
+ return null;
92
+ }
@@ -0,0 +1,46 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { open, readFile, rename, rm, stat } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+
5
+ function sourceChangedError() {
6
+ const error = new Error("source_changed");
7
+ error.code = "SOURCE_CHANGED";
8
+ return error;
9
+ }
10
+
11
+ export async function atomicReplaceMarkdown({
12
+ path,
13
+ markdown,
14
+ expectedMarkdown,
15
+ mode,
16
+ revalidate,
17
+ }) {
18
+ const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
19
+ let handle = null;
20
+ try {
21
+ const expectedInfo = await stat(path);
22
+ handle = await open(temporary, "wx", mode);
23
+ await handle.writeFile(markdown, "utf8");
24
+ await handle.sync();
25
+ await handle.close();
26
+ handle = null;
27
+ await revalidate();
28
+ const currentInfo = await stat(path);
29
+ if (
30
+ currentInfo.dev !== expectedInfo.dev ||
31
+ currentInfo.ino !== expectedInfo.ino ||
32
+ currentInfo.size !== expectedInfo.size ||
33
+ currentInfo.mtimeMs !== expectedInfo.mtimeMs ||
34
+ currentInfo.ctimeMs !== expectedInfo.ctimeMs
35
+ ) {
36
+ throw sourceChangedError();
37
+ }
38
+ if ((await readFile(path, "utf8")) !== expectedMarkdown) {
39
+ throw sourceChangedError();
40
+ }
41
+ await rename(temporary, path);
42
+ } finally {
43
+ await handle?.close().catch(() => {});
44
+ await rm(temporary, { force: true }).catch(() => {});
45
+ }
46
+ }