@tycoworks/tycoslide 0.8.0 → 0.10.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/README.md +12 -9
- package/SKILL.md +6 -7
- package/dist/cli.js +39 -59
- package/dist/engine/fillers/filler.d.ts +21 -13
- package/dist/engine/fillers/filler.js +21 -24
- package/dist/engine/generate.d.ts +23 -17
- package/dist/engine/generate.js +157 -70
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/types.d.ts +39 -9
- package/dist/index.d.ts +15 -21
- package/dist/index.js +63 -88
- package/dist/manifest.js +17 -25
- package/dist/markdown/blocks/code.d.ts +17 -0
- package/dist/markdown/blocks/code.js +65 -0
- package/dist/markdown/blocks/image.d.ts +2 -0
- package/dist/markdown/blocks/image.js +9 -0
- package/dist/markdown/blocks/mermaid.d.ts +15 -0
- package/dist/markdown/blocks/mermaid.js +227 -0
- package/dist/markdown/{resolvers → blocks}/mermaidTheme.d.ts +1 -1
- package/dist/markdown/{resolvers → blocks}/mermaidTheme.js +1 -1
- package/dist/markdown/blocks/registry.d.ts +16 -0
- package/dist/markdown/blocks/registry.js +44 -0
- package/dist/markdown/blocks/table.d.ts +2 -0
- package/dist/markdown/blocks/table.js +23 -0
- package/dist/markdown/blocks/text.d.ts +12 -0
- package/dist/markdown/blocks/text.js +90 -0
- package/dist/markdown/deckCompiler.d.ts +17 -20
- package/dist/markdown/deckCompiler.js +143 -113
- package/dist/markdown/index.d.ts +11 -11
- package/dist/markdown/index.js +9 -8
- package/dist/markdown/inline.d.ts +26 -0
- package/dist/markdown/inline.js +136 -0
- package/dist/markdown/mdast.d.ts +25 -0
- package/dist/markdown/mdast.js +49 -0
- package/dist/markdown/schema/deckSchema.d.ts +30 -0
- package/dist/markdown/schema/deckSchema.js +51 -0
- package/dist/markdown/schema/strict.d.ts +9 -0
- package/dist/markdown/schema/strict.js +18 -0
- package/dist/markdown/schema/themeConfigSchema.d.ts +106 -0
- package/dist/markdown/schema/themeConfigSchema.js +147 -0
- package/dist/markdown/types.d.ts +194 -134
- package/dist/markdown/types.js +34 -23
- package/dist/skillZip.d.ts +17 -0
- package/dist/skillZip.js +35 -0
- package/package.json +7 -3
- package/syntax.md +1 -1
- package/dist/markdown/parsers.d.ts +0 -32
- package/dist/markdown/parsers.js +0 -233
- package/dist/markdown/resolvers/code.d.ts +0 -17
- package/dist/markdown/resolvers/code.js +0 -44
- package/dist/markdown/resolvers/mermaid.d.ts +0 -14
- package/dist/markdown/resolvers/mermaid.js +0 -81
- package/dist/markdown/resolvers/resolver.d.ts +0 -42
- package/dist/markdown/resolvers/resolver.js +0 -52
package/dist/manifest.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { templateKeys } from "./markdown/textTemplate.js";
|
|
2
|
-
import {
|
|
2
|
+
import { ParameterType } from "./markdown/types.js";
|
|
3
3
|
/**
|
|
4
4
|
* Flatten a compiler parameter to the manifest entries advertised to AI authors.
|
|
5
5
|
* A template parameter has no top-level key — its template's keys are the keys, so it
|
|
@@ -24,42 +24,34 @@ function stripParameter(param) {
|
|
|
24
24
|
const result = { key: param.key, type: param.type };
|
|
25
25
|
if (param.required)
|
|
26
26
|
result.required = true;
|
|
27
|
-
if (param.limit)
|
|
28
|
-
result.limit = param.limit;
|
|
29
27
|
return [result];
|
|
30
28
|
}
|
|
31
29
|
}
|
|
32
30
|
}
|
|
33
31
|
function stripSlot(slot) {
|
|
34
|
-
const result = { key: slot.key,
|
|
32
|
+
const result = { key: slot.key, accepts: slot.accepts.map((b) => b.type) };
|
|
35
33
|
if (slot.required)
|
|
36
34
|
result.required = true;
|
|
37
35
|
if (slot.limit)
|
|
38
36
|
result.limit = slot.limit;
|
|
39
|
-
switch (slot.type) {
|
|
40
|
-
case CompilerSlotType.Text:
|
|
41
|
-
case CompilerSlotType.Table:
|
|
42
|
-
// No fields beyond the shared key/type/required/limit.
|
|
43
|
-
break;
|
|
44
|
-
case CompilerSlotType.Code:
|
|
45
|
-
result.codeTheme = slot.codeTheme;
|
|
46
|
-
break;
|
|
47
|
-
case CompilerSlotType.Mermaid:
|
|
48
|
-
result.mermaidVariant = slot.mermaidVariant;
|
|
49
|
-
break;
|
|
50
|
-
}
|
|
51
37
|
return result;
|
|
52
38
|
}
|
|
53
39
|
export function generateManifest(config, options) {
|
|
54
|
-
const layouts = config.layouts.map((layout) =>
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
40
|
+
const layouts = config.layouts.map((layout) => {
|
|
41
|
+
const ml = {
|
|
42
|
+
name: layout.name,
|
|
43
|
+
slideNumber: layout.slideNumber,
|
|
44
|
+
parameters: layout.parameters.flatMap(stripParameter),
|
|
45
|
+
slots: layout.slots.map(stripSlot),
|
|
46
|
+
};
|
|
47
|
+
if (layout.description !== undefined)
|
|
48
|
+
ml.description = layout.description;
|
|
49
|
+
if (layout.whenToUse !== undefined)
|
|
50
|
+
ml.whenToUse = layout.whenToUse;
|
|
51
|
+
if (layout.whenNotToUse !== undefined)
|
|
52
|
+
ml.whenNotToUse = layout.whenNotToUse;
|
|
53
|
+
return ml;
|
|
54
|
+
});
|
|
63
55
|
const assets = {};
|
|
64
56
|
for (const [category, entries] of Object.entries(config.assets)) {
|
|
65
57
|
assets[category] = {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { StyledParagraph } from "../../engine/index.js";
|
|
2
|
+
import { type BlockHandler } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Recognize a fenced code block (any language except mermaid) at a region's top
|
|
5
|
+
* level, folding it to a Text fill, and compile it by Shiki-highlighting its
|
|
6
|
+
* source into a TextFill. Theme resolution is strict: the theme MUST declare a
|
|
7
|
+
* `codeTheme` — either one Shiki id (one style per theme — the design-system
|
|
8
|
+
* framing) or a `{ light, dark }` pair the layout's `variant` selects (a pair
|
|
9
|
+
* with no layout `variant` throws). A deck with code fences but no theme-level
|
|
10
|
+
* `codeTheme` throws, naming the offending slot.
|
|
11
|
+
*/
|
|
12
|
+
export declare const CODE: BlockHandler;
|
|
13
|
+
/**
|
|
14
|
+
* Run Shiki over a code block, producing StyledParagraph[] with per-token
|
|
15
|
+
* color runs. Blank lines become paragraphs with a single empty run.
|
|
16
|
+
*/
|
|
17
|
+
export declare function highlightCode(code: string, language: string, theme: string): Promise<StyledParagraph[]>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { MdastType } from "../mdast.js";
|
|
2
|
+
import { AcceptType } from "../types.js";
|
|
3
|
+
import { MERMAID_LANG } from "./mermaid.js";
|
|
4
|
+
/**
|
|
5
|
+
* Recognize a fenced code block (any language except mermaid) at a region's top
|
|
6
|
+
* level, folding it to a Text fill, and compile it by Shiki-highlighting its
|
|
7
|
+
* source into a TextFill. Theme resolution is strict: the theme MUST declare a
|
|
8
|
+
* `codeTheme` — either one Shiki id (one style per theme — the design-system
|
|
9
|
+
* framing) or a `{ light, dark }` pair the layout's `variant` selects (a pair
|
|
10
|
+
* with no layout `variant` throws). A deck with code fences but no theme-level
|
|
11
|
+
* `codeTheme` throws, naming the offending slot.
|
|
12
|
+
*/
|
|
13
|
+
export const CODE = {
|
|
14
|
+
match: (node) => node.type === MdastType.Code && node.lang !== MERMAID_LANG,
|
|
15
|
+
acceptType: AcceptType.Text,
|
|
16
|
+
compile: async (node, ctx) => {
|
|
17
|
+
const code = node;
|
|
18
|
+
// A fence with no language can't be highlighted (no Shiki grammar to pick),
|
|
19
|
+
// so fail fast naming the slot rather than attempt a language-less highlight.
|
|
20
|
+
if (!code.lang) {
|
|
21
|
+
throw new Error(`Slide ${ctx.slideIdx}: layout "${ctx.layoutName}" slot content (from ${ctx.source}) has a code fence ` +
|
|
22
|
+
"with no language; add one after the opening ``` (e.g. ```sql).");
|
|
23
|
+
}
|
|
24
|
+
const codeTheme = ctx.config.codeTheme;
|
|
25
|
+
if (!codeTheme) {
|
|
26
|
+
throw new Error(`Layout "${ctx.layoutName}" slot content (from ${ctx.source}): deck contains a code fence but the theme ` +
|
|
27
|
+
'declares no "codeTheme". Add a theme-level "codeTheme" (a Shiki theme id) to theme.json.');
|
|
28
|
+
}
|
|
29
|
+
// A single string is one style for every code layout; a `{ light, dark }`
|
|
30
|
+
// pair requires the layout to declare which via `variant` — no default.
|
|
31
|
+
let theme;
|
|
32
|
+
if (typeof codeTheme === "string") {
|
|
33
|
+
theme = codeTheme;
|
|
34
|
+
}
|
|
35
|
+
else if (ctx.layoutVariant) {
|
|
36
|
+
theme = codeTheme[ctx.layoutVariant];
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
throw new Error(`Layout "${ctx.layoutName}" slot content (from ${ctx.source}): the theme's "codeTheme" is a ` +
|
|
40
|
+
'{ light, dark } pair, but this layout declares no "variant". Add variant: "light" or "dark" to the layout.');
|
|
41
|
+
}
|
|
42
|
+
return { paragraphs: await highlightCode(code.value, code.lang, theme) };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Run Shiki over a code block, producing StyledParagraph[] with per-token
|
|
47
|
+
* color runs. Blank lines become paragraphs with a single empty run.
|
|
48
|
+
*/
|
|
49
|
+
export async function highlightCode(code, language, theme) {
|
|
50
|
+
const { createHighlighter } = await import("shiki");
|
|
51
|
+
const lang = language;
|
|
52
|
+
const thm = theme;
|
|
53
|
+
const highlighter = await createHighlighter({ themes: [thm], langs: [lang] });
|
|
54
|
+
const { tokens } = highlighter.codeToTokens(code, { lang, theme: thm });
|
|
55
|
+
return tokens.map((line) => ({
|
|
56
|
+
runs: line.length === 0
|
|
57
|
+
? [{ text: "" }]
|
|
58
|
+
: line.map((token) => {
|
|
59
|
+
const run = { text: token.content };
|
|
60
|
+
if (token.color)
|
|
61
|
+
run.color = token.color.replace(/^#/, "");
|
|
62
|
+
return run;
|
|
63
|
+
}),
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { MdastType } from "../mdast.js";
|
|
2
|
+
import { AcceptType } from "../types.js";
|
|
3
|
+
export const IMAGE = {
|
|
4
|
+
match: (node) => node.type === MdastType.Image,
|
|
5
|
+
acceptType: AcceptType.Image,
|
|
6
|
+
// `node.url` is the raw `$category.name` ref; `resolveAssetRef` validates and
|
|
7
|
+
// resolves it (fail-fast on a malformed or unknown reference). `alt` is ignored.
|
|
8
|
+
compile: async (node, ctx) => ctx.resolveAssetRef(node.url),
|
|
9
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type BlockHandler } from "../types.js";
|
|
2
|
+
/** The markdown code-fence *language* that selects mermaid rendering. The CODE
|
|
3
|
+
* handler reuses this to exclude mermaid so the two fence kinds match disjointly
|
|
4
|
+
* on `lang`. */
|
|
5
|
+
export declare const MERMAID_LANG = "mermaid";
|
|
6
|
+
/**
|
|
7
|
+
* Recognize a ```mermaid fenced block at a region's top level, folding it to an
|
|
8
|
+
* Image fill, and compile it by rendering the definition to a PNG (cached under
|
|
9
|
+
* `<outputDir>/.tycoslide-cache/mermaid/<hash>.png`) and wrapping it as an
|
|
10
|
+
* ImageFill. Fit is always `contain` — mermaid diagrams are shown in their
|
|
11
|
+
* entirety. Resolution is strict: the theme MUST carry a `mermaid` block, MUST
|
|
12
|
+
* declare a `mermaidVariant`, and that variant MUST exist — each missing piece
|
|
13
|
+
* throws by name.
|
|
14
|
+
*/
|
|
15
|
+
export declare const MERMAID: BlockHandler;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { extname, join, resolve } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { ImageFit, SlotType } from "../../engine/index.js";
|
|
7
|
+
import { MdastType } from "../mdast.js";
|
|
8
|
+
import { AcceptType } from "../types.js";
|
|
9
|
+
import { buildMermaidRenderConfig, injectClassDefs, validateMermaidDefinition, } from "./mermaidTheme.js";
|
|
10
|
+
/** The markdown code-fence *language* that selects mermaid rendering. The CODE
|
|
11
|
+
* handler reuses this to exclude mermaid so the two fence kinds match disjointly
|
|
12
|
+
* on `lang`. */
|
|
13
|
+
export const MERMAID_LANG = "mermaid";
|
|
14
|
+
/**
|
|
15
|
+
* Recognize a ```mermaid fenced block at a region's top level, folding it to an
|
|
16
|
+
* Image fill, and compile it by rendering the definition to a PNG (cached under
|
|
17
|
+
* `<outputDir>/.tycoslide-cache/mermaid/<hash>.png`) and wrapping it as an
|
|
18
|
+
* ImageFill. Fit is always `contain` — mermaid diagrams are shown in their
|
|
19
|
+
* entirety. Resolution is strict: the theme MUST carry a `mermaid` block, MUST
|
|
20
|
+
* declare a `mermaidVariant`, and that variant MUST exist — each missing piece
|
|
21
|
+
* throws by name.
|
|
22
|
+
*/
|
|
23
|
+
export const MERMAID = {
|
|
24
|
+
match: (node) => node.type === MdastType.Code && node.lang === MERMAID_LANG,
|
|
25
|
+
acceptType: AcceptType.Image,
|
|
26
|
+
compile: async (node, ctx) => {
|
|
27
|
+
const definition = node.value;
|
|
28
|
+
const { config } = ctx;
|
|
29
|
+
if (!config.mermaid) {
|
|
30
|
+
throw new Error('Deck contains mermaid diagrams, but theme has no "mermaid" block. ' +
|
|
31
|
+
"Add mermaid color configuration to theme.json.");
|
|
32
|
+
}
|
|
33
|
+
const variantName = config.mermaidVariant;
|
|
34
|
+
if (variantName === undefined) {
|
|
35
|
+
throw new Error(`Layout "${ctx.layoutName}" slot content (from ${ctx.source}): deck contains a mermaid diagram but the theme ` +
|
|
36
|
+
'declares no "mermaidVariant". Add a theme-level "mermaidVariant" naming a "mermaid" entry to theme.json.');
|
|
37
|
+
}
|
|
38
|
+
const variant = config.mermaid[variantName];
|
|
39
|
+
if (!variant) {
|
|
40
|
+
throw new Error(`Slide layout "${ctx.layoutName}": mermaid variant "${variantName}" not found in theme. ` +
|
|
41
|
+
`Available variants: ${Object.keys(config.mermaid).join(", ")}`);
|
|
42
|
+
}
|
|
43
|
+
const cacheDir = ensureCacheDir(config);
|
|
44
|
+
const pngPath = await renderOne(definition, variantName, variant, cacheDir, config);
|
|
45
|
+
return { type: SlotType.Image, path: pngPath, fit: ImageFit.Contain };
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
const FONT_FORMATS = {
|
|
49
|
+
".woff2": "woff2",
|
|
50
|
+
".woff": "woff",
|
|
51
|
+
".ttf": "truetype",
|
|
52
|
+
".otf": "opentype",
|
|
53
|
+
};
|
|
54
|
+
/** The `#output` div's DOM contract, shared by the in-page script and the
|
|
55
|
+
* Playwright poller — one const so a rename can't drift the two sides into a
|
|
56
|
+
* silent 30s timeout. */
|
|
57
|
+
const RENDER_SIGNAL_ATTR = "data-render-signal";
|
|
58
|
+
const RENDER_ERROR_ATTR = "data-render-error";
|
|
59
|
+
const RenderSignal = { Pending: "pending", Done: "done" };
|
|
60
|
+
function hashKey(definition, variantName, renderConfig, fonts) {
|
|
61
|
+
const hash = createHash("sha256")
|
|
62
|
+
.update(variantName)
|
|
63
|
+
.update("\n")
|
|
64
|
+
.update(definition)
|
|
65
|
+
.update("\n")
|
|
66
|
+
.update(JSON.stringify(renderConfig));
|
|
67
|
+
for (const f of fonts)
|
|
68
|
+
hash.update("\n").update(`${f.family}:${f.weight}:${f.url}`);
|
|
69
|
+
return hash.digest("hex").slice(0, 16);
|
|
70
|
+
}
|
|
71
|
+
function ensureCacheDir(config) {
|
|
72
|
+
const base = resolve(config.outputDir ?? process.cwd(), ".tycoslide-cache", "mermaid");
|
|
73
|
+
mkdirSync(base, { recursive: true });
|
|
74
|
+
return base;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolve each theme font's `path` to an absolute `file://` URL. A bare specifier
|
|
78
|
+
* (`@fontsource/inter/files/...`) resolves through the theme's node_modules; a
|
|
79
|
+
* `./`- or `/`-prefixed path resolves against `rootDir`. A missing file fails fast
|
|
80
|
+
* naming the family + path — never a silent skip.
|
|
81
|
+
*/
|
|
82
|
+
function resolveFonts(rootDir, fonts) {
|
|
83
|
+
const require = createRequire(join(rootDir, "package.json"));
|
|
84
|
+
return fonts.map((font) => {
|
|
85
|
+
const isFsPath = font.path.startsWith(".") || font.path.startsWith("/");
|
|
86
|
+
let absPath;
|
|
87
|
+
if (isFsPath) {
|
|
88
|
+
absPath = resolve(rootDir, font.path);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
try {
|
|
92
|
+
absPath = require.resolve(font.path);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw new Error(`Theme font "${font.family}": could not resolve "${font.path}" from ${rootDir}. ` +
|
|
96
|
+
"Install the package, or use a ./- or /-prefixed file path.");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!existsSync(absPath)) {
|
|
100
|
+
throw new Error(`Theme font "${font.family}": file not found at ${absPath} (from "${font.path}").`);
|
|
101
|
+
}
|
|
102
|
+
const format = FONT_FORMATS[extname(absPath).toLowerCase()];
|
|
103
|
+
if (!format) {
|
|
104
|
+
throw new Error(`Theme font "${font.family}": unsupported format "${extname(absPath)}". ` +
|
|
105
|
+
`Supported: ${Object.keys(FONT_FORMATS).join(", ")}.`);
|
|
106
|
+
}
|
|
107
|
+
return { family: font.family, url: pathToFileURL(absPath).href, weight: font.weight ?? 400, format };
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
async function renderOne(definition, variantName, variant, cacheDir, compilerConfig) {
|
|
111
|
+
const validated = validateMermaidDefinition(definition);
|
|
112
|
+
const processed = injectClassDefs(validated, variant.accents, variant.accentOpacity, variant.accentTextColor, variant.surface, variant.groupCornerRadius);
|
|
113
|
+
const fonts = resolveFonts(compilerConfig.rootDir, compilerConfig.fonts ?? []);
|
|
114
|
+
// The variant asks mermaid for `fontFamily` by name; if the theme declared
|
|
115
|
+
// fonts but none provide that family, Chromium substitutes silently — the exact
|
|
116
|
+
// failure this feature exists to prevent — so surface the likely typo. Not fatal:
|
|
117
|
+
// a theme may intentionally target an OS-installed font and declare no faces.
|
|
118
|
+
if (fonts.length > 0 && !fonts.some((f) => f.family === variant.fontFamily)) {
|
|
119
|
+
console.warn(`Mermaid variant "${variantName}": fontFamily "${variant.fontFamily}" matches none of the ` +
|
|
120
|
+
`declared theme fonts (${[...new Set(fonts.map((f) => f.family))].join(", ")}); ` +
|
|
121
|
+
"the diagram will fall back to a substitute font.");
|
|
122
|
+
}
|
|
123
|
+
const renderConfig = buildMermaidRenderConfig(variant);
|
|
124
|
+
const key = hashKey(processed, variantName, renderConfig, fonts);
|
|
125
|
+
const outputPath = join(cacheDir, `${key}.png`);
|
|
126
|
+
if (existsSync(outputPath))
|
|
127
|
+
return outputPath;
|
|
128
|
+
await renderMermaidToPng(processed, renderConfig, fonts, outputPath);
|
|
129
|
+
return outputPath;
|
|
130
|
+
}
|
|
131
|
+
let bundleCache = null;
|
|
132
|
+
/** The mermaid browser bundle, read once and reused across renders. */
|
|
133
|
+
function getMermaidBundle() {
|
|
134
|
+
if (bundleCache === null) {
|
|
135
|
+
const require = createRequire(import.meta.url);
|
|
136
|
+
bundleCache = readFileSync(require.resolve("mermaid/dist/mermaid.min.js"), "utf-8");
|
|
137
|
+
}
|
|
138
|
+
return bundleCache;
|
|
139
|
+
}
|
|
140
|
+
function fontFaceCss(fonts) {
|
|
141
|
+
return fonts
|
|
142
|
+
.map((f) => `@font-face { font-family: '${f.family}'; src: url('${f.url}') format('${f.format}'); font-weight: ${f.weight}; font-style: normal; }`)
|
|
143
|
+
.join("\n");
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Render a mermaid definition to a transparent PNG via a headless Chromium
|
|
147
|
+
* (Playwright — the proven old driver, stronger headless font fidelity). The
|
|
148
|
+
* theme fonts are injected as `@font-face` and every registered face is awaited
|
|
149
|
+
* (`document.fonts.load()`) BEFORE `mermaid.render()` measures text — the only
|
|
150
|
+
* cross-platform, zero-install way to make Chromium lay out labels in the brand
|
|
151
|
+
* font instead of a substitute. mmdc's `--cssFile` injects too late (after layout)
|
|
152
|
+
* to affect metrics, which is why this replaces its programmatic API.
|
|
153
|
+
*/
|
|
154
|
+
async function renderMermaidToPng(processed, renderConfig, fonts, outputPath) {
|
|
155
|
+
const bundle = getMermaidBundle();
|
|
156
|
+
// JSON script blocks pass data without escaping issues; escape `</` so a value
|
|
157
|
+
// can't close the surrounding <script>.
|
|
158
|
+
const defJson = JSON.stringify(processed).replace(/<\//g, "<\\/");
|
|
159
|
+
const configJson = JSON.stringify(renderConfig).replace(/<\//g, "<\\/");
|
|
160
|
+
const html = `<!DOCTYPE html>
|
|
161
|
+
<html><head>
|
|
162
|
+
<style>
|
|
163
|
+
body { margin: 0; background: transparent; }
|
|
164
|
+
${fontFaceCss(fonts)}
|
|
165
|
+
</style>
|
|
166
|
+
</head>
|
|
167
|
+
<body>
|
|
168
|
+
<div id="output" ${RENDER_SIGNAL_ATTR}="${RenderSignal.Pending}"></div>
|
|
169
|
+
<script id="mermaid-def" type="application/json">${defJson}</script>
|
|
170
|
+
<script id="mermaid-config" type="application/json">${configJson}</script>
|
|
171
|
+
<script>${bundle}</script>
|
|
172
|
+
<script>
|
|
173
|
+
(async () => {
|
|
174
|
+
const out = document.getElementById('output');
|
|
175
|
+
try {
|
|
176
|
+
const def = JSON.parse(document.getElementById('mermaid-def').textContent);
|
|
177
|
+
const config = JSON.parse(document.getElementById('mermaid-config').textContent);
|
|
178
|
+
const container = document.createElement('div');
|
|
179
|
+
container.style.position = 'absolute';
|
|
180
|
+
container.style.top = '-9999px';
|
|
181
|
+
document.body.appendChild(container);
|
|
182
|
+
// document.fonts.ready alone resolves early (nothing references the face
|
|
183
|
+
// yet); iterating and .load()-ing each forces the fetch before mermaid
|
|
184
|
+
// measures text.
|
|
185
|
+
await Promise.all([...document.fonts].map(f => f.load()));
|
|
186
|
+
await document.fonts.ready;
|
|
187
|
+
mermaid.initialize(config);
|
|
188
|
+
const { svg } = await mermaid.render('mermaid-0', def, container);
|
|
189
|
+
container.remove();
|
|
190
|
+
out.innerHTML = svg;
|
|
191
|
+
out.setAttribute('${RENDER_SIGNAL_ATTR}', '${RenderSignal.Done}');
|
|
192
|
+
} catch (e) {
|
|
193
|
+
out.setAttribute('${RENDER_ERROR_ATTR}', (e && e.message) || String(e));
|
|
194
|
+
out.setAttribute('${RENDER_SIGNAL_ATTR}', '${RenderSignal.Done}');
|
|
195
|
+
}
|
|
196
|
+
})();
|
|
197
|
+
</script>
|
|
198
|
+
</body></html>`;
|
|
199
|
+
// Serve the page from a real file:// URL, not setContent — an about:blank
|
|
200
|
+
// origin can't fetch the file:// font resources (@font-face silently fails),
|
|
201
|
+
// whereas a file://-origin document loads them. Mirrors the old harness.
|
|
202
|
+
const htmlPath = `${outputPath}.html`;
|
|
203
|
+
const { chromium } = await import("playwright");
|
|
204
|
+
let browser;
|
|
205
|
+
try {
|
|
206
|
+
// Written inside the try so the finally always cleans it up, even if launch throws.
|
|
207
|
+
writeFileSync(htmlPath, html);
|
|
208
|
+
browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] });
|
|
209
|
+
const page = await browser.newPage({ viewport: { width: 800, height: 600 }, deviceScaleFactor: 2 });
|
|
210
|
+
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: "load" });
|
|
211
|
+
await page.waitForSelector(`#output[${RENDER_SIGNAL_ATTR}="${RenderSignal.Done}"]`, { timeout: 30000 });
|
|
212
|
+
const error = await page.getAttribute("#output", RENDER_ERROR_ATTR);
|
|
213
|
+
if (error)
|
|
214
|
+
throw new Error(error);
|
|
215
|
+
const svg = page.locator("#output svg");
|
|
216
|
+
if ((await svg.count()) === 0)
|
|
217
|
+
throw new Error("mermaid produced no SVG");
|
|
218
|
+
await svg.screenshot({ path: outputPath, omitBackground: true });
|
|
219
|
+
}
|
|
220
|
+
catch (e) {
|
|
221
|
+
throw new Error(`Mermaid render failed:\n${e instanceof Error ? e.message : String(e)}`);
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
await browser?.close();
|
|
225
|
+
rmSync(htmlPath, { force: true });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Mermaid theme types and definition-processing utilities.
|
|
3
3
|
*
|
|
4
4
|
* Owner of MermaidVariant / MermaidConfig — these types are compiler-facing
|
|
5
|
-
* (the engine has no idea mermaid exists). `
|
|
5
|
+
* (the engine has no idea mermaid exists). `blocks/mermaid.ts` consumes
|
|
6
6
|
* them to build --configFile input for `mmdc`.
|
|
7
7
|
*/
|
|
8
8
|
export type MermaidVariant = {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Mermaid theme types and definition-processing utilities.
|
|
3
3
|
*
|
|
4
4
|
* Owner of MermaidVariant / MermaidConfig — these types are compiler-facing
|
|
5
|
-
* (the engine has no idea mermaid exists). `
|
|
5
|
+
* (the engine has no idea mermaid exists). `blocks/mermaid.ts` consumes
|
|
6
6
|
* them to build --configFile input for `mmdc`.
|
|
7
7
|
*/
|
|
8
8
|
const FORBIDDEN_PATTERNS = [/^\s*style\s+\S+\s+/, /^\s*linkStyle\s+/, /^\s*classDef\s+/, /^\s*%%\{init/];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AcceptType, type BlockContext, type BlockFill, type BlockHandler } from "../types.js";
|
|
2
|
+
export type { BlockContext, BlockHandler };
|
|
3
|
+
/**
|
|
4
|
+
* Recognize a body/`::name::` region's content and return the engine
|
|
5
|
+
* `AcceptType` it folds to, plus a lazy `fill` that compiles it to the engine
|
|
6
|
+
* fill. The split is deliberate: `acceptType` is available synchronously so the
|
|
7
|
+
* caller can validate a region against its slot BEFORE running the (possibly
|
|
8
|
+
* expensive — Shiki, Playwright) `fill`. A region that is exactly one
|
|
9
|
+
* `mermaid`/`code`/`image`/`table` node folds to that kind; anything else
|
|
10
|
+
* aggregates to one TextFill (prose + lists + headings). The paragraph-unwrap
|
|
11
|
+
* mirrors remark wrapping a lone `` in a paragraph.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseSlotContent(text: string, ctx: BlockContext): {
|
|
14
|
+
acceptType: AcceptType;
|
|
15
|
+
fill: () => Promise<BlockFill>;
|
|
16
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { MdastType, parseRegion } from "../mdast.js";
|
|
2
|
+
import { AcceptType } from "../types.js";
|
|
3
|
+
import { CODE } from "./code.js";
|
|
4
|
+
import { IMAGE } from "./image.js";
|
|
5
|
+
import { MERMAID } from "./mermaid.js";
|
|
6
|
+
import { TABLE } from "./table.js";
|
|
7
|
+
import { compileTextAggregate } from "./text.js";
|
|
8
|
+
// ── The one registry: every content kind is one row ───────────────────────────
|
|
9
|
+
// One descriptor per content kind, carrying recognition (`match`/`acceptType`)
|
|
10
|
+
// and the single `compile` that folds the node straight into an engine fill. A
|
|
11
|
+
// region that is exactly one of these nodes folds to that kind. MERMAID precedes
|
|
12
|
+
// CODE, but their matches are already disjoint on `lang`.
|
|
13
|
+
const BLOCKS = [MERMAID, CODE, IMAGE, TABLE];
|
|
14
|
+
/**
|
|
15
|
+
* Recognize a body/`::name::` region's content and return the engine
|
|
16
|
+
* `AcceptType` it folds to, plus a lazy `fill` that compiles it to the engine
|
|
17
|
+
* fill. The split is deliberate: `acceptType` is available synchronously so the
|
|
18
|
+
* caller can validate a region against its slot BEFORE running the (possibly
|
|
19
|
+
* expensive — Shiki, Playwright) `fill`. A region that is exactly one
|
|
20
|
+
* `mermaid`/`code`/`image`/`table` node folds to that kind; anything else
|
|
21
|
+
* aggregates to one TextFill (prose + lists + headings). The paragraph-unwrap
|
|
22
|
+
* mirrors remark wrapping a lone `` in a paragraph.
|
|
23
|
+
*/
|
|
24
|
+
export function parseSlotContent(text, ctx) {
|
|
25
|
+
const nodes = parseRegion(text).children.map(unwrapLoneImage);
|
|
26
|
+
if (nodes.length === 1) {
|
|
27
|
+
const handler = BLOCKS.find((h) => h.match(nodes[0]));
|
|
28
|
+
if (handler)
|
|
29
|
+
return { acceptType: handler.acceptType, fill: () => handler.compile(nodes[0], ctx) };
|
|
30
|
+
}
|
|
31
|
+
// TEXT is the aggregate fallback (prose + lists + headings), not a registry
|
|
32
|
+
// row — it's what a region folds to when no single-block handler claims it.
|
|
33
|
+
// Wrapped in an async thunk so its fail-fast (a standalone kind mixed into
|
|
34
|
+
// prose) surfaces from `fill`, after the caller's acceptType check.
|
|
35
|
+
return { acceptType: AcceptType.Text, fill: async () => compileTextAggregate(nodes, ctx) };
|
|
36
|
+
}
|
|
37
|
+
/** remark wraps a lone `` in a paragraph; unwrap it so a standalone
|
|
38
|
+
* image is dispatched as an `image` node, not walked as prose. */
|
|
39
|
+
function unwrapLoneImage(node) {
|
|
40
|
+
if (node.type === MdastType.Paragraph && node.children.length === 1 && node.children[0].type === MdastType.Image) {
|
|
41
|
+
return node.children[0];
|
|
42
|
+
}
|
|
43
|
+
return node;
|
|
44
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { walkPhrasingChildren } from "../inline.js";
|
|
2
|
+
import { MdastType } from "../mdast.js";
|
|
3
|
+
import { AcceptType } from "../types.js";
|
|
4
|
+
export const TABLE = {
|
|
5
|
+
match: (node) => node.type === MdastType.Table,
|
|
6
|
+
acceptType: AcceptType.Table,
|
|
7
|
+
compile: async (node) => compileTable(node),
|
|
8
|
+
};
|
|
9
|
+
/** A GFM `table` → TableFill: first row is headers, the rest are body rows; each
|
|
10
|
+
* cell's phrasing children become `TextRun[]` via the shared inline walk. */
|
|
11
|
+
function compileTable(node) {
|
|
12
|
+
const [head, ...body] = node.children;
|
|
13
|
+
return {
|
|
14
|
+
headers: head ? head.children.map(cellParagraph) : [],
|
|
15
|
+
rows: body.map((row) => row.children.map(cellParagraph)),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/** One table cell → a StyledParagraph. An empty cell keeps a single empty run so
|
|
19
|
+
* downstream code sees a run to style, matching the old `parseInlineRuns("")`. */
|
|
20
|
+
function cellParagraph(cell) {
|
|
21
|
+
const runs = walkPhrasingChildren(cell.children, {});
|
|
22
|
+
return { runs: runs.length > 0 ? runs : [{ text: "" }] };
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RootContent } from "mdast";
|
|
2
|
+
import type { TextFill } from "../../engine/index.js";
|
|
3
|
+
import type { BlockContext } from "../types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Aggregate a region's prose blocks into one TextFill. Paragraphs, headings, and
|
|
6
|
+
* lists become `StyledParagraph[]` in document order; list nesting sets bullet
|
|
7
|
+
* levels. A region reaching here whose only node is an unsupported standalone
|
|
8
|
+
* block (a lone `blockquote`, `thematicBreak`, …) — or one that mixes a
|
|
9
|
+
* standalone kind (table/image/code) into prose — is illegal, so fail fast
|
|
10
|
+
* naming the layout/slide/slot and the node type.
|
|
11
|
+
*/
|
|
12
|
+
export declare function compileTextAggregate(nodes: RootContent[], ctx: BlockContext): TextFill;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { walkPhrasingChildren } from "../inline.js";
|
|
2
|
+
import { MdastType } from "../mdast.js";
|
|
3
|
+
/**
|
|
4
|
+
* Aggregate a region's prose blocks into one TextFill. Paragraphs, headings, and
|
|
5
|
+
* lists become `StyledParagraph[]` in document order; list nesting sets bullet
|
|
6
|
+
* levels. A region reaching here whose only node is an unsupported standalone
|
|
7
|
+
* block (a lone `blockquote`, `thematicBreak`, …) — or one that mixes a
|
|
8
|
+
* standalone kind (table/image/code) into prose — is illegal, so fail fast
|
|
9
|
+
* naming the layout/slide/slot and the node type.
|
|
10
|
+
*/
|
|
11
|
+
export function compileTextAggregate(nodes, ctx) {
|
|
12
|
+
const paragraphs = [];
|
|
13
|
+
for (const node of nodes) {
|
|
14
|
+
switch (node.type) {
|
|
15
|
+
case MdastType.Paragraph:
|
|
16
|
+
case MdastType.Heading:
|
|
17
|
+
for (const runs of splitRunsIntoParagraphs(walkPhrasingChildren(node.children, { breakAsNewline: true }))) {
|
|
18
|
+
paragraphs.push({ runs });
|
|
19
|
+
}
|
|
20
|
+
break;
|
|
21
|
+
case MdastType.List:
|
|
22
|
+
paragraphs.push(...listParagraphs(node, 0));
|
|
23
|
+
break;
|
|
24
|
+
default:
|
|
25
|
+
throw new Error(reject(nodes.length, node.type, ctx));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { paragraphs };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Word the rejection for a node that prose aggregation can't take. A region that
|
|
32
|
+
* is a single unsupported block asks to be a standalone content kind that
|
|
33
|
+
* doesn't exist; a node reaching here beside others is a standalone kind
|
|
34
|
+
* (table/image/code) mixed into prose — both name the layout/slide/slot + type.
|
|
35
|
+
*/
|
|
36
|
+
function reject(nodeCount, nodeType, ctx) {
|
|
37
|
+
const where = `Slide ${ctx.slideIdx}: layout "${ctx.layoutName}" slot content (from ${ctx.source})`;
|
|
38
|
+
if (nodeCount === 1) {
|
|
39
|
+
return `${where} is a standalone "${nodeType}" block, which is not a supported content kind.`;
|
|
40
|
+
}
|
|
41
|
+
return (`${where} mixes a "${nodeType}" block with other content; ` +
|
|
42
|
+
"a table, image, or code block must be the region's only content.");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A `list` → bulleted `StyledParagraph[]`. Each item's paragraph/heading runs
|
|
46
|
+
* become a bullet at `level` (top-level list = 0); a nested `list` recurses at
|
|
47
|
+
* `level + 1`. Ordered and unordered both yield plain bullets — the engine has
|
|
48
|
+
* no ordered flag.
|
|
49
|
+
*/
|
|
50
|
+
function listParagraphs(list, level) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const item of list.children) {
|
|
53
|
+
for (const child of item.children) {
|
|
54
|
+
if (child.type === MdastType.List) {
|
|
55
|
+
out.push(...listParagraphs(child, level + 1));
|
|
56
|
+
}
|
|
57
|
+
else if (child.type === MdastType.Paragraph || child.type === MdastType.Heading) {
|
|
58
|
+
for (const runs of splitRunsIntoParagraphs(walkPhrasingChildren(child.children, { breakAsNewline: true }))) {
|
|
59
|
+
out.push({ runs, bullet: { level } });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Split a paragraph's runs on newlines into one `TextRun[]` per source line —
|
|
68
|
+
* preserving the pre-mdast "one source line = one StyledParagraph" behavior.
|
|
69
|
+
* Both a soft break (`\n` inside a text run) and a markdown hard break (a
|
|
70
|
+
* `break` node the inline walk emits as a `"\n"` run in block context) split
|
|
71
|
+
* here. Empty segments (a style boundary landing on a line edge) are dropped,
|
|
72
|
+
* and empty lines produce no paragraph, matching the old blank-line filter.
|
|
73
|
+
*/
|
|
74
|
+
function splitRunsIntoParagraphs(runs) {
|
|
75
|
+
const lines = [];
|
|
76
|
+
let current = [];
|
|
77
|
+
for (const run of runs) {
|
|
78
|
+
const parts = run.text.split("\n");
|
|
79
|
+
for (let i = 0; i < parts.length; i++) {
|
|
80
|
+
if (i > 0) {
|
|
81
|
+
lines.push(current);
|
|
82
|
+
current = [];
|
|
83
|
+
}
|
|
84
|
+
if (parts[i] !== "")
|
|
85
|
+
current.push({ ...run, text: parts[i] });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
lines.push(current);
|
|
89
|
+
return lines.filter((line) => line.length > 0);
|
|
90
|
+
}
|