@tycoworks/tycoslide 0.8.0 → 0.9.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.
Files changed (50) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +5 -15
  3. package/dist/engine/fillers/filler.d.ts +21 -13
  4. package/dist/engine/fillers/filler.js +21 -24
  5. package/dist/engine/generate.d.ts +23 -17
  6. package/dist/engine/generate.js +157 -70
  7. package/dist/engine/index.d.ts +1 -1
  8. package/dist/engine/types.d.ts +39 -9
  9. package/dist/index.d.ts +15 -21
  10. package/dist/index.js +63 -88
  11. package/dist/manifest.js +17 -25
  12. package/dist/markdown/blocks/code.d.ts +15 -0
  13. package/dist/markdown/blocks/code.js +50 -0
  14. package/dist/markdown/blocks/image.d.ts +2 -0
  15. package/dist/markdown/blocks/image.js +9 -0
  16. package/dist/markdown/blocks/mermaid.d.ts +15 -0
  17. package/dist/markdown/blocks/mermaid.js +227 -0
  18. package/dist/markdown/{resolvers → blocks}/mermaidTheme.d.ts +1 -1
  19. package/dist/markdown/{resolvers → blocks}/mermaidTheme.js +1 -1
  20. package/dist/markdown/blocks/registry.d.ts +16 -0
  21. package/dist/markdown/blocks/registry.js +44 -0
  22. package/dist/markdown/blocks/table.d.ts +2 -0
  23. package/dist/markdown/blocks/table.js +23 -0
  24. package/dist/markdown/blocks/text.d.ts +12 -0
  25. package/dist/markdown/blocks/text.js +90 -0
  26. package/dist/markdown/deckCompiler.d.ts +17 -20
  27. package/dist/markdown/deckCompiler.js +135 -113
  28. package/dist/markdown/index.d.ts +11 -11
  29. package/dist/markdown/index.js +9 -8
  30. package/dist/markdown/inline.d.ts +26 -0
  31. package/dist/markdown/inline.js +136 -0
  32. package/dist/markdown/mdast.d.ts +25 -0
  33. package/dist/markdown/mdast.js +49 -0
  34. package/dist/markdown/schema/deckSchema.d.ts +30 -0
  35. package/dist/markdown/schema/deckSchema.js +51 -0
  36. package/dist/markdown/schema/strict.d.ts +9 -0
  37. package/dist/markdown/schema/strict.js +18 -0
  38. package/dist/markdown/schema/themeConfigSchema.d.ts +99 -0
  39. package/dist/markdown/schema/themeConfigSchema.js +145 -0
  40. package/dist/markdown/types.d.ts +168 -135
  41. package/dist/markdown/types.js +23 -23
  42. package/package.json +6 -3
  43. package/dist/markdown/parsers.d.ts +0 -32
  44. package/dist/markdown/parsers.js +0 -233
  45. package/dist/markdown/resolvers/code.d.ts +0 -17
  46. package/dist/markdown/resolvers/code.js +0 -44
  47. package/dist/markdown/resolvers/mermaid.d.ts +0 -14
  48. package/dist/markdown/resolvers/mermaid.js +0 -81
  49. package/dist/markdown/resolvers/resolver.d.ts +0 -42
  50. package/dist/markdown/resolvers/resolver.js +0 -52
package/dist/index.js CHANGED
@@ -1,99 +1,64 @@
1
1
  import { generate, SlotType, } from "./engine/index.js";
2
- import { isFence, resolveFences } from "./markdown/resolvers/resolver.js";
3
- import { CompilerSlotType, ParameterType, } from "./markdown/types.js";
2
+ import { ParameterType, } from "./markdown/types.js";
4
3
  /**
5
- * Narrow a resolved content map to the engine-shaped value union. Runs after
6
- * `resolveFences`, so no CodeFence or MermaidFence should remain a leftover is
7
- * a resolver bug and throws. Every surviving value is already an engine fill
8
- * (TextFill / TableFill / ImageFill / TemplateFill), so no unwrapping is needed.
4
+ * A frontmatter parameter always fills one physical shape on the layout's own
5
+ * slide, so it projects to a single base `Block` (`sourceSlide === baseSlide`)
6
+ * and never transplants its `frame` is never read. `NO_FRAME` is that unread
7
+ * placeholder; only body slots with a transplant block carry a real frame.
9
8
  */
10
- function narrowContent(content) {
11
- const out = {};
12
- for (const [key, value] of Object.entries(content)) {
13
- if (isFence(value)) {
14
- throw new Error(`resolveDeck: slot "${key}" still holds an unresolved ${value.type} block ` +
15
- "after resolvers ran. This is a resolver bug.");
16
- }
17
- out[key] = value;
9
+ const NO_FRAME = { x: 0, y: 0, cx: 0, cy: 0 };
10
+ function paramToEngineSlot(param, baseSlide) {
11
+ const block = (type) => ({ type, sourceSlide: baseSlide, shapeName: param.shapeName });
12
+ switch (param.type) {
13
+ case ParameterType.Template:
14
+ // A text shape carries no top-level key — its template placeholders are the keys. The
15
+ // compiler emits its expanded content under shapeName, so the engine slot
16
+ // is keyed by shapeName too.
17
+ return { key: param.shapeName, frame: NO_FRAME, accepts: [block(SlotType.Template)] };
18
+ case ParameterType.Image:
19
+ return { key: param.key, frame: NO_FRAME, accepts: [block(SlotType.Image)] };
18
20
  }
19
- return out;
20
21
  }
21
22
  /**
22
- * Run every compiler-owned resolver over `deck` (highlight code fences,
23
- * render mermaid PNGs) and return a `ResolvedCompilerDeck` whose content
24
- * values are narrowed to the engine's `TextFill | TableFill | ImageFill |
25
- * TemplateFill` union. Structurally equivalent to the engine's `Deck` a
26
- * caller passes the returned value straight to `generate()` with no cast.
27
- *
28
- * Fails fast if `deck.output` is missing: downstream `generate()` requires it,
29
- * and the CLI populates it before calling `buildDeck`; a programmatic caller
30
- * that forgot to set it hits the error here instead of a confusing engine-side
31
- * failure.
23
+ * Project a body slot's real `accepts` to engine `Block[]` and pass its `frame`
24
+ * through. The compiler `accepts` already carry engine content types
25
+ * (text/table/image) code folded to text and mermaid to image at authoring
26
+ * so projection is 1:1. A slot with no declared `frame` (a base-only slot that
27
+ * never transplants) gets `NO_FRAME`, which the engine never reads.
32
28
  */
33
- export async function resolveDeck(deck, config) {
34
- await resolveFences(deck, config);
35
- if (deck.output === undefined) {
36
- throw new Error('resolveDeck: deck.output is not set. Set it (e.g. "deck.pptx") before calling buildDeck.');
37
- }
38
- return {
39
- theme: deck.theme,
40
- output: deck.output,
41
- steps: deck.steps.map((step) => {
42
- const resolvedStep = { layout: step.layout };
43
- if (step.content)
44
- resolvedStep.content = narrowContent(step.content);
45
- if (step.notes !== undefined)
46
- resolvedStep.notes = step.notes;
47
- return resolvedStep;
48
- }),
49
- };
29
+ function slotToEngineSlot(slot) {
30
+ const accepts = slot.accepts.map((b) => {
31
+ const eb = { type: b.type, sourceSlide: b.sourceSlide, shapeName: b.shapeName };
32
+ if (b.startAt !== undefined)
33
+ eb.startAt = b.startAt;
34
+ return eb;
35
+ });
36
+ return { key: slot.key, frame: slot.frame ?? NO_FRAME, accepts };
50
37
  }
51
38
  /**
52
- * Project a CompilerParameter or CompilerSlot down to the engine's flat Slot.
53
- * Parameters (Template, Image) map straight to their engine equivalent; compiler-
54
- * only slot types (Code, Mermaid) map to Text / Image since their resolved
55
- * StyledParagraph[] / ImageFill content is filled by the corresponding engine
56
- * primitive once the compiler is done.
57
- *
58
- * The discriminated unions narrow per-variant fields, so the projection is a
59
- * straight switch over all six type values — no runtime "wrong field on wrong
60
- * type" checks; TypeScript enforces the invariants at authoring time.
39
+ * Compiler→engine boundary check: a slot with a transplant block (any block
40
+ * whose `sourceSlide` differs from the layout's base slide) must declare a
41
+ * `frame` the real region the transplant is positioned into. A base-only slot
42
+ * (all blocks in place) needs none. Missing fail fast, naming layout + slot.
43
+ * (The engine's `assertSlotsWellFormed` separately rejects duplicate accept
44
+ * types.)
61
45
  */
62
- function toEngineSlot(slot) {
63
- switch (slot.type) {
64
- case ParameterType.Template:
65
- // A text shape carries no top-level key — its template placeholders are the keys. The
66
- // compiler emits its expanded content under shapeName, so the engine slot
67
- // is keyed by shapeName too.
68
- return { key: slot.shapeName, shapeName: slot.shapeName, type: SlotType.Template };
69
- case ParameterType.Image:
70
- return { key: slot.key, shapeName: slot.shapeName, type: SlotType.Image };
71
- case CompilerSlotType.Text: {
72
- const result = { key: slot.key, shapeName: slot.shapeName, type: SlotType.Text };
73
- if (slot.startAt !== undefined)
74
- result.startAt = slot.startAt;
75
- return result;
46
+ function assertSlotFrames(layout) {
47
+ for (const slot of layout.slots) {
48
+ const transplants = slot.accepts.some((b) => b.sourceSlide !== layout.slideNumber);
49
+ if (transplants && slot.frame === undefined) {
50
+ throw new Error(`Layout "${layout.name}" slot "${slot.key}": a transplant block (sourceSlide ${layout.slideNumber}) ` +
51
+ 'requires a "frame" (the region to position it into), but none is declared.');
76
52
  }
77
- case CompilerSlotType.Table:
78
- return { key: slot.key, shapeName: slot.shapeName, type: SlotType.Table };
79
- case CompilerSlotType.Code:
80
- // Highlighter resolves the code fence into StyledParagraph[]; engine
81
- // fills it via fillText.
82
- return { key: slot.key, shapeName: slot.shapeName, type: SlotType.Text };
83
- case CompilerSlotType.Mermaid:
84
- // Mermaid renderer produces a PNG (ImageFill); engine fills it via
85
- // fillImage. The fit lives on the ImageFill, not the engine Slot.
86
- return { key: slot.key, shapeName: slot.shapeName, type: SlotType.Image };
87
53
  }
88
54
  }
89
55
  function toEngineLayout(layout) {
56
+ assertSlotFrames(layout);
57
+ const base = layout.slideNumber;
90
58
  return {
91
59
  name: layout.name,
92
- slideNumber: layout.slideNumber,
93
- description: layout.description,
94
- whenToUse: layout.whenToUse,
95
- whenNotToUse: layout.whenNotToUse,
96
- slots: [...layout.parameters.map(toEngineSlot), ...layout.slots.map(toEngineSlot)],
60
+ baseSlide: base,
61
+ slots: [...layout.parameters.map((p) => paramToEngineSlot(p, base)), ...layout.slots.map(slotToEngineSlot)],
97
62
  };
98
63
  }
99
64
  /**
@@ -124,21 +89,31 @@ export function toEngineConfig(config) {
124
89
  };
125
90
  }
126
91
  /**
127
- * End-to-end build: run compiler-owned resolvers (syntax highlighting, mermaid
128
- * PNG rendering) over the deck via `resolveDeck`, which returns a narrowed
129
- * `ResolvedCompilerDeck` structurally equivalent to the engine's `Deck`
130
- * then hand it to the engine's primitives-only `generate()`. No cast required:
131
- * the narrowing happens at the type level via `resolveDeck`.
92
+ * End-to-end build: `compileDeck` already produced engine-shaped content (code
93
+ * highlighted, mermaid rendered), so `buildDeck` only asserts an `output` is set
94
+ * and hands the deck to the engine's primitives-only `generate()`. The deck is
95
+ * structurally equivalent to the engine's `Deck` once `output` is present, so no
96
+ * cast is required. `buildDeck` does not itself validate `config` — a
97
+ * programmatic caller assembling a `CompilerConfig` by hand should load it
98
+ * through `loadThemeConfig` (or `parseThemeConfig`) first to get the same
99
+ * fail-fast structural checks the CLI gets.
100
+ *
101
+ * Fails fast if `deck.output` is missing: `generate()` requires it, and the CLI
102
+ * populates it before calling `buildDeck`; a programmatic caller that forgot to
103
+ * set it hits this error instead of a confusing engine-side failure.
132
104
  *
133
105
  * Mermaid PNGs are cached under `<outputDir>/.tycoslide-cache/mermaid/` so no
134
106
  * post-write cleanup is needed.
135
107
  */
136
108
  export async function buildDeck(deck, config, options = {}) {
137
- const resolved = await resolveDeck(deck, config);
138
- await generate(resolved, toEngineConfig(config), options);
109
+ if (deck.output === undefined) {
110
+ throw new Error('buildDeck: deck.output is not set. Set it (e.g. "deck.pptx") before calling buildDeck.');
111
+ }
112
+ const engineDeck = { theme: deck.theme, output: deck.output, steps: deck.steps };
113
+ await generate(engineDeck, toEngineConfig(config), options);
139
114
  }
140
115
  // Engine — primitives-only public surface.
141
116
  export { fillImage, fillTable, fillTemplate, fillText, generate, SlotType } from "./engine/index.js";
142
117
  export { generateManifest } from "./manifest.js";
143
118
  // Markdown / Compiler
144
- export { CompilerSlotType, compileMarkdownDeck, FenceType, ParameterType, resolveFences } from "./markdown/index.js";
119
+ export { AcceptType, compileMarkdownDeck, loadThemeConfig, ParameterType, parseThemeConfig } from "./markdown/index.js";
package/dist/manifest.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { templateKeys } from "./markdown/textTemplate.js";
2
- import { CompilerSlotType, ParameterType } from "./markdown/types.js";
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, type: slot.type };
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
- name: layout.name,
56
- slideNumber: layout.slideNumber,
57
- description: layout.description,
58
- whenToUse: layout.whenToUse,
59
- whenNotToUse: layout.whenNotToUse,
60
- parameters: layout.parameters.flatMap(stripParameter),
61
- slots: layout.slots.map(stripSlot),
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,15 @@
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` (one style per theme — the design-system framing); a deck with
8
+ * code fences but no theme-level `codeTheme` throws, naming the offending slot.
9
+ */
10
+ export declare const CODE: BlockHandler;
11
+ /**
12
+ * Run Shiki over a code block, producing StyledParagraph[] with per-token
13
+ * color runs. Blank lines become paragraphs with a single empty run.
14
+ */
15
+ export declare function highlightCode(code: string, language: string, theme: string): Promise<StyledParagraph[]>;
@@ -0,0 +1,50 @@
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` (one style per theme — the design-system framing); a deck with
9
+ * code fences but no theme-level `codeTheme` throws, naming the offending slot.
10
+ */
11
+ export const CODE = {
12
+ match: (node) => node.type === MdastType.Code && node.lang !== MERMAID_LANG,
13
+ acceptType: AcceptType.Text,
14
+ compile: async (node, ctx) => {
15
+ const code = node;
16
+ // A fence with no language can't be highlighted (no Shiki grammar to pick),
17
+ // so fail fast naming the slot rather than attempt a language-less highlight.
18
+ if (!code.lang) {
19
+ throw new Error(`Slide ${ctx.slideIdx}: layout "${ctx.layoutName}" slot content (from ${ctx.source}) has a code fence ` +
20
+ "with no language; add one after the opening ``` (e.g. ```sql).");
21
+ }
22
+ const theme = ctx.config.codeTheme;
23
+ if (!theme) {
24
+ throw new Error(`Layout "${ctx.layoutName}" slot content (from ${ctx.source}): deck contains a code fence but the theme ` +
25
+ 'declares no "codeTheme". Add a theme-level "codeTheme" (a Shiki theme id) to theme.json.');
26
+ }
27
+ return { paragraphs: await highlightCode(code.value, code.lang, theme) };
28
+ },
29
+ };
30
+ /**
31
+ * Run Shiki over a code block, producing StyledParagraph[] with per-token
32
+ * color runs. Blank lines become paragraphs with a single empty run.
33
+ */
34
+ export async function highlightCode(code, language, theme) {
35
+ const { createHighlighter } = await import("shiki");
36
+ const lang = language;
37
+ const thm = theme;
38
+ const highlighter = await createHighlighter({ themes: [thm], langs: [lang] });
39
+ const { tokens } = highlighter.codeToTokens(code, { lang, theme: thm });
40
+ return tokens.map((line) => ({
41
+ runs: line.length === 0
42
+ ? [{ text: "" }]
43
+ : line.map((token) => {
44
+ const run = { text: token.content };
45
+ if (token.color)
46
+ run.color = token.color.replace(/^#/, "");
47
+ return run;
48
+ }),
49
+ }));
50
+ }
@@ -0,0 +1,2 @@
1
+ import { type BlockHandler } from "../types.js";
2
+ export declare const IMAGE: BlockHandler;
@@ -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). `resolvers/mermaid.ts` consumes
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). `resolvers/mermaid.ts` consumes
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 `![alt](src)` in a paragraph.
12
+ */
13
+ export declare function parseSlotContent(text: string, ctx: BlockContext): {
14
+ acceptType: AcceptType;
15
+ fill: () => Promise<BlockFill>;
16
+ };