@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.
- package/README.md +90 -0
- package/bin/markdstage.mjs +12 -0
- package/package.json +45 -0
- package/shared/README.md +1014 -0
- package/shared/THIRD-PARTY-NOTICES.md +19 -0
- package/shared/deck-state.mjs +105 -0
- package/shared/docs/custom-theme-authoring.md +208 -0
- package/shared/markdown-deck.mjs +220 -0
- package/shared/markdstage-guide.mjs +276 -0
- package/shared/presenter-window.mjs +17 -0
- package/shared/renderer/architecture-document.mjs +596 -0
- package/shared/renderer/architecture-edit.mjs +298 -0
- package/shared/renderer/architecture-editor.mjs +449 -0
- package/shared/renderer/architecture.mjs +4033 -0
- package/shared/renderer/import-path.mjs +11 -0
- package/shared/renderer/index.html +106 -0
- package/shared/renderer/renderer.js +2082 -0
- package/shared/renderer/slides.css +614 -0
- package/shared/renderer/speaker-notes.mjs +106 -0
- package/shared/renderer/theme.mjs +205 -0
- package/shared/runtime/browser.mjs +539 -0
- package/shared/runtime/custom-theme.mjs +135 -0
- package/shared/runtime/deck-session.mjs +188 -0
- package/shared/runtime/errors.mjs +17 -0
- package/shared/runtime/output-paths.mjs +159 -0
- package/shared/runtime/output.mjs +385 -0
- package/shared/runtime/presentation-server.mjs +505 -0
- package/shared/runtime/static-files.mjs +70 -0
- package/shared/schema/README.md +228 -0
- package/shared/schema/architecture-v1.schema.json +664 -0
- package/shared/schema/examples/web-app.architecture.json +119 -0
- package/shared/schema/theme-metadata-v1.schema.json +75 -0
- package/shared/schema/theme-v1.json +84 -0
- package/shared/scripts/architecture-assets.mjs +226 -0
- package/shared/scripts/asset-paths.mjs +92 -0
- package/shared/scripts/atomic-markdown-replace.mjs +46 -0
- package/shared/scripts/markdown-blocks.mjs +182 -0
- package/shared/scripts/markdown-files.mjs +63 -0
- package/shared/scripts/markdown-save-coordinator.mjs +18 -0
- package/shared/scripts/markdown-watcher.mjs +80 -0
- package/shared/scripts/theme-paths.mjs +108 -0
- package/shared/scripts/vendor-assets.mjs +132 -0
- package/shared/scripts/workspace-root.mjs +32 -0
- package/shared/vendor/highlight.LICENSE +29 -0
- package/shared/vendor/highlight.min.js +1244 -0
- package/shared/vendor/marked.min.js +6 -0
- package/shared/vendor/mermaid.min.js.part-0001 +268 -0
- package/shared/vendor/mermaid.min.js.part-0002 +304 -0
- package/shared/vendor/mermaid.min.js.part-0003 +324 -0
- package/shared/vendor/mermaid.min.js.part-0004 +374 -0
- package/shared/vendor/mermaid.min.js.part-0005 +564 -0
- package/shared/vendor/mermaid.min.js.part-0006 +1308 -0
- package/shared/vendor/mermaid.min.js.part-0007 +269 -0
- package/shared/vendor/purify.min.js +3 -0
- package/shared/vendor/vendor-assets.lock.json +60 -0
- package/src/cli.mjs +347 -0
- package/src/commands/capture.mjs +23 -0
- package/src/commands/export.mjs +18 -0
- package/src/commands/guide.mjs +23 -0
- package/src/commands/inspect.mjs +35 -0
- package/src/commands/present.mjs +91 -0
- package/src/commands/skill.mjs +114 -0
- package/src/commands/validate.mjs +79 -0
- package/src/deck.mjs +63 -0
- package/src/exit.mjs +58 -0
- package/src/runtime.mjs +77 -0
- package/src/skills.mjs +155 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Canvas-independent deck session.
|
|
2
|
+
//
|
|
3
|
+
// A session owns the deck (slides split from one Markdown file), the resolved
|
|
4
|
+
// theme, and the current slide index. It carries exactly the fields the shared
|
|
5
|
+
// output runtime (PDF export, PNG capture, layout inspection) expects, so the
|
|
6
|
+
// CLI and the Canvas Extension drive the same implementation.
|
|
7
|
+
|
|
8
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
9
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
10
|
+
import { buildDeckSlides } from "../markdown-deck.mjs";
|
|
11
|
+
import { ensureBackCover } from "../deck-state.mjs";
|
|
12
|
+
import { MARKDOWN_MAX_BYTES, isMarkdownPath } from "../scripts/markdown-files.mjs";
|
|
13
|
+
import { resolveWorkspaceRoot } from "../scripts/workspace-root.mjs";
|
|
14
|
+
import { DEFAULT_THEME, normalizeTheme, resolveFrontMatterTheme } from "../renderer/theme.mjs";
|
|
15
|
+
import { MarkdStageError } from "./errors.mjs";
|
|
16
|
+
import { loadCustomTheme } from "./custom-theme.mjs";
|
|
17
|
+
import { isPathInside } from "./output-paths.mjs";
|
|
18
|
+
|
|
19
|
+
export function clampIndex(value, total) {
|
|
20
|
+
let index = Number(value);
|
|
21
|
+
if (!Number.isFinite(index)) return 0;
|
|
22
|
+
index = Math.trunc(index);
|
|
23
|
+
if (total <= 0) return 0;
|
|
24
|
+
if (index < 0) return 0;
|
|
25
|
+
if (index >= total) return total - 1;
|
|
26
|
+
return index;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Front matter selects the theme unless the caller passes an explicit one; an
|
|
30
|
+
// explicit theme locks the deck so per-slide front matter cannot override it.
|
|
31
|
+
export function resolveDeckTheme({ slides, explicitTheme, explicitThemeFile }) {
|
|
32
|
+
const frontMatter = resolveFrontMatterTheme(slides);
|
|
33
|
+
const hasExplicitTheme = typeof explicitTheme === "string" && explicitTheme.trim().length > 0;
|
|
34
|
+
const theme = hasExplicitTheme ? normalizeTheme(explicitTheme) : frontMatter.theme;
|
|
35
|
+
const themeFile = explicitThemeFile?.trim() || frontMatter.themeFile;
|
|
36
|
+
return {
|
|
37
|
+
theme: themeFile && (!hasExplicitTheme || theme === "custom") ? "custom" : theme,
|
|
38
|
+
themeFile,
|
|
39
|
+
themeLocked: hasExplicitTheme,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function resolveDeckFile(file, workspaceRoot) {
|
|
44
|
+
if (typeof file !== "string" || !file.trim()) {
|
|
45
|
+
throw new MarkdStageError("invalid_input", "A Markdown file path is required.");
|
|
46
|
+
}
|
|
47
|
+
const absolute = resolve(file);
|
|
48
|
+
if (!isMarkdownPath(absolute)) {
|
|
49
|
+
throw new MarkdStageError(
|
|
50
|
+
"invalid_markdown_path",
|
|
51
|
+
`Only .md and .markdown files can be presented: ${file}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
let canonicalFile;
|
|
55
|
+
let canonicalRoot;
|
|
56
|
+
try {
|
|
57
|
+
[canonicalFile, canonicalRoot] = await Promise.all([
|
|
58
|
+
realpath(absolute),
|
|
59
|
+
realpath(workspaceRoot),
|
|
60
|
+
]);
|
|
61
|
+
} catch (_) {
|
|
62
|
+
throw new MarkdStageError("file_not_found", `Could not read Markdown file: ${file}`);
|
|
63
|
+
}
|
|
64
|
+
if (!isPathInside(canonicalRoot, canonicalFile)) {
|
|
65
|
+
throw new MarkdStageError(
|
|
66
|
+
"path_outside_workspace",
|
|
67
|
+
`Markdown files must stay inside the workspace (${canonicalRoot}).`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const info = await stat(canonicalFile);
|
|
71
|
+
if (!info.isFile()) {
|
|
72
|
+
throw new MarkdStageError("file_not_found", `Not a file: ${file}`);
|
|
73
|
+
}
|
|
74
|
+
if (info.size > MARKDOWN_MAX_BYTES) {
|
|
75
|
+
throw new MarkdStageError(
|
|
76
|
+
"file_too_large",
|
|
77
|
+
`Markdown files must be ${MARKDOWN_MAX_BYTES} bytes or smaller: ${file}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return { path: canonicalFile, workspaceRoot: canonicalRoot };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function readDeckSlides(path) {
|
|
84
|
+
const markdown = await readFile(path, "utf8");
|
|
85
|
+
const slides = buildDeckSlides(markdown);
|
|
86
|
+
if (!slides.length) {
|
|
87
|
+
throw new MarkdStageError("empty_markdown", `The Markdown file has no slides: ${path}`);
|
|
88
|
+
}
|
|
89
|
+
return { markdown, slides };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function workspaceRelative(root, path) {
|
|
93
|
+
const rel = relative(root, path);
|
|
94
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return "";
|
|
95
|
+
return rel.split(sep).join("/");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build a deck session for one Markdown file.
|
|
100
|
+
*
|
|
101
|
+
* `assetUrlPrefix` lets the presentation server serve theme assets below its
|
|
102
|
+
* unguessable per-process URL token.
|
|
103
|
+
*/
|
|
104
|
+
export async function createDeckSession({
|
|
105
|
+
file,
|
|
106
|
+
workspaceRoot,
|
|
107
|
+
theme,
|
|
108
|
+
themeFile,
|
|
109
|
+
assetUrlPrefix = "/theme-assets/",
|
|
110
|
+
log,
|
|
111
|
+
} = {}) {
|
|
112
|
+
// An explicit --workspace wins; otherwise confine the deck to its Git
|
|
113
|
+
// repository root (or the folder holding the Markdown file).
|
|
114
|
+
const deckDirectory = file ? resolve(file, "..") : process.cwd();
|
|
115
|
+
const root = workspaceRoot
|
|
116
|
+
? resolve(workspaceRoot)
|
|
117
|
+
: resolveWorkspaceRoot(deckDirectory, deckDirectory);
|
|
118
|
+
const resolved = await resolveDeckFile(file, root);
|
|
119
|
+
const session = {
|
|
120
|
+
file: resolved.path,
|
|
121
|
+
workspaceRoot: resolved.workspaceRoot,
|
|
122
|
+
sourceName: workspaceRelative(resolved.workspaceRoot, resolved.path),
|
|
123
|
+
url: "",
|
|
124
|
+
version: 0,
|
|
125
|
+
deckVersion: 0,
|
|
126
|
+
markdown: "",
|
|
127
|
+
slides: [],
|
|
128
|
+
index: 0,
|
|
129
|
+
mode: "deck",
|
|
130
|
+
theme: DEFAULT_THEME,
|
|
131
|
+
themeLocked: false,
|
|
132
|
+
customThemeFile: "",
|
|
133
|
+
customThemeCss: "",
|
|
134
|
+
customThemeDir: "",
|
|
135
|
+
customThemeMeta: null,
|
|
136
|
+
customThemeAssets: new Set(),
|
|
137
|
+
exportJobs: new Map(),
|
|
138
|
+
exporting: false,
|
|
139
|
+
clients: new Set(),
|
|
140
|
+
requestedTheme: theme,
|
|
141
|
+
requestedThemeFile: themeFile,
|
|
142
|
+
assetUrlPrefix,
|
|
143
|
+
log,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
session.load = async ({ preserveIndex = false } = {}) => {
|
|
147
|
+
const { slides } = await readDeckSlides(session.file);
|
|
148
|
+
const selection = resolveDeckTheme({
|
|
149
|
+
slides,
|
|
150
|
+
explicitTheme: session.requestedTheme,
|
|
151
|
+
explicitThemeFile: session.requestedThemeFile,
|
|
152
|
+
});
|
|
153
|
+
const custom =
|
|
154
|
+
selection.theme === "custom"
|
|
155
|
+
? await loadCustomTheme(
|
|
156
|
+
session.workspaceRoot,
|
|
157
|
+
session.sourceName,
|
|
158
|
+
selection.themeFile,
|
|
159
|
+
{ assetUrlPrefix: session.assetUrlPrefix },
|
|
160
|
+
)
|
|
161
|
+
: { file: "", css: "", dir: "", metadata: null, assets: [] };
|
|
162
|
+
session.theme = selection.theme;
|
|
163
|
+
session.themeLocked = selection.themeLocked;
|
|
164
|
+
session.customThemeFile = custom.file;
|
|
165
|
+
session.customThemeCss = custom.css;
|
|
166
|
+
session.customThemeDir = custom.dir;
|
|
167
|
+
session.customThemeMeta = custom.metadata;
|
|
168
|
+
session.customThemeAssets = new Set(custom.assets);
|
|
169
|
+
session.slides = ensureBackCover(slides.slice());
|
|
170
|
+
session.index = clampIndex(preserveIndex ? session.index : 0, session.slides.length);
|
|
171
|
+
session.markdown = session.slides[session.index] ?? "";
|
|
172
|
+
session.deckVersion += 1;
|
|
173
|
+
session.version += 1;
|
|
174
|
+
return session.slides.length;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
session.navigate = (target) => {
|
|
178
|
+
const next = clampIndex(target, session.slides.length);
|
|
179
|
+
if (next === session.index) return false;
|
|
180
|
+
session.index = next;
|
|
181
|
+
session.markdown = session.slides[session.index] ?? "";
|
|
182
|
+
session.version += 1;
|
|
183
|
+
return true;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
await session.load();
|
|
187
|
+
return session;
|
|
188
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Runtime error contract shared by the Canvas Extension and the MarkdStage CLI.
|
|
2
|
+
//
|
|
3
|
+
// Canvas code translates MarkdStageError into CanvasError; the CLI translates it
|
|
4
|
+
// into human-readable (or JSON) output plus a stable exit code. Keeping a single
|
|
5
|
+
// error type in the runtime avoids importing the Copilot SDK outside the canvas.
|
|
6
|
+
|
|
7
|
+
export class MarkdStageError extends Error {
|
|
8
|
+
constructor(code, message) {
|
|
9
|
+
super(message || code);
|
|
10
|
+
this.name = "MarkdStageError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isMarkdStageError(error) {
|
|
16
|
+
return error instanceof MarkdStageError;
|
|
17
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Workspace-confined output path resolution shared by the Canvas Extension and
|
|
2
|
+
// the MarkdStage CLI.
|
|
3
|
+
//
|
|
4
|
+
// Every generated file (PDF, PNG) must land inside the resolved workspace, and
|
|
5
|
+
// no intermediate directory may traverse a symlink or junction that escapes it.
|
|
6
|
+
|
|
7
|
+
import { mkdir, realpath, stat } from "node:fs/promises";
|
|
8
|
+
import {
|
|
9
|
+
basename,
|
|
10
|
+
dirname,
|
|
11
|
+
extname,
|
|
12
|
+
isAbsolute,
|
|
13
|
+
join,
|
|
14
|
+
relative,
|
|
15
|
+
resolve,
|
|
16
|
+
sep,
|
|
17
|
+
} from "node:path";
|
|
18
|
+
import { MarkdStageError } from "./errors.mjs";
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_PDF_NAME = "markdstage.pdf";
|
|
21
|
+
export const DEFAULT_CAPTURE_DIR = "markdstage-previews";
|
|
22
|
+
|
|
23
|
+
function safeBaseName(sourceName) {
|
|
24
|
+
const sourceBase = basename(typeof sourceName === "string" ? sourceName.trim() : "");
|
|
25
|
+
const withoutExtension = sourceBase.replace(/\.(?:md|markdown)$/i, "");
|
|
26
|
+
return withoutExtension
|
|
27
|
+
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
|
|
28
|
+
.trim()
|
|
29
|
+
.replace(/[. ]+$/, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function pdfNameForSource(sourceName) {
|
|
33
|
+
return `${safeBaseName(sourceName) || basename(DEFAULT_PDF_NAME, ".pdf")}.pdf`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function captureDirectoryName(sourceName) {
|
|
37
|
+
const safeBase = safeBaseName(sourceName);
|
|
38
|
+
return safeBase ? `${safeBase}-previews` : DEFAULT_CAPTURE_DIR;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isPathInside(root, candidate) {
|
|
42
|
+
const rootRelative = relative(root, candidate);
|
|
43
|
+
return (
|
|
44
|
+
rootRelative === "" ||
|
|
45
|
+
(!rootRelative.startsWith(`..${sep}`) &&
|
|
46
|
+
rootRelative !== ".." &&
|
|
47
|
+
!isAbsolute(rootRelative))
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resolveWorkspaceOutputPath(
|
|
52
|
+
workspaceRoot,
|
|
53
|
+
requestedPath,
|
|
54
|
+
{ defaultName, extension, label },
|
|
55
|
+
) {
|
|
56
|
+
const root = resolve(workspaceRoot);
|
|
57
|
+
const requested =
|
|
58
|
+
typeof requestedPath === "string" && requestedPath.trim()
|
|
59
|
+
? requestedPath.trim()
|
|
60
|
+
: defaultName;
|
|
61
|
+
if (requested.includes("\0")) {
|
|
62
|
+
throw new MarkdStageError(
|
|
63
|
+
"invalid_output_path",
|
|
64
|
+
`${label} output path contains an invalid character.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const outputPath = resolve(root, requested);
|
|
69
|
+
const workspaceRelative = relative(root, outputPath);
|
|
70
|
+
if (
|
|
71
|
+
workspaceRelative === "" ||
|
|
72
|
+
workspaceRelative === ".." ||
|
|
73
|
+
workspaceRelative.startsWith(`..${sep}`) ||
|
|
74
|
+
isAbsolute(workspaceRelative)
|
|
75
|
+
) {
|
|
76
|
+
throw new MarkdStageError(
|
|
77
|
+
"invalid_output_path",
|
|
78
|
+
`${label} output path must be a file inside the current workspace.`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (extname(outputPath).toLowerCase() !== extension) {
|
|
82
|
+
throw new MarkdStageError(
|
|
83
|
+
"invalid_output_path",
|
|
84
|
+
`${label} output path must end with ${extension}.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return outputPath;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function resolvePdfOutputPath(workspaceRoot, requestedPath) {
|
|
91
|
+
return resolveWorkspaceOutputPath(workspaceRoot, requestedPath, {
|
|
92
|
+
defaultName: DEFAULT_PDF_NAME,
|
|
93
|
+
extension: ".pdf",
|
|
94
|
+
label: "PDF",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function resolveCaptureOutputDirectory(workspaceRoot, sourceName, requestedPath) {
|
|
99
|
+
const root = resolve(workspaceRoot);
|
|
100
|
+
const requested =
|
|
101
|
+
typeof requestedPath === "string" && requestedPath.trim()
|
|
102
|
+
? requestedPath.trim()
|
|
103
|
+
: captureDirectoryName(sourceName);
|
|
104
|
+
if (requested.includes("\0")) {
|
|
105
|
+
throw new MarkdStageError(
|
|
106
|
+
"invalid_output_path",
|
|
107
|
+
"PNG output directory contains an invalid character.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const outputDirectory = resolve(root, requested);
|
|
111
|
+
const workspaceRelative = relative(root, outputDirectory);
|
|
112
|
+
if (
|
|
113
|
+
workspaceRelative === ".." ||
|
|
114
|
+
workspaceRelative.startsWith(`..${sep}`) ||
|
|
115
|
+
isAbsolute(workspaceRelative)
|
|
116
|
+
) {
|
|
117
|
+
throw new MarkdStageError(
|
|
118
|
+
"invalid_output_path",
|
|
119
|
+
"PNG output directory must be inside the current workspace.",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return outputDirectory;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function prepareWorkspaceDirectory(workspaceRoot, outputParent, label) {
|
|
126
|
+
const root = resolve(workspaceRoot);
|
|
127
|
+
const canonicalWorkspaceRoot = await realpath(root);
|
|
128
|
+
const relativeParent = relative(root, outputParent);
|
|
129
|
+
let current = root;
|
|
130
|
+
|
|
131
|
+
for (const segment of relativeParent.split(sep).filter(Boolean)) {
|
|
132
|
+
current = join(current, segment);
|
|
133
|
+
try {
|
|
134
|
+
await mkdir(current);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error?.code !== "EEXIST") throw error;
|
|
137
|
+
}
|
|
138
|
+
const canonicalCurrent = await realpath(current);
|
|
139
|
+
if (!isPathInside(canonicalWorkspaceRoot, canonicalCurrent)) {
|
|
140
|
+
throw new MarkdStageError(
|
|
141
|
+
"invalid_output_path",
|
|
142
|
+
`${label} output path must not traverse a link outside the current workspace.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const info = await stat(current);
|
|
146
|
+
if (!info.isDirectory()) {
|
|
147
|
+
throw new MarkdStageError(
|
|
148
|
+
"invalid_output_path",
|
|
149
|
+
`${label} output parent must be a directory inside the current workspace.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return outputParent;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function preparePdfOutputDirectory(workspaceRoot, outputPath) {
|
|
158
|
+
return prepareWorkspaceDirectory(workspaceRoot, dirname(outputPath), "PDF");
|
|
159
|
+
}
|