@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,19 @@
|
|
|
1
|
+
# Third-Party Notices
|
|
2
|
+
|
|
3
|
+
This extension bundles the following open-source software under `vendor/`. Use each package
|
|
4
|
+
in accordance with its license terms, copyright notices, and disclaimers. Consult each
|
|
5
|
+
project's distribution and linked source for the latest license and complete copyright notices.
|
|
6
|
+
|
|
7
|
+
| Open-source software | License | Bundled file | Source |
|
|
8
|
+
| --- | --- | --- | --- |
|
|
9
|
+
| marked | MIT License | `vendor/marked.min.js` | <https://github.com/markedjs/marked> |
|
|
10
|
+
| DOMPurify | Apache-2.0 / MPL-2.0 | `vendor/purify.min.js` | <https://github.com/cure53/DOMPurify> |
|
|
11
|
+
| highlight.js | MIT License | `vendor/highlight.min.js` | <https://github.com/highlightjs/highlight.js> |
|
|
12
|
+
| Mermaid | MIT License | `vendor/mermaid.min.js` | <https://github.com/mermaid-js/mermaid> |
|
|
13
|
+
|
|
14
|
+
The highlight.js license text is also bundled in
|
|
15
|
+
[`vendor/highlight.LICENSE`](./vendor/highlight.LICENSE). Because DOMPurify's upstream
|
|
16
|
+
distribution terms provide multiple license options, users should review the upstream LICENSE.
|
|
17
|
+
|
|
18
|
+
Include this file in this repository's extension distribution. Do not remove copyright notices
|
|
19
|
+
or license links for bundled open-source software from release ZIP files or other distributions.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const DEFAULT_BACKCOVER = ["---", "layout: backcover", "---", ""].join("\n");
|
|
2
|
+
|
|
3
|
+
export const OPEN_INPUT_REQUIRES_SLIDES_MESSAGE =
|
|
4
|
+
"Non-empty open input must include slides (a non-empty array of strings). " +
|
|
5
|
+
"To refocus the current canvas, call open_canvas with no input. " +
|
|
6
|
+
"To replace the registered snapshot, pass slides or call load_deck. " +
|
|
7
|
+
"sourceName is metadata for asset/theme resolution and output naming; it never reads or watches a Markdown file.";
|
|
8
|
+
|
|
9
|
+
function readLayout(markdown) {
|
|
10
|
+
if (typeof markdown !== "string") return "";
|
|
11
|
+
const text = markdown
|
|
12
|
+
.replace(/\r\n/g, "\n")
|
|
13
|
+
.replace(/\r/g, "\n")
|
|
14
|
+
.replace(/^[\n \t\uFEFF]+/, "");
|
|
15
|
+
if (!text.startsWith("---\n")) return "";
|
|
16
|
+
const lines = text.split("\n");
|
|
17
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
18
|
+
if (lines[i].trim() === "---") break;
|
|
19
|
+
const separator = lines[i].indexOf(":");
|
|
20
|
+
if (separator <= 0) continue;
|
|
21
|
+
if (lines[i].slice(0, separator).trim().toLowerCase() !== "layout") continue;
|
|
22
|
+
return lines[i]
|
|
23
|
+
.slice(separator + 1)
|
|
24
|
+
.trim()
|
|
25
|
+
.replace(/^["']+|["']+$/g, "")
|
|
26
|
+
.toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ensureBackCover(slides) {
|
|
32
|
+
if (!slides.length) return slides;
|
|
33
|
+
if (readLayout(slides[slides.length - 1]) === "backcover") return slides;
|
|
34
|
+
return [...slides, DEFAULT_BACKCOVER];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sameSlides(left, right) {
|
|
38
|
+
return left.length === right.length && left.every((slide, index) => slide === right[index]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clampIndex(value, total) {
|
|
42
|
+
const index = Number(value);
|
|
43
|
+
if (!Number.isFinite(index) || total <= 0) return 0;
|
|
44
|
+
return Math.max(0, Math.min(Math.trunc(index), total - 1));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function classifyOpenInput(input) {
|
|
48
|
+
if (input === undefined || input === null) return { kind: "refocus" };
|
|
49
|
+
if (typeof input !== "object" || Array.isArray(input)) {
|
|
50
|
+
return {
|
|
51
|
+
kind: "invalid",
|
|
52
|
+
message: "MarkdStage open input must be an object when provided.",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (Object.keys(input).length === 0) return { kind: "refocus" };
|
|
56
|
+
if (!Object.prototype.hasOwnProperty.call(input, "slides")) {
|
|
57
|
+
return { kind: "invalid", message: OPEN_INPUT_REQUIRES_SLIDES_MESSAGE };
|
|
58
|
+
}
|
|
59
|
+
const slides = input.slides;
|
|
60
|
+
if (
|
|
61
|
+
!Array.isArray(slides) ||
|
|
62
|
+
slides.length === 0 ||
|
|
63
|
+
!slides.every((slide) => typeof slide === "string")
|
|
64
|
+
) {
|
|
65
|
+
return {
|
|
66
|
+
kind: "invalid",
|
|
67
|
+
message: "slides must be a non-empty array of strings when provided to open",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { kind: "deck", slides };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function planDeckOpen(
|
|
74
|
+
currentSlides,
|
|
75
|
+
incomingSlides,
|
|
76
|
+
{ hasThemeInput = false, hasSourceInput = false } = {},
|
|
77
|
+
) {
|
|
78
|
+
const normalizedCurrent = ensureBackCover(currentSlides.slice());
|
|
79
|
+
const normalizedIncoming = ensureBackCover(incomingSlides.slice());
|
|
80
|
+
const sameDeck = sameSlides(normalizedCurrent, normalizedIncoming);
|
|
81
|
+
return {
|
|
82
|
+
sameDeck,
|
|
83
|
+
shouldApply:
|
|
84
|
+
currentSlides.length === 0 || !sameDeck || hasThemeInput || hasSourceInput,
|
|
85
|
+
preserveCurrentIndex: sameDeck,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getExportSlides(inst) {
|
|
90
|
+
if (inst.slides.length) {
|
|
91
|
+
const slides = [...inst.slides];
|
|
92
|
+
if (inst.mode === "adhoc" && typeof inst.markdown === "string") {
|
|
93
|
+
slides[clampIndex(inst.index, slides.length)] = inst.markdown;
|
|
94
|
+
}
|
|
95
|
+
return slides;
|
|
96
|
+
}
|
|
97
|
+
if (inst.mode === "adhoc" && typeof inst.markdown === "string") {
|
|
98
|
+
return [inst.markdown];
|
|
99
|
+
}
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function getOutputSnapshotSlides(inst) {
|
|
104
|
+
return ensureBackCover(getExportSlides(inst));
|
|
105
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# Custom theme authoring guide
|
|
2
|
+
|
|
3
|
+
This guide is a reference for people who create `custom` themes for the
|
|
4
|
+
MarkdStage canvas and for AI systems that generate themes.
|
|
5
|
+
|
|
6
|
+
## Minimum setup
|
|
7
|
+
|
|
8
|
+
Themes can be managed by folder. Put only custom properties in the CSS, and
|
|
9
|
+
define optional cover and back-cover assets in `theme.json` in the same folder.
|
|
10
|
+
|
|
11
|
+
```markdown
|
|
12
|
+
---
|
|
13
|
+
theme: custom
|
|
14
|
+
theme-file: themes/brand/theme.css
|
|
15
|
+
---
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
A minimal theme folder has the following structure. `theme.json` is optional;
|
|
19
|
+
without it, the theme works as a traditional CSS-only theme.
|
|
20
|
+
|
|
21
|
+
```text
|
|
22
|
+
themes/brand/
|
|
23
|
+
theme.css
|
|
24
|
+
theme.json
|
|
25
|
+
assets/
|
|
26
|
+
cover.svg
|
|
27
|
+
logo.svg
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`theme-file` resolves the same relative path in this order:
|
|
31
|
+
|
|
32
|
+
1. The same folder as the source Markdown
|
|
33
|
+
2. The workspace root
|
|
34
|
+
|
|
35
|
+
For example, place `themes/brand/theme.css` at the workspace root as a shared
|
|
36
|
+
theme for every deck. To override only `decks/quarterly/slides.md`, add
|
|
37
|
+
`decks/quarterly/themes/brand/theme.css`. Both Markdown files can specify
|
|
38
|
+
`theme-file: themes/brand/theme.css`; the file beside the Markdown takes
|
|
39
|
+
precedence. When AI registers a deck with `open` / `load_deck`, it passes the
|
|
40
|
+
source Markdown's workspace-relative path as `sourceName` for relative resolution.
|
|
41
|
+
|
|
42
|
+
```css
|
|
43
|
+
--bg: #101820;
|
|
44
|
+
--fg: #ffffff;
|
|
45
|
+
--body: #d7e3ef;
|
|
46
|
+
--accent: #00a4ef;
|
|
47
|
+
--surface: #182b3a;
|
|
48
|
+
--border: #31536b;
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
You may also wrap declarations in `:root { ... }`.
|
|
52
|
+
|
|
53
|
+
```css
|
|
54
|
+
:root {
|
|
55
|
+
--bg: #101820;
|
|
56
|
+
--accent: #00a4ef;
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Available properties
|
|
61
|
+
|
|
62
|
+
### Base slide colors
|
|
63
|
+
|
|
64
|
+
| Property | Purpose |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| `--bg` | Standard slide background |
|
|
67
|
+
| `--fg` | Headings and primary text |
|
|
68
|
+
| `--muted` | Secondary text |
|
|
69
|
+
| `--body` | Body text |
|
|
70
|
+
| `--accent` | Primary accent |
|
|
71
|
+
| `--accent-strong` | Strong accent |
|
|
72
|
+
| `--accent-soft` | Subtle accent background |
|
|
73
|
+
| `--accent-line` | Accent line |
|
|
74
|
+
| `--surface` | Cards and surfaces |
|
|
75
|
+
| `--code` | Code block background |
|
|
76
|
+
| `--code-fg` | Code block text |
|
|
77
|
+
| `--border` | Borders |
|
|
78
|
+
|
|
79
|
+
### Code syntax
|
|
80
|
+
|
|
81
|
+
`--syntax-comment`, `--syntax-keyword`, `--syntax-string`,
|
|
82
|
+
`--syntax-number`, `--syntax-title`, `--syntax-type`,
|
|
83
|
+
`--syntax-meta`, and `--syntax-variable` color corresponding code elements.
|
|
84
|
+
|
|
85
|
+
For diff rendering, use `--syntax-addition`, `--syntax-addition-bg`,
|
|
86
|
+
`--syntax-deletion`, and `--syntax-deletion-bg`.
|
|
87
|
+
|
|
88
|
+
### Decoration and covers
|
|
89
|
+
|
|
90
|
+
| Property | Purpose |
|
|
91
|
+
| --- | --- |
|
|
92
|
+
| `--glow-1` / `--glow-2` | Background glows |
|
|
93
|
+
| `--topbar` | Top bar background |
|
|
94
|
+
| `--kicker-mark` | Kicker mark |
|
|
95
|
+
| `--cover-bg` | Cover background |
|
|
96
|
+
| `--cover-topbar` | Top bar background used only on the cover |
|
|
97
|
+
| `--cover-text-align` | Cover body text alignment |
|
|
98
|
+
| `--cover-content-align` | Horizontal alignment within the cover body |
|
|
99
|
+
| `--cover-content-self` | Cover body area alignment |
|
|
100
|
+
| `--cover-content-width` | Cover body area width |
|
|
101
|
+
| `--cover-logo-width` | Cover logo width |
|
|
102
|
+
| `--section-bg` | Section-divider background |
|
|
103
|
+
| `--backcover-bg` | Back-cover background |
|
|
104
|
+
| `--backcover-logo-width` | Back-cover logo width |
|
|
105
|
+
| `--print-slide-bg` | Standard PDF page background |
|
|
106
|
+
| `--print-cover-bg` | PDF cover background |
|
|
107
|
+
| `--print-section-bg` | PDF section-divider background |
|
|
108
|
+
| `--ms-font` | Font for Microsoft-style themes |
|
|
109
|
+
|
|
110
|
+
`--ms-red`, `--ms-green`, `--ms-blue`, and `--ms-yellow` are also available as
|
|
111
|
+
supporting brand colors.
|
|
112
|
+
|
|
113
|
+
`--section-bg` and `--print-section-bg` are used as CSS `background` values.
|
|
114
|
+
They support solid colors and multiple comma-separated gradients. Theme-file
|
|
115
|
+
security restrictions prohibit `url()`. Section dividers have no image, logo,
|
|
116
|
+
or icon settings in `theme.json`.
|
|
117
|
+
|
|
118
|
+
## theme.json
|
|
119
|
+
|
|
120
|
+
A `theme.json` file in the CSS folder is loaded automatically. Specify image
|
|
121
|
+
paths relative to `theme.json` using the `assets/...` form.
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"$schema": "../../.github/extensions/markdstage/schema/theme-metadata-v1.schema.json",
|
|
126
|
+
"version": 1,
|
|
127
|
+
"cover": {
|
|
128
|
+
"background": { "image": "assets/cover.svg" },
|
|
129
|
+
"logo": { "image": "assets/logo.svg", "alt": "Example" }
|
|
130
|
+
},
|
|
131
|
+
"backcover": {
|
|
132
|
+
"logo": { "image": "assets/logo-light.svg", "alt": "Example" },
|
|
133
|
+
"copyright": "Copyright Example"
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- `cover.background` is decorative, so `alt` is optional.
|
|
139
|
+
- Logo `alt` text is required.
|
|
140
|
+
- Supported formats are SVG / PNG / WebP / JPEG, with a 2 MiB limit per file.
|
|
141
|
+
- Absolute paths, external URLs, `..`, and symbolic links outside the theme
|
|
142
|
+
folder are rejected.
|
|
143
|
+
- If an existing `theme.json` is invalid, loading returns an error rather than
|
|
144
|
+
silently falling back to CSS only.
|
|
145
|
+
- Slide-front-matter `logo` / `copyright` values override back-cover metadata.
|
|
146
|
+
|
|
147
|
+
### Sizing and spacing
|
|
148
|
+
|
|
149
|
+
You may change `--deck-pad-y`, `--deck-pad-x`, `--slide-h1-size`,
|
|
150
|
+
`--slide-h2-size`, `--slide-h3-size`, `--slide-body-size`, and
|
|
151
|
+
`--slide-code-size`.
|
|
152
|
+
|
|
153
|
+
Values may use CSS units and functions such as `px`, `rem`, and `clamp(...)`.
|
|
154
|
+
|
|
155
|
+
## Authoring considerations
|
|
156
|
+
|
|
157
|
+
- Runtime accepts arbitrary custom properties beginning with `--`, but only the
|
|
158
|
+
names documented here are used by standard layouts.
|
|
159
|
+
- Values cannot be empty.
|
|
160
|
+
- Selectors, `@import`, `url(...)`, `javascript:`, and `expression(...)` are prohibited.
|
|
161
|
+
- Do not put arbitrary CSS rules or JavaScript in a theme file.
|
|
162
|
+
- Keep theme files in the workspace and use relative paths that resolve safely
|
|
163
|
+
from the Markdown or workspace root.
|
|
164
|
+
- The maximum theme file size is 64 KiB.
|
|
165
|
+
- The maximum `theme.json` size is 64 KiB.
|
|
166
|
+
- When a property appears more than once, the final value wins.
|
|
167
|
+
- Gradients are supported in addition to solid colors. Verify contrast for body
|
|
168
|
+
and secondary text.
|
|
169
|
+
- The same theme applies to PDF output.
|
|
170
|
+
|
|
171
|
+
## Example
|
|
172
|
+
|
|
173
|
+
```css
|
|
174
|
+
:root {
|
|
175
|
+
--bg: #0b1320;
|
|
176
|
+
--fg: #f7fbff;
|
|
177
|
+
--muted: #9bb0c6;
|
|
178
|
+
--body: #d9e7f2;
|
|
179
|
+
--accent: #42d3ff;
|
|
180
|
+
--accent-strong: #a6f36b;
|
|
181
|
+
--accent-soft: rgb(66 211 255 / 14%);
|
|
182
|
+
--accent-line: rgb(66 211 255 / 45%);
|
|
183
|
+
--surface: #14263a;
|
|
184
|
+
--code: #101e2e;
|
|
185
|
+
--code-fg: #c6e6ff;
|
|
186
|
+
--border: #2d4d68;
|
|
187
|
+
--topbar: linear-gradient(90deg, #42d3ff, #a6f36b);
|
|
188
|
+
--kicker-mark: linear-gradient(135deg, #42d3ff, #a6f36b);
|
|
189
|
+
--cover-bg: linear-gradient(145deg, #102b48, #101729);
|
|
190
|
+
--cover-text-align: left;
|
|
191
|
+
--cover-content-align: flex-start;
|
|
192
|
+
--cover-content-width: 62%;
|
|
193
|
+
--section-bg:
|
|
194
|
+
linear-gradient(55deg, transparent 76%, #42d3ff 77%, transparent 78%),
|
|
195
|
+
radial-gradient(110% 55% at 60% 115%, #42d3ff, #315b32 42%, transparent 72%),
|
|
196
|
+
#0b1320;
|
|
197
|
+
--print-section-bg: var(--section-bg);
|
|
198
|
+
--backcover-bg: linear-gradient(145deg, #0d6b91, #315b32);
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Guidance for AI theme selection
|
|
203
|
+
|
|
204
|
+
- When explicit brand colors are requested, use `custom` with `theme-file`.
|
|
205
|
+
- When built-in themes satisfy a request such as "bright" or "dark," use
|
|
206
|
+
`light` or `dark` rather than creating a custom theme.
|
|
207
|
+
- Before creating a custom theme, retrieve `theme-schema` from
|
|
208
|
+
`markdstage_guide` and verify property names.
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// Split one Markdown file into an array of Markdown fragments, one per slide.
|
|
2
|
+
//
|
|
3
|
+
// Splitting was previously the skill's (generative AI's) responsibility. The
|
|
4
|
+
// extension now also provides deterministic splitting so the canvas can import
|
|
5
|
+
// Markdown directly. It does not summarize prose or decide how to adapt content
|
|
6
|
+
// for slides; it only splits content and combines front matter.
|
|
7
|
+
//
|
|
8
|
+
// Keep this free of runtime npm dependencies because the extension is distributed as a ZIP.
|
|
9
|
+
|
|
10
|
+
// Opening fence: three or more ` or ~ characters, as in marked; inspect only one info word.
|
|
11
|
+
const FENCE_OPEN = /^([ \t]{0,3})(`{3,}|~{3,})[ \t]*([^\s`~]*)[ \t]*$/;
|
|
12
|
+
|
|
13
|
+
// Slide separator: a line containing only `---` (three or more hyphens).
|
|
14
|
+
// Do not treat `***` / `___` horizontal rules as separators; only the front-matter marker qualifies.
|
|
15
|
+
const SEPARATOR = /^[ \t]{0,3}-{3,}[ \t]*$/;
|
|
16
|
+
|
|
17
|
+
// One front-matter line: `key: value`. The value may be empty.
|
|
18
|
+
const META_LINE = /^([A-Za-z][\w-]*)[ \t]*:(.*)$/;
|
|
19
|
+
|
|
20
|
+
// Comment syntax allowed in front matter.
|
|
21
|
+
const META_COMMENT = /^[ \t]*#/;
|
|
22
|
+
|
|
23
|
+
// Keys not inherited by each slide from deck front matter.
|
|
24
|
+
// - layout: Inheritance would make every page a cover or back cover. The leading
|
|
25
|
+
// front matter is also the first slide's front matter, so it still applies there.
|
|
26
|
+
// - page: Sequence numbers are slide-specific; a deck-wide value has no meaning.
|
|
27
|
+
const NON_INHERITED_KEYS = new Set(["layout", "page"]);
|
|
28
|
+
|
|
29
|
+
// Layouts that do not receive automatic page numbers (cover, section divider, and back cover).
|
|
30
|
+
const UNNUMBERED_LAYOUTS = new Set(["title", "section", "backcover"]);
|
|
31
|
+
|
|
32
|
+
function normalizeText(text) {
|
|
33
|
+
return String(text).replace(/\r\n?/g, "\n").replace(/^\uFEFF/, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read one front-matter block (`---` through `---`).
|
|
38
|
+
*
|
|
39
|
+
* Treat lines[start] as front matter only when it is `---` and all content up to
|
|
40
|
+
* the closing `---` consists of `key: value`, blank lines, or comments. Otherwise
|
|
41
|
+
* return null so the caller treats it as a normal separator or body content.
|
|
42
|
+
*
|
|
43
|
+
* Inspecting the contents before deciding prevents per-slide front matter
|
|
44
|
+
* immediately after a `---` slide separator from becoming an extra slide.
|
|
45
|
+
*/
|
|
46
|
+
function readFrontMatterAt(lines, start) {
|
|
47
|
+
if (!SEPARATOR.test(lines[start] ?? "")) return null;
|
|
48
|
+
const entries = [];
|
|
49
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
50
|
+
const line = lines[i];
|
|
51
|
+
if (SEPARATOR.test(line)) {
|
|
52
|
+
// Do not treat empty `---` `---` as front matter; it is indistinguishable from two rules.
|
|
53
|
+
if (!entries.length) return null;
|
|
54
|
+
return { meta: entriesToMeta(entries), end: i };
|
|
55
|
+
}
|
|
56
|
+
if (line.trim() === "" || META_COMMENT.test(line)) continue;
|
|
57
|
+
const matched = META_LINE.exec(line);
|
|
58
|
+
if (!matched) return null;
|
|
59
|
+
entries.push([matched[1], matched[2].trim()]);
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function entriesToMeta(entries) {
|
|
65
|
+
const meta = new Map();
|
|
66
|
+
for (const [key, value] of entries) meta.set(key.toLowerCase(), { key, value });
|
|
67
|
+
return meta;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function metaLayout(meta) {
|
|
71
|
+
return (meta.get("layout")?.value || "").toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Convert a front-matter Map back to text enclosed by `---`. */
|
|
75
|
+
function formatFrontMatter(meta) {
|
|
76
|
+
if (!meta.size) return "";
|
|
77
|
+
const lines = ["---"];
|
|
78
|
+
for (const { key, value } of meta.values()) {
|
|
79
|
+
lines.push(value === "" ? `${key}:` : `${key}: ${value}`);
|
|
80
|
+
}
|
|
81
|
+
lines.push("---");
|
|
82
|
+
return lines.join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Split a complete Markdown file into shared deck front matter and individual slides.
|
|
87
|
+
*
|
|
88
|
+
* The returned slides value is an array of `{ meta, body }`, where meta maps
|
|
89
|
+
* lowercase keys to `{ key, value }`.
|
|
90
|
+
*
|
|
91
|
+
* Splitting rules:
|
|
92
|
+
* - Do not use `---` inside code fences as a separator.
|
|
93
|
+
* - Extract leading file front matter as shared deck settings.
|
|
94
|
+
* - Associate front matter immediately after a separator (or at the beginning
|
|
95
|
+
* of an otherwise empty body) with that slide.
|
|
96
|
+
* - Treat `---` after a nonblank line as a setext heading (H2), not a separator.
|
|
97
|
+
*/
|
|
98
|
+
export function splitMarkdownDeck(text) {
|
|
99
|
+
const lines = normalizeText(text).split("\n");
|
|
100
|
+
let cursor = 0;
|
|
101
|
+
|
|
102
|
+
// Skip leading blank lines, then read shared deck front matter.
|
|
103
|
+
while (cursor < lines.length && lines[cursor].trim() === "") cursor += 1;
|
|
104
|
+
let deckMeta = new Map();
|
|
105
|
+
const deckFrontMatter = readFrontMatterAt(lines, cursor);
|
|
106
|
+
if (deckFrontMatter) {
|
|
107
|
+
deckMeta = deckFrontMatter.meta;
|
|
108
|
+
cursor = deckFrontMatter.end + 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const slides = [];
|
|
112
|
+
let meta = new Map();
|
|
113
|
+
let body = [];
|
|
114
|
+
let sawContent = false;
|
|
115
|
+
let fence = null;
|
|
116
|
+
|
|
117
|
+
const flush = () => {
|
|
118
|
+
const text = body.join("\n").trim();
|
|
119
|
+
if (text || meta.size) slides.push({ meta, body: text });
|
|
120
|
+
meta = new Map();
|
|
121
|
+
body = [];
|
|
122
|
+
sawContent = false;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
for (let i = cursor; i < lines.length; i += 1) {
|
|
126
|
+
const line = lines[i];
|
|
127
|
+
|
|
128
|
+
if (fence) {
|
|
129
|
+
body.push(line);
|
|
130
|
+
if (new RegExp(`^[ \\t]{0,3}[${fence[0]}]{${fence.length},}[ \\t]*$`).test(line)) {
|
|
131
|
+
fence = null;
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const open = FENCE_OPEN.exec(line);
|
|
137
|
+
if (open) {
|
|
138
|
+
fence = open[2];
|
|
139
|
+
body.push(line);
|
|
140
|
+
sawContent = true;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (SEPARATOR.test(line)) {
|
|
145
|
+
// `---` after a nonblank line is a setext heading (H2), not a separator.
|
|
146
|
+
// Check this before front matter so a `key: value`-like paragraph after a
|
|
147
|
+
// heading is not misidentified as front matter.
|
|
148
|
+
const previous = i > 0 ? lines[i - 1] : "";
|
|
149
|
+
if (sawContent && previous.trim() !== "") {
|
|
150
|
+
body.push(line);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// A separator `---` may also open the next slide's front matter
|
|
154
|
+
// (`---` / `key: value` / `---`). Include it only after validating the
|
|
155
|
+
// content as front matter, avoiding an extra blank slide.
|
|
156
|
+
const front = readFrontMatterAt(lines, i);
|
|
157
|
+
if (front) {
|
|
158
|
+
// If nothing has accumulated, this is the current slide's own front matter.
|
|
159
|
+
if (sawContent || meta.size) flush();
|
|
160
|
+
meta = front.meta;
|
|
161
|
+
i = front.end;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
flush();
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
body.push(line);
|
|
169
|
+
if (line.trim() !== "") sawContent = true;
|
|
170
|
+
}
|
|
171
|
+
flush();
|
|
172
|
+
|
|
173
|
+
return { deckMeta, slides };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Convert a Markdown file to slide fragments the extension can render directly.
|
|
178
|
+
*
|
|
179
|
+
* - Inherit shared deck front matter on each slide; slide-level values take precedence.
|
|
180
|
+
* - Do not inherit `layout`. Leading file front matter also belongs to the first
|
|
181
|
+
* slide, so it still applies there (for example, `layout: title`).
|
|
182
|
+
* - Add `page` / `total` automatically only when neither the deck nor slide
|
|
183
|
+
* specifies them. Do not display numbers on covers, section dividers, or back
|
|
184
|
+
* covers, although they still participate in sequence numbering.
|
|
185
|
+
*/
|
|
186
|
+
export function buildDeckSlides(text) {
|
|
187
|
+
const { deckMeta, slides } = splitMarkdownDeck(text);
|
|
188
|
+
if (!slides.length) return [];
|
|
189
|
+
|
|
190
|
+
const merged = slides.map((slide, i) => {
|
|
191
|
+
const meta = new Map();
|
|
192
|
+
for (const [key, entry] of deckMeta) {
|
|
193
|
+
// Leading file front matter also belongs to the first slide, so include its layout.
|
|
194
|
+
if (NON_INHERITED_KEYS.has(key) && !(i === 0 && key === "layout")) continue;
|
|
195
|
+
meta.set(key, entry);
|
|
196
|
+
}
|
|
197
|
+
for (const [key, entry] of slide.meta) meta.set(key, entry);
|
|
198
|
+
return { meta, body: slide.body };
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const total = String(merged.filter((slide) => metaLayout(slide.meta) !== "backcover").length);
|
|
202
|
+
|
|
203
|
+
let ordinal = 0;
|
|
204
|
+
return merged.map((slide) => {
|
|
205
|
+
const layout = metaLayout(slide.meta);
|
|
206
|
+
if (layout !== "backcover") ordinal += 1;
|
|
207
|
+
if (!UNNUMBERED_LAYOUTS.has(layout)) {
|
|
208
|
+
if (!slide.meta.has("page")) {
|
|
209
|
+
slide.meta.set("page", { key: "page", value: String(ordinal) });
|
|
210
|
+
}
|
|
211
|
+
if (!slide.meta.has("total")) {
|
|
212
|
+
slide.meta.set("total", { key: "total", value: total });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const front = formatFrontMatter(slide.meta);
|
|
217
|
+
if (!front) return slide.body;
|
|
218
|
+
return slide.body ? `${front}\n${slide.body}` : front;
|
|
219
|
+
});
|
|
220
|
+
}
|