@lutrin/core 1.0.0
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/LICENSE +21 -0
- package/README.md +154 -0
- package/design/layouts/before-after.json +6 -0
- package/design/layouts/funnel.json +6 -0
- package/design/layouts/journey.json +5 -0
- package/design/layouts/key-message.json +5 -0
- package/design/layouts/portfolio.json +8 -0
- package/design/layouts/priority-matrix.json +7 -0
- package/design/layouts/pros-cons.json +6 -0
- package/design/layouts/pyramid.json +6 -0
- package/design/layouts/risk-map.json +8 -0
- package/design/layouts/roadmap.json +6 -0
- package/design/themes/default.json +159 -0
- package/package.json +57 -0
- package/src/cli.mjs +1114 -0
- package/src/deck/assets.mjs +910 -0
- package/src/deck/chart.mjs +424 -0
- package/src/deck/context.mjs +62 -0
- package/src/deck/highlight.mjs +206 -0
- package/src/deck/kit.mjs +342 -0
- package/src/deck/layout.mjs +1599 -0
- package/src/deck/parse.mjs +776 -0
- package/src/deck/suggest.mjs +33 -0
- package/src/deck/theme.mjs +1135 -0
- package/src/deck/tokens.mjs +268 -0
- package/src/deck/validate.mjs +737 -0
- package/src/html/render.mjs +1586 -0
- package/src/kit/archive.mjs +448 -0
- package/src/pptx/anim.mjs +196 -0
- package/src/pptx/fonts.mjs +204 -0
- package/src/pptx/morph.mjs +103 -0
- package/src/pptx/render.mjs +1379 -0
- package/src/vendor.mjs +281 -0
- package/src/worker/protocol.d.ts +82 -0
- package/src/worker/worker.mjs +145 -0
package/src/vendor.mjs
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `lutrin vendor` — make a deck's directory self-contained.
|
|
3
|
+
*
|
|
4
|
+
* Compiling must write nothing into the source tree: remote images and Mermaid
|
|
5
|
+
* diagrams go to the user cache, and the kit is installed once per machine.
|
|
6
|
+
* That is the right default, but it assumes THIS machine — a directory that is
|
|
7
|
+
* handed on, archived or cloned elsewhere only compiles identically if its
|
|
8
|
+
* dependencies travel with it.
|
|
9
|
+
*
|
|
10
|
+
* Vendoring is therefore the explicit act that freezes those dependencies into
|
|
11
|
+
* the directory:
|
|
12
|
+
*
|
|
13
|
+
* assets/remote/ downloaded remote images
|
|
14
|
+
* assets/mermaid/ rendered diagrams (compilable without mmdc installed)
|
|
15
|
+
* assets/kit/ the resolved kit — fonts and logos included
|
|
16
|
+
*
|
|
17
|
+
* Only a KIT is vendored. A BARE FILE theme (`kit: ./theme.json`) has no tree
|
|
18
|
+
* of its own: its tree is the deck's, and copying it would mean copying the
|
|
19
|
+
* author's entire neighbourhood — drafts included — into a directory meant to
|
|
20
|
+
* be handed on. So nothing is vendored in that case, and it is said out loud;
|
|
21
|
+
* this is the opposite of the command's contract, and that deserves a notice.
|
|
22
|
+
*
|
|
23
|
+
* The deck's frontmatter is rewritten accordingly (`assets: vendor`, and
|
|
24
|
+
* `kit: ./assets/kit`). This is deliberately a visible modification of the
|
|
25
|
+
* source file rather than an implicit convention: the kit precedence chain
|
|
26
|
+
* (CLI > frontmatter > project > user > host > generic) is the most-read
|
|
27
|
+
* contract in the project, and slipping a hidden level "if there is an
|
|
28
|
+
* assets/kit/" into it would cost more than two honest frontmatter lines.
|
|
29
|
+
*
|
|
30
|
+
* Accepted consequence: after vendoring, the deck uses ITS copy of the kit,
|
|
31
|
+
* including on the original machine. That is what freezing means — updating
|
|
32
|
+
* the kit requires re-running vendor.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { looksLikeFrontmatter, parseDeck } from './deck/parse.mjs';
|
|
38
|
+
import { buildScenes } from './deck/layout.mjs';
|
|
39
|
+
import { prepareDeckContext } from './deck/context.mjs';
|
|
40
|
+
import { resolveTheme } from './deck/theme.mjs';
|
|
41
|
+
import { fetchRemoteImage, mermaidVendorDir, renderMermaidCached } from './deck/assets.mjs';
|
|
42
|
+
import { ALLOWED_EXT, SKIP_DIRS } from './kit/archive.mjs';
|
|
43
|
+
|
|
44
|
+
/** Every block of the scenes, hero layout included (the same flattening as the
|
|
45
|
+
* renderers: a hero image is not in `elements`). */
|
|
46
|
+
const allBlocks = (scenes) =>
|
|
47
|
+
scenes.flatMap((sc) => [...sc.elements.map((e) => e.block), ...(sc.image ? [sc.image] : [])]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Walks up from `themeFile` to the directory holding the `kit.json` that
|
|
51
|
+
* includes it.
|
|
52
|
+
*
|
|
53
|
+
* The theme's own directory cannot stand in for the kit: `kit.json` declares
|
|
54
|
+
* its theme by a relative path that may descend (`theme: themes/x.json`). And
|
|
55
|
+
* above all, a BARE FILE theme has no kit at all — that is what `null` says
|
|
56
|
+
* here, and it is what forbids vendoring the directory around it (the deck's,
|
|
57
|
+
* most of the time: the whole neighbourhood would go along).
|
|
58
|
+
*/
|
|
59
|
+
function kitRoot(themeFile) {
|
|
60
|
+
let dir = path.dirname(path.resolve(themeFile));
|
|
61
|
+
for (;;) {
|
|
62
|
+
if (fs.existsSync(path.join(dir, 'kit.json'))) return dir;
|
|
63
|
+
const parent = path.dirname(dir);
|
|
64
|
+
if (parent === dir) return null;
|
|
65
|
+
dir = parent;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Is `target` inside `parent` (or parent itself)? */
|
|
70
|
+
function isUnder(parent, target) {
|
|
71
|
+
const rel = path.relative(path.resolve(parent), path.resolve(target));
|
|
72
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A vendoring target we allow ourselves to DELETE recursively.
|
|
77
|
+
*
|
|
78
|
+
* `assets/kit/` is purged before every copy (see below), and an rm -rf is the
|
|
79
|
+
* only operation in this command that destroys work. So it is confined to what
|
|
80
|
+
* we produced ourselves: a path under the deck's directory, and a directory
|
|
81
|
+
* holding a `kit.json` — the signature of a kit copy. Any other content
|
|
82
|
+
* already in place there belongs to the user.
|
|
83
|
+
*/
|
|
84
|
+
function purgeable(baseDir, destDir) {
|
|
85
|
+
if (!isUnder(baseDir, destDir)) return false;
|
|
86
|
+
if (!fs.existsSync(destDir)) return true;
|
|
87
|
+
return fs.statSync(destDir).isDirectory() && fs.existsSync(path.join(destDir, 'kit.json'));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Says what is occupying the place, when `purgeable` has refused.
|
|
92
|
+
*
|
|
93
|
+
* The user reads this message at the precise moment they are looking for why
|
|
94
|
+
* their vendoring was refused: talking about a "directory without a kit.json"
|
|
95
|
+
* when `assets/kit` is a FILE would send them looking for the wrong thing. The
|
|
96
|
+
* two situations also call for different gestures (renaming a file, or moving
|
|
97
|
+
* a working directory), so they are told apart.
|
|
98
|
+
*/
|
|
99
|
+
function occupiedReason(destDir) {
|
|
100
|
+
if (fs.existsSync(destDir) && !fs.statSync(destDir).isDirectory())
|
|
101
|
+
return `${destDir} already exists and is a file, not a kit directory. Move or delete it, then re-run vendor.`;
|
|
102
|
+
return `${destDir} already exists and is not a kit copy (no kit.json). Move or delete this directory, then re-run vendor.`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Copies a kit into the deck's directory.
|
|
107
|
+
*
|
|
108
|
+
* We copy the kit's TREE rather than only the files cited by the theme:
|
|
109
|
+
* `theme.json` references its fonts and its logos by relative paths
|
|
110
|
+
* (`./fonts/Body.ttf`), which must keep resolving in the copy. The allow-list
|
|
111
|
+
* is the one used for .deckkit archives — the same definition of "what a kit
|
|
112
|
+
* is allowed to contain", data only, nothing executable.
|
|
113
|
+
*
|
|
114
|
+
* `srcDir` is the kit's ROOT (the one holding `kit.json`), never some arbitrary
|
|
115
|
+
* directory: see `kitRoot`.
|
|
116
|
+
*/
|
|
117
|
+
function copyKit(srcDir, destDir) {
|
|
118
|
+
let files = 0;
|
|
119
|
+
const walk = (rel) => {
|
|
120
|
+
for (const entry of fs.readdirSync(path.join(srcDir, rel), { withFileTypes: true })) {
|
|
121
|
+
const r = path.join(rel, entry.name);
|
|
122
|
+
if (entry.isDirectory()) {
|
|
123
|
+
if (!SKIP_DIRS.has(entry.name)) walk(r);
|
|
124
|
+
} else if (entry.isFile() && ALLOWED_EXT.has(path.extname(entry.name).toLowerCase())) {
|
|
125
|
+
fs.mkdirSync(path.dirname(path.join(destDir, r)), { recursive: true });
|
|
126
|
+
fs.copyFileSync(path.join(srcDir, r), path.join(destDir, r));
|
|
127
|
+
files++;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
walk('');
|
|
132
|
+
return files;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Rewrites one frontmatter key (added if absent), preserving the rest of the
|
|
137
|
+
* file byte for byte.
|
|
138
|
+
*
|
|
139
|
+
* The project's frontmatter parser is flat (`key: value`, parse.mjs), so a
|
|
140
|
+
* line-by-line rewrite is faithful to it — no YAML to rebuild, no comments and
|
|
141
|
+
* no ordering to lose. A deck WITHOUT frontmatter is given one: it is the only
|
|
142
|
+
* way to carry the declaration.
|
|
143
|
+
*/
|
|
144
|
+
export function setFrontmatterKey(source, key, value) {
|
|
145
|
+
const m = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
146
|
+
// The SAME predicate as parse.mjs: a deck may begin with `---` (a horizontal
|
|
147
|
+
// rule) without having frontmatter. Without that sharing, `assets: vendor`
|
|
148
|
+
// was injected INTO the first slide (a key never honoured), and an empty
|
|
149
|
+
// block at the top mutilated the author's file. In those cases we prefix a
|
|
150
|
+
// fresh frontmatter rather than edit a block that is not one.
|
|
151
|
+
if (!m || !looksLikeFrontmatter(m[1])) return `---\n${key}: ${value}\n---\n\n${source}`;
|
|
152
|
+
const nl = m[0].includes('\r\n') ? '\r\n' : '\n';
|
|
153
|
+
const lines = m[1].split(/\r?\n/);
|
|
154
|
+
const i = lines.findIndex((l) => new RegExp(`^${key}\\s*:`).test(l));
|
|
155
|
+
if (i >= 0) {
|
|
156
|
+
if (lines[i] === `${key}: ${value}`) return source; // already up to date: touch nothing
|
|
157
|
+
lines[i] = `${key}: ${value}`;
|
|
158
|
+
} else {
|
|
159
|
+
lines.push(`${key}: ${value}`);
|
|
160
|
+
}
|
|
161
|
+
// end offset of the opening `---` line (handles \n and \r\n) — never
|
|
162
|
+
// indexOf(m[1]), which is 0 when m[1] is empty and destroys the delimiter
|
|
163
|
+
const start = m[0].indexOf('\n') + 1;
|
|
164
|
+
return source.slice(0, start) + lines.join(nl) + source.slice(start + m[1].length);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Materializes the deck's external dependencies inside its directory.
|
|
169
|
+
*
|
|
170
|
+
* Does not throw on a missing resource: an offline image or a diagram mmdc
|
|
171
|
+
* cannot render is *reported*, not fatal — the deck stays compilable, with the
|
|
172
|
+
* usual fallbacks. Only a deck that could not be read fails.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} input path of the .md
|
|
175
|
+
* @param {object} [opts] { themePath } — the CLI's `--kit`, which takes priority
|
|
176
|
+
* @returns {Promise<object>} report for the CLI display
|
|
177
|
+
*/
|
|
178
|
+
export async function vendorDeck(input, { themePath = null } = {}) {
|
|
179
|
+
const baseDir = path.dirname(path.resolve(input));
|
|
180
|
+
const source = fs.readFileSync(input, 'utf8');
|
|
181
|
+
const deck = parseDeck(source);
|
|
182
|
+
const prep = prepareDeckContext(deck.meta, { baseDir, themePath });
|
|
183
|
+
const scenes = buildScenes(deck);
|
|
184
|
+
const blocks = allBlocks(scenes);
|
|
185
|
+
|
|
186
|
+
const report = {
|
|
187
|
+
baseDir,
|
|
188
|
+
warnings: prep.diagnostics.map((d) => d.message),
|
|
189
|
+
images: { done: 0, total: 0 },
|
|
190
|
+
mermaid: { done: 0, total: 0 },
|
|
191
|
+
kit: null,
|
|
192
|
+
frontmatter: [],
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// ---- remote images -------------------------------------------------------
|
|
196
|
+
const urls = [
|
|
197
|
+
...new Set(
|
|
198
|
+
blocks
|
|
199
|
+
.filter((b) => b.type === 'image')
|
|
200
|
+
.map((b) => b.src)
|
|
201
|
+
.filter((s) => /^https?:/.test(s)),
|
|
202
|
+
),
|
|
203
|
+
];
|
|
204
|
+
report.images.total = urls.length;
|
|
205
|
+
const fetched = await Promise.all(
|
|
206
|
+
urls.map((u) => fetchRemoteImage(u, baseDir, { vendor: true })),
|
|
207
|
+
);
|
|
208
|
+
report.images.done = fetched.filter(Boolean).length;
|
|
209
|
+
|
|
210
|
+
// ---- Mermaid diagrams ----------------------------------------------------
|
|
211
|
+
// both formats: the .pptx consumes PNG, the HTML consumes SVG — a
|
|
212
|
+
// self-contained directory must be able to produce either
|
|
213
|
+
const sources = [...new Set(blocks.filter((b) => b.type === 'mermaid').map((b) => b.source))];
|
|
214
|
+
report.mermaid.total = sources.length;
|
|
215
|
+
if (sources.length) {
|
|
216
|
+
const dir = mermaidVendorDir(baseDir);
|
|
217
|
+
for (const src of sources) {
|
|
218
|
+
const rendered = ['png', 'svg']
|
|
219
|
+
.map((format) => renderMermaidCached(src, { format, baseDir }))
|
|
220
|
+
.filter(Boolean);
|
|
221
|
+
if (rendered.length) fs.mkdirSync(dir, { recursive: true });
|
|
222
|
+
for (const f of rendered) {
|
|
223
|
+
const dest = path.join(dir, path.basename(f));
|
|
224
|
+
if (path.resolve(f) !== path.resolve(dest)) fs.copyFileSync(f, dest);
|
|
225
|
+
}
|
|
226
|
+
// partial rendering (a single format): the deck compiles, but not for
|
|
227
|
+
// both targets — that is not "done"
|
|
228
|
+
if (rendered.length === 2) report.mermaid.done++;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---- kit -----------------------------------------------------------------
|
|
233
|
+
// Only a KIT is vendored. `kitName` is resolveTheme's mark of a kit (a file
|
|
234
|
+
// theme has a null `kitName`): without it there is no tree to copy, only a
|
|
235
|
+
// .json sitting somewhere — and the directory around it is the deck's, which
|
|
236
|
+
// we will not copy into itself.
|
|
237
|
+
const { path: themeFile, kitName } = resolveTheme(deck.meta, { baseDir, themePath });
|
|
238
|
+
const srcDir = themeFile && kitName ? kitRoot(themeFile) : null;
|
|
239
|
+
if (srcDir) {
|
|
240
|
+
const destDir = path.join(baseDir, 'assets', 'kit');
|
|
241
|
+
if (path.resolve(srcDir) === path.resolve(destDir)) {
|
|
242
|
+
report.kit = { name: kitName, files: 0, alreadyVendored: true };
|
|
243
|
+
} else if (!purgeable(baseDir, destDir)) {
|
|
244
|
+
// the place is taken by something other than our own copy: we do not
|
|
245
|
+
// destroy it, and above all we do not rewrite the frontmatter towards it
|
|
246
|
+
report.warnings.push(`Kit "${kitName}" not vendored: ${occupiedReason(destDir)}`);
|
|
247
|
+
} else {
|
|
248
|
+
// full replacement: a re-vendored kit must not inherit the files of a
|
|
249
|
+
// previous version (a renamed font, a removed logo)
|
|
250
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
251
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
252
|
+
report.kit = { name: kitName, files: copyKit(srcDir, destDir) };
|
|
253
|
+
}
|
|
254
|
+
} else if (themeFile && !isUnder(baseDir, themeFile)) {
|
|
255
|
+
// a bare file theme outside the directory: freezing it would mean guessing
|
|
256
|
+
// what it cites and inventing a kit around it; saying so is better than
|
|
257
|
+
// blindly copying a directory that is not a kit
|
|
258
|
+
report.warnings.push(
|
|
259
|
+
`Theme ${themeFile}: bare file outside the deck's directory — it will NOT travel with it. Copy it next to the deck (and adjust "kit:"), or turn it into a kit (lutrin kit create).`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---- frontmatter ---------------------------------------------------------
|
|
264
|
+
let out = source;
|
|
265
|
+
// `assets: vendor` only makes sense if there are remote images: on a deck
|
|
266
|
+
// that has none, it would be a line of noise in the author's file — and
|
|
267
|
+
// vendoring must only modify the source for a reason
|
|
268
|
+
if (report.images.total) {
|
|
269
|
+
const before = out;
|
|
270
|
+
out = setFrontmatterKey(out, 'assets', 'vendor');
|
|
271
|
+
if (out !== before) report.frontmatter.push('assets: vendor');
|
|
272
|
+
}
|
|
273
|
+
if (report.kit && !report.kit.alreadyVendored) {
|
|
274
|
+
const before = out;
|
|
275
|
+
out = setFrontmatterKey(out, 'kit', './assets/kit');
|
|
276
|
+
if (out !== before) report.frontmatter.push('kit: ./assets/kit');
|
|
277
|
+
}
|
|
278
|
+
if (out !== source) fs.writeFileSync(input, out);
|
|
279
|
+
|
|
280
|
+
return report;
|
|
281
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IPC protocol of the compilation worker (worker.mjs) — the single source of
|
|
3
|
+
* truth for the types exchanged between the worker and its hosts (VS Code
|
|
4
|
+
* extension, Obsidian plugin). A pure type file: no code emitted.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface DeckDiagnostic {
|
|
8
|
+
severity: 'error' | 'warning' | 'info';
|
|
9
|
+
code: string;
|
|
10
|
+
message: string;
|
|
11
|
+
line: number;
|
|
12
|
+
suggestion?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Render stats; `warnings`: what the OOXML post-processing (animations/fonts)
|
|
16
|
+
* and the theme (file could not be read, insufficient contrast…) gave up on,
|
|
17
|
+
* never silently. */
|
|
18
|
+
export interface RenderStats {
|
|
19
|
+
slideCount: number;
|
|
20
|
+
fontsEmbedded: number;
|
|
21
|
+
animatedSlides: number;
|
|
22
|
+
/** "(cont.)" slides that received the Morph transition (PPTX). */
|
|
23
|
+
morphSlides?: number;
|
|
24
|
+
warnings?: string[];
|
|
25
|
+
[k: string]: number | string[] | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Result of `compile`: HTML fragments for the webview + diagnostics. */
|
|
29
|
+
export interface CompileResult {
|
|
30
|
+
slides: string[];
|
|
31
|
+
css: string;
|
|
32
|
+
fontsCss: string;
|
|
33
|
+
stats: RenderStats;
|
|
34
|
+
slideMap: { slide: number; startLine: number }[];
|
|
35
|
+
animSteps: (number | null)[];
|
|
36
|
+
diagnostics: DeckDiagnostic[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Result of `exportPptx` / `exportHtml`. */
|
|
40
|
+
export interface ExportResult {
|
|
41
|
+
stats: RenderStats;
|
|
42
|
+
outPath: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Result of `validate`. */
|
|
46
|
+
export interface ValidateResult {
|
|
47
|
+
diagnostics: DeckDiagnostic[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface WorkerRequest {
|
|
51
|
+
id: number;
|
|
52
|
+
cmd: 'compile' | 'validate' | 'exportPptx' | 'exportHtml';
|
|
53
|
+
/** `themePath` (optional): kit imposed by the host, absolute — OVERRIDES the
|
|
54
|
+
* frontmatter `kit:` key, which is resolved relative to `baseDir` (the theme
|
|
55
|
+
* file travels with the deck, not with the extension). `defaultTheme`
|
|
56
|
+
* (optional): the host's LAST-RESORT kit — the name of an installed kit, or
|
|
57
|
+
* the ABSOLUTE PATH of a kit directory bundled by the extension
|
|
58
|
+
* (`<dist>/kits/<name>`, chosen by the compilerClients' fallbackKit: the
|
|
59
|
+
* first kit bundled by the build, no hard-coded name) —
|
|
60
|
+
* applied only if neither the frontmatter, nor the project default, nor the
|
|
61
|
+
* user default names a kit; `kit: none` in the deck disables it. */
|
|
62
|
+
payload: {
|
|
63
|
+
source: string;
|
|
64
|
+
baseDir?: string;
|
|
65
|
+
outPath?: string;
|
|
66
|
+
themePath?: string;
|
|
67
|
+
defaultTheme?: string;
|
|
68
|
+
/** Additional roots (beyond the deck's directory) a LOCAL image may be
|
|
69
|
+
* embedded from — containment against arbitrary file reads (assets.mjs).
|
|
70
|
+
* Absent/empty ⇒ the deck's directory only. VS Code: the workspace
|
|
71
|
+
* directory; Obsidian: the vault root. */
|
|
72
|
+
imageRoots?: string[];
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type WorkerResponse =
|
|
77
|
+
/** Requests are serialized inside the worker; `started` is emitted when the
|
|
78
|
+
* request ENTERS execution — the client rearms its guard timeout there
|
|
79
|
+
* (time spent queued does not count towards the timeout). */
|
|
80
|
+
| { id: number; started: true }
|
|
81
|
+
| { id: number; ok: true; result: unknown }
|
|
82
|
+
| { id: number; ok: false; error: { message: string } };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compilation worker — a dedicated Node process, launched by the editor hosts
|
|
3
|
+
* (VS Code extension: fork; Obsidian plugin: spawn of a system Node).
|
|
4
|
+
*
|
|
5
|
+
* All of the compilation (including mmdc/execFileSync, blocking for up to 60 s
|
|
6
|
+
* per Mermaid diagram, and the native resvg module) lives here: the host only
|
|
7
|
+
* does IPC round trips and never freezes.
|
|
8
|
+
*
|
|
9
|
+
* One and the same for both hosts: it lives in the core (the extensions point
|
|
10
|
+
* at `dist/core/src/worker/worker.mjs` — a faithful copy of the core in
|
|
11
|
+
* production, a symlink in development), so its asset resolution through
|
|
12
|
+
* import.meta.url and its node_modules stay intact.
|
|
13
|
+
*
|
|
14
|
+
* Protocol (IPC, types in ./protocol.d.ts):
|
|
15
|
+
* → { id, cmd: 'compile', payload: { source, baseDir, themePath?, defaultTheme?, imageRoots? } }
|
|
16
|
+
* → { id, cmd: 'validate', payload: { source, baseDir, themePath?, defaultTheme?, imageRoots? } }
|
|
17
|
+
* → { id, cmd: 'exportPptx', payload: { source, baseDir, outPath, themePath?, defaultTheme?, imageRoots? } }
|
|
18
|
+
* → { id, cmd: 'exportHtml', payload: { source, baseDir, outPath, themePath?, defaultTheme?, imageRoots? } }
|
|
19
|
+
* ← { id, ok: true, result } | { id, ok: false, error: { message } }
|
|
20
|
+
*
|
|
21
|
+
* `imageRoots`: additional roots (beyond the deck's directory) from which a
|
|
22
|
+
* LOCAL image may be embedded — containment against arbitrary file reads
|
|
23
|
+
* (assets.mjs). Absent/empty ⇒ only the deck's directory is admitted.
|
|
24
|
+
*
|
|
25
|
+
* Requests are SERIALIZED (a promise queue): the theme and the user layouts
|
|
26
|
+
* are module state mutated by each compilation — two interleaved requests
|
|
27
|
+
* (deck A being rendered while deck B applies its theme) would produce a
|
|
28
|
+
* hybrid rendering, silently wrong.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import fs from 'node:fs/promises';
|
|
32
|
+
import { compileHtml, renderDeckHtml } from '../html/render.mjs';
|
|
33
|
+
import { parseDeck } from '../deck/parse.mjs';
|
|
34
|
+
import { buildScenes } from '../deck/layout.mjs';
|
|
35
|
+
import { prepareDeckContext } from '../deck/context.mjs';
|
|
36
|
+
import { renderDeck } from '../pptx/render.mjs';
|
|
37
|
+
import { validateDeck } from '../deck/validate.mjs';
|
|
38
|
+
|
|
39
|
+
const HANDLERS = {
|
|
40
|
+
async compile({ source, baseDir, themePath, defaultTheme, imageRoots }) {
|
|
41
|
+
const { slides, css, fontsCss, stats, scenes, deck } = await compileHtml(source, {
|
|
42
|
+
baseDir,
|
|
43
|
+
fragment: true,
|
|
44
|
+
themePath,
|
|
45
|
+
defaultTheme,
|
|
46
|
+
imageRoots,
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
slides,
|
|
50
|
+
css,
|
|
51
|
+
fontsCss,
|
|
52
|
+
stats,
|
|
53
|
+
slideMap: scenes.map((s, k) => ({ slide: k + 1, startLine: s.sourceLine ?? 1 })),
|
|
54
|
+
animSteps: scenes.map((s) => s.animSteps ?? null),
|
|
55
|
+
// deck and scenes reused: a single parse/layout pass per keystroke
|
|
56
|
+
diagnostics: validateDeck(source, {
|
|
57
|
+
baseDir,
|
|
58
|
+
themePath,
|
|
59
|
+
defaultTheme,
|
|
60
|
+
imageRoots,
|
|
61
|
+
deck,
|
|
62
|
+
scenes,
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async validate({ source, baseDir, themePath, defaultTheme, imageRoots }) {
|
|
68
|
+
return { diagnostics: validateDeck(source, { baseDir, themePath, defaultTheme, imageRoots }) };
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async exportPptx({ source, baseDir, outPath, themePath, defaultTheme, imageRoots }) {
|
|
72
|
+
const deck = parseDeck(source);
|
|
73
|
+
const prep = prepareDeckContext(deck.meta, { baseDir, themePath, defaultTheme });
|
|
74
|
+
const scenes = buildScenes(deck);
|
|
75
|
+
const stats = await renderDeck(scenes, deck.meta, baseDir, outPath, { imageRoots });
|
|
76
|
+
stats.warnings = [...prep.diagnostics.map((d) => d.message), ...(stats.warnings ?? [])];
|
|
77
|
+
return { stats, outPath };
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async exportHtml({ source, baseDir, outPath, themePath, defaultTheme, imageRoots }) {
|
|
81
|
+
const deck = parseDeck(source);
|
|
82
|
+
const prep = prepareDeckContext(deck.meta, { baseDir, themePath, defaultTheme });
|
|
83
|
+
const scenes = buildScenes(deck);
|
|
84
|
+
const { html, stats } = await renderDeckHtml(scenes, deck.meta, baseDir, { imageRoots });
|
|
85
|
+
stats.warnings = [...prep.diagnostics.map((d) => d.message), ...(stats.warnings ?? [])];
|
|
86
|
+
await fs.writeFile(outPath, html);
|
|
87
|
+
return { stats, outPath };
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* TOLERANT send: the IPC channel can be closed out from under us (host killed,
|
|
93
|
+
* extension reloaded). A send exception propagated as is would travel through
|
|
94
|
+
* the request's catch and reject the QUEUE — every subsequent request would
|
|
95
|
+
* then be left without an answer, in a worker that is nonetheless alive. What
|
|
96
|
+
* does not get out is lost, never fatal.
|
|
97
|
+
*/
|
|
98
|
+
function post(message) {
|
|
99
|
+
try {
|
|
100
|
+
process.send?.(message);
|
|
101
|
+
} catch {
|
|
102
|
+
/* channel closed: the client will notice through its own watchdog timer */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let queue = Promise.resolve(); // serialization: one request at a time
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* An IPC message is EXTERNAL data: nothing guarantees its shape. Destructuring
|
|
110
|
+
* outright (`({ id, cmd, payload })`) threw OUTSIDE the try on `null` or on a
|
|
111
|
+
* string — the exception escaped the handler, and the worker died on a
|
|
112
|
+
* malformed message. So the shape is validated first: anything that is not an
|
|
113
|
+
* object carrying an `id` has nobody to answer to and is ignored; the rest
|
|
114
|
+
* follows the normal path, errors included.
|
|
115
|
+
*/
|
|
116
|
+
process.on('message', (message) => {
|
|
117
|
+
if (message === null || typeof message !== 'object' || Array.isArray(message)) return;
|
|
118
|
+
const { id, cmd, payload } = message;
|
|
119
|
+
if (id === undefined || id === null) return;
|
|
120
|
+
|
|
121
|
+
queue = queue
|
|
122
|
+
.then(async () => {
|
|
123
|
+
try {
|
|
124
|
+
// the request ENTERS execution: the client rearms its watchdog timer
|
|
125
|
+
// from here on (waiting in the queue must not count — otherwise a
|
|
126
|
+
// healthy request would expire behind a long Mermaid render, and the
|
|
127
|
+
// client's restart() would kill the worker mid-work)
|
|
128
|
+
post({ id, started: true });
|
|
129
|
+
// Object.hasOwn: HANDLERS is a literal — without the guard, a cmd
|
|
130
|
+
// 'toString' (or 'constructor', 'valueOf'…) would surface a function
|
|
131
|
+
// inherited from Object.prototype, would pass the test below and would
|
|
132
|
+
// be CALLED: the worker would answer { ok: true, result: '[object …]' }
|
|
133
|
+
// instead of the protocol error, and the host would destructure undefined
|
|
134
|
+
const handler =
|
|
135
|
+
typeof cmd === 'string' && Object.hasOwn(HANDLERS, cmd) ? HANDLERS[cmd] : null;
|
|
136
|
+
if (!handler) throw new Error(`unknown command: ${cmd}`);
|
|
137
|
+
post({ id, ok: true, result: await handler(payload ?? {}) });
|
|
138
|
+
} catch (e) {
|
|
139
|
+
post({ id, ok: false, error: { message: e?.message ?? String(e) } });
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
// final safeguard: whatever happens in the chain link above, the queue restarts from a
|
|
143
|
+
// RESOLVED promise — a rejected queue never repairs itself
|
|
144
|
+
.catch(() => {});
|
|
145
|
+
});
|