@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
@@ -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 `![alt](src)` 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 `![alt](src)` 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,2 @@
1
+ import { type BlockHandler } from "../types.js";
2
+ export declare const TABLE: BlockHandler;
@@ -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
+ }
@@ -1,27 +1,24 @@
1
1
  import { type ImageFill } from "../engine/index.js";
2
2
  import type { ParsedDocument } from "./slideParser.js";
3
- import { type AssetCatalog, AssetType, type CompilerDeck, type CompilerLayout } from "./types.js";
4
- export declare function toImageFill(path: string, type: AssetType): ImageFill;
3
+ import { AssetType, type CompilerConfig, type CompilerDeck } from "./types.js";
5
4
  /**
6
- * Reserved keys in a deck's frontmatter global (theme, output) and per-slide
7
- * (layout, body). Exported so callers (e.g. cli.ts) reference the constants
8
- * instead of literal strings.
5
+ * Wrap an absolute image path as an ImageFill, expanding the resolved asset
6
+ * `type` into the engine's scaling constraints. Callers resolve the path (see
7
+ * `resolveImagePath`) and the type (from the catalog) first.
9
8
  */
10
- export declare const RESERVED_KEY: {
11
- readonly LAYOUT: "layout";
12
- readonly BODY: "body";
13
- readonly OUTPUT: "output";
14
- readonly THEME: "theme";
15
- readonly NOTES: "notes";
16
- };
9
+ export declare function toImageFill(path: string, type: AssetType): ImageFill;
17
10
  /**
18
- * Compile a parsed deck document against a set of layouts.
11
+ * Compile a parsed deck document against a theme `config`. Each slide's content
12
+ * is compiled straight into engine fills — prose/tables/images plus highlighted
13
+ * code (Shiki) and rendered mermaid (PNG) — so the returned deck is
14
+ * engine-shaped, ready for `buildDeck`.
19
15
  *
20
- * `rootDir` (optional) is the base directory for resolving relative image
21
- * paths declared in the deck's frontmatter or named slots. When omitted (or
22
- * empty), image paths are returned unchanged — callers that already produce
23
- * absolute paths (or callers that don't need resolution, e.g. unit tests)
24
- * can rely on the pass-through. When provided, relative paths are resolved
25
- * to absolute via `path.resolve(rootDir, path)`; absolute paths pass through.
16
+ * `config.rootDir` is the base directory for resolving relative image paths
17
+ * declared in the deck's frontmatter or named slots. When empty, image paths are
18
+ * returned unchanged — callers that already produce absolute paths (or don't need
19
+ * resolution, e.g. unit tests) rely on the pass-through. When set, relative paths
20
+ * are resolved to absolute via `path.resolve(rootDir, path)`; absolute paths pass
21
+ * through. `config.codeTheme` / `config.mermaid` / `config.mermaidVariant` /
22
+ * `config.outputDir` feed the code and mermaid compiles.
26
23
  */
27
- export declare function compileDeck(doc: ParsedDocument, layouts: CompilerLayout[], rootDir?: string, assets?: AssetCatalog): CompilerDeck;
24
+ export declare function compileDeck(doc: ParsedDocument, config: CompilerConfig): Promise<CompilerDeck>;
@@ -1,86 +1,41 @@
1
1
  import { resolve } from "node:path";
2
- import { FILLERS, ImageFit, SlotType } from "../engine/index.js";
3
- import { parseGfmTable, parseStyledParagraph } from "./parsers.js";
4
- import { RESOLVERS } from "./resolvers/resolver.js";
2
+ import { ImageFit, SlotType } from "../engine/index.js";
3
+ import { parseSlotContent } from "./blocks/registry.js";
4
+ import { validateSlideFrontmatter } from "./schema/deckSchema.js";
5
5
  import { templateKeys, templateToSegments } from "./textTemplate.js";
6
- import { AssetType, CompilerSlotType, FenceType, ParameterType, } from "./types.js";
7
- /**
8
- * Wrap an absolute image path as an ImageFill, expanding the resolved asset
9
- * `type` into the engine's scaling constraints. Callers resolve the path (see
10
- * `resolveImagePath`) and the type (from the catalog) first.
11
- */
6
+ import { AssetType, ParameterType, RESERVED_KEY, } from "./types.js";
12
7
  /** Map each semantic asset type to the engine's object-fit directive. */
13
8
  const FIT_FOR = {
14
9
  [AssetType.Icon]: ImageFit.ScaleDown,
15
10
  [AssetType.Image]: ImageFit.Contain,
16
11
  [AssetType.Background]: ImageFit.Cover,
17
12
  };
18
- export function toImageFill(path, type) {
19
- return { type: SlotType.Image, path, fit: FIT_FOR[type] };
20
- }
21
13
  /**
22
- * Reserved keys in a deck's frontmatter global (theme, output) and per-slide
23
- * (layout, body). Exported so callers (e.g. cli.ts) reference the constants
24
- * instead of literal strings.
14
+ * Wrap an absolute image path as an ImageFill, expanding the resolved asset
15
+ * `type` into the engine's scaling constraints. Callers resolve the path (see
16
+ * `resolveImagePath`) and the type (from the catalog) first.
25
17
  */
26
- export const RESERVED_KEY = {
27
- LAYOUT: "layout",
28
- BODY: "body",
29
- OUTPUT: "output",
30
- THEME: "theme",
31
- NOTES: "notes",
32
- };
33
- const CODE_FENCE_RE = /^```(\w+)\n([\s\S]*?)```\s*$/;
34
- function toTextFill(text) {
35
- const paragraphs = text
36
- .split(/\r?\n/)
37
- .filter((line) => line.trim() !== "")
38
- .map(parseStyledParagraph);
39
- return { paragraphs };
40
- }
41
- function parseSlotContent(text) {
42
- const fence = CODE_FENCE_RE.exec(text.trim());
43
- if (fence) {
44
- if (fence[1] === FenceType.Mermaid) {
45
- const block = { type: FenceType.Mermaid, definition: fence[2].replace(/\n$/, "") };
46
- return block;
47
- }
48
- const block = { type: FenceType.Code, language: fence[1], source: fence[2].replace(/\n$/, "") };
49
- return block;
50
- }
51
- const table = parseGfmTable(text);
52
- if (table)
53
- return table;
54
- return toTextFill(text);
18
+ export function toImageFill(path, type) {
19
+ return { type: SlotType.Image, path, fit: FIT_FOR[type] };
55
20
  }
21
+ const KNOWN_GLOBAL_KEYS = new Set([RESERVED_KEY.THEME, RESERVED_KEY.OUTPUT]);
22
+ // Anchored whole-field reference: the entire value is `$category.name` or it is
23
+ // not a reference at all. Anchored ⇒ no escaping concerns.
24
+ const ASSET_REF_RE = /^\$([a-zA-Z]\w*)\.([a-zA-Z]\w*)$/;
56
25
  /**
57
- * Matcher for each CompilerSlotType, composed from the engine's `FILLERS` (text,
58
- * table) and the compiler's `RESOLVERS` (code, mermaid) no raw guards imported
59
- * here, so a slot's expected shape always tracks whatever those registries say a
60
- * fill/fence looks like.
61
- */
62
- const REGION_MATCHERS = {
63
- [CompilerSlotType.Text]: FILLERS[SlotType.Text].matches,
64
- [CompilerSlotType.Table]: FILLERS[SlotType.Table].matches,
65
- [CompilerSlotType.Code]: RESOLVERS[FenceType.Code].matches,
66
- [CompilerSlotType.Mermaid]: RESOLVERS[FenceType.Mermaid].matches,
67
- };
68
- /**
69
- * Assert that a region's parsed block matches the slot's declared type. Called
70
- * after `parseSlotContent` narrows a body/`::name::` region into a MarkdownBlock.
71
- * Keys off the CompilerSlotType discriminator via `REGION_MATCHERS`.
26
+ * Assert that a region's parsed block folds to a type the slot `accepts`. The
27
+ * folded type comes straight from `parseSlotContent` (which returns it beside
28
+ * the block no re-probe). A slot may accept several types (text/table/image);
29
+ * the author's markdown shape selects one. A type the slot does not accept fails
30
+ * fast, naming the layout, slot, the type it got, and the types the slot accepts.
72
31
  */
73
- function assertSlotRegion(slot, block, slideIdx, source) {
74
- if (!REGION_MATCHERS[slot.type](block)) {
75
- throw new Error(`Slide ${slideIdx}: slot "${slot.key}" (type "${slot.type}") got wrong content shape from ${source}.`);
32
+ function assertSlotRegion(slot, got, layoutName, slideIdx, source) {
33
+ if (!slot.accepts.some((b) => b.type === got)) {
34
+ const accepted = slot.accepts.map((b) => b.type).join(", ");
35
+ throw new Error(`Slide ${slideIdx}: layout "${layoutName}" slot "${slot.key}" does not accept ${got} content ` +
36
+ `(from ${source}); it accepts: ${accepted}.`);
76
37
  }
77
38
  }
78
- function isImageParameter(param) {
79
- return param.type === ParameterType.Image;
80
- }
81
- function isTemplateParameter(param) {
82
- return param.type === ParameterType.Template;
83
- }
84
39
  /**
85
40
  * Resolve a user-supplied image path against the deck's root directory.
86
41
  * When `rootDir` is empty, the path is returned unchanged so callers that
@@ -92,6 +47,26 @@ function resolveImagePath(rootDir, path) {
92
47
  return path;
93
48
  return resolve(rootDir, path);
94
49
  }
50
+ /**
51
+ * Assert no two layouts sample the same base slide. In the sampled-composition
52
+ * model one layout is one sampled base slide's region-arrangement, so a shared
53
+ * `slideNumber` means the old single-content-type welding leaked through — two
54
+ * layouts on one physical slide that should be one multi-`accepts` layout.
55
+ * `sourceSlide` inside a slot's `accepts` block is a distinct concept (a
56
+ * specimen source for one content type) and is legitimately reused across
57
+ * layouts, so it is deliberately not checked here.
58
+ */
59
+ function assertUniqueSlideNumbers(layouts) {
60
+ const layoutNameBySlideNumber = new Map();
61
+ for (const layout of layouts) {
62
+ const existing = layoutNameBySlideNumber.get(layout.slideNumber);
63
+ if (existing !== undefined) {
64
+ throw new Error(`Layouts "${existing}" and "${layout.name}" share slideNumber ${layout.slideNumber}; ` +
65
+ "each layout must sample a distinct base slide (one layout = one slide, with slots that accept multiple content types).");
66
+ }
67
+ layoutNameBySlideNumber.set(layout.slideNumber, layout.name);
68
+ }
69
+ }
95
70
  /**
96
71
  * Validate a layout's key spaces once, independent of any slide, so every later
97
72
  * frontmatter lookup and content-map write is unambiguous. Two spaces must each
@@ -125,25 +100,29 @@ function validateLayout(layout) {
125
100
  contentKeys.add(key);
126
101
  };
127
102
  for (const param of layout.parameters) {
128
- if (isImageParameter(param)) {
129
- claimAuthorKey(param.key, "image parameter");
130
- claimContentKey(param.key, "image parameter");
131
- }
132
- else {
133
- const keys = templateKeys(param.template);
134
- if (param.required && keys.length === 0) {
135
- throw new Error(`Layout "${layout.name}": template parameter "${param.shapeName}" is marked required but its template has no keys to fill.`);
103
+ switch (param.type) {
104
+ case ParameterType.Image:
105
+ claimAuthorKey(param.key, "image parameter");
106
+ claimContentKey(param.key, "image parameter");
107
+ break;
108
+ case ParameterType.Template: {
109
+ const keys = templateKeys(param.template);
110
+ if (param.required && keys.length === 0) {
111
+ throw new Error(`Layout "${layout.name}": template parameter "${param.shapeName}" is marked required but its template has no keys to fill.`);
112
+ }
113
+ for (const key of keys)
114
+ claimAuthorKey(key, `template parameter "${param.shapeName}"`);
115
+ claimContentKey(param.shapeName, "template parameter");
116
+ break;
136
117
  }
137
- for (const key of keys)
138
- claimAuthorKey(key, `template parameter "${param.shapeName}"`);
139
- claimContentKey(param.shapeName, "template parameter");
140
118
  }
141
119
  }
142
120
  for (const slot of layout.slots) {
143
121
  claimContentKey(slot.key, "slot");
144
122
  }
145
123
  }
146
- function compileStep(slide, layouts, rootDir, assetTypeByPath) {
124
+ async function compileStep(slide, config, assetTypeByPath, resolveAssetRef) {
125
+ const { layouts, rootDir } = config;
147
126
  const { frontmatter, body, slots, index } = slide;
148
127
  const layout = frontmatter[RESERVED_KEY.LAYOUT];
149
128
  if (layout === undefined) {
@@ -161,20 +140,27 @@ function compileStep(slide, layouts, rootDir, assetTypeByPath) {
161
140
  const known = layouts.map((l) => l.name).join(", ");
162
141
  throw new Error(`Slide ${index}: unknown layout "${layoutName}". Available layouts: ${known}`);
163
142
  }
143
+ // Reject any frontmatter key not declared by this layout's parameters (reserved
144
+ // layout/notes stripped first). The per-layout strict schema IS the unknown-key
145
+ // check — it fires before the resolution loop, so that loop only sees valid keys.
146
+ validateSlideFrontmatter(frontmatter, layoutDef, index);
164
147
  // Map each author-facing key to its owning parameter: template keys → the template
165
148
  // parameter that declares them, image keys → the image parameter. validateLayout
166
149
  // (run once per layout in compileDeck) has already proven these key spaces are
167
150
  // collision-free, so a later lookup is unambiguous.
168
- const templateParams = layoutDef.parameters.filter(isTemplateParameter);
151
+ const templateParams = [];
169
152
  const imageByKey = new Map();
170
153
  const templateParamByKey = new Map();
171
154
  for (const param of layoutDef.parameters) {
172
- if (isImageParameter(param)) {
173
- imageByKey.set(param.key, param);
174
- }
175
- else {
176
- for (const key of templateKeys(param.template))
177
- templateParamByKey.set(key, param);
155
+ switch (param.type) {
156
+ case ParameterType.Image:
157
+ imageByKey.set(param.key, param);
158
+ break;
159
+ case ParameterType.Template:
160
+ templateParams.push(param);
161
+ for (const key of templateKeys(param.template))
162
+ templateParamByKey.set(key, param);
163
+ break;
178
164
  }
179
165
  }
180
166
  const slotsByKey = new Map(layoutDef.slots.map((s) => [s.key, s]));
@@ -204,10 +190,9 @@ function compileStep(slide, layouts, rootDir, assetTypeByPath) {
204
190
  valuesByTemplateParam.set(templateParam, bucket);
205
191
  }
206
192
  bucket.set(key, String(value));
207
- continue;
208
193
  }
209
- const validKeys = [...templateParamByKey.keys(), ...imageByKey.keys()].join(", ");
210
- throw new Error(`Slide ${index}: unknown key "${key}" in layout "${layoutName}". Valid parameters: ${validKeys}`);
194
+ // Unreachable: validateSlideFrontmatter (above) already rejected any key that is
195
+ // neither an image key nor a template key, so every key here routes to a parameter.
211
196
  }
212
197
  // Expand each template parameter whose keys were supplied. Filling any key fills the
213
198
  // parameter as a whole — a missing key throws (fail-fast in templateToSegments). A
@@ -232,9 +217,18 @@ function compileStep(slide, layouts, rootDir, assetTypeByPath) {
232
217
  if (!bodySlot) {
233
218
  throw new Error(`Slide ${index}: layout "${layoutName}" does not accept body content. Valid slots: ${[...slotsByKey.keys()].join(", ")}`);
234
219
  }
235
- const parsedBody = parseSlotContent(body);
236
- assertSlotRegion(bodySlot, parsedBody, index, "body content");
237
- content[RESERVED_KEY.BODY] = parsedBody;
220
+ const parsedBody = parseSlotContent(body, {
221
+ resolveAssetRef,
222
+ layoutName,
223
+ slideIdx: index,
224
+ source: "body content",
225
+ config,
226
+ });
227
+ // Validate the slot accepts this region's type BEFORE running the (possibly
228
+ // expensive — Shiki, Playwright) fill: a mismatched region fails fast without
229
+ // spinning up a renderer.
230
+ assertSlotRegion(bodySlot, parsedBody.acceptType, layoutName, index, "body content");
231
+ content[RESERVED_KEY.BODY] = await parsedBody.fill();
238
232
  }
239
233
  // `::name::` regions resolve against the layout's slots.
240
234
  for (const [name, text] of Object.entries(slots)) {
@@ -243,9 +237,10 @@ function compileStep(slide, layouts, rootDir, assetTypeByPath) {
243
237
  throw new Error(`Slide ${index}: unknown slot "::${name}::" in layout "${layoutName}". ` +
244
238
  `Valid slots: ${[...slotsByKey.keys()].join(", ")}`);
245
239
  }
246
- const block = parseSlotContent(text);
247
- assertSlotRegion(slot, block, index, `::${name}::`);
248
- content[name] = block;
240
+ const source = `::${name}::`;
241
+ const parsed = parseSlotContent(text, { resolveAssetRef, layoutName, slideIdx: index, source, config });
242
+ assertSlotRegion(slot, parsed.acceptType, layoutName, index, source);
243
+ content[name] = await parsed.fill();
249
244
  }
250
245
  // Required image parameters (missing frontmatter key) and required slots
251
246
  // (missing region) throw with layout + key context. Required template parameters are
@@ -260,26 +255,29 @@ function compileStep(slide, layouts, rootDir, assetTypeByPath) {
260
255
  throw new Error(`Slide ${index}: layout "${layoutName}" requires slot "${slot.key}"; none provided`);
261
256
  }
262
257
  }
263
- // CompilerDeckStep.content values are MarkdownBlockCodeFence and
264
- // MermaidFence are legal in transit until the resolvers narrow them into
265
- // StyledParagraph[] / ImageFill before the engine sees the deck.
258
+ // Content values are already engine fills code fences highlighted to TextFill
259
+ // and mermaid fences rendered to ImageFill by their handler's `compile`.
266
260
  const step = { layout: layoutName, content };
267
261
  if (notes !== undefined)
268
262
  step.notes = notes;
269
263
  return step;
270
264
  }
271
- const KNOWN_GLOBAL_KEYS = new Set([RESERVED_KEY.THEME, RESERVED_KEY.OUTPUT]);
272
265
  /**
273
- * Compile a parsed deck document against a set of layouts.
266
+ * Compile a parsed deck document against a theme `config`. Each slide's content
267
+ * is compiled straight into engine fills — prose/tables/images plus highlighted
268
+ * code (Shiki) and rendered mermaid (PNG) — so the returned deck is
269
+ * engine-shaped, ready for `buildDeck`.
274
270
  *
275
- * `rootDir` (optional) is the base directory for resolving relative image
276
- * paths declared in the deck's frontmatter or named slots. When omitted (or
277
- * empty), image paths are returned unchanged — callers that already produce
278
- * absolute paths (or callers that don't need resolution, e.g. unit tests)
279
- * can rely on the pass-through. When provided, relative paths are resolved
280
- * to absolute via `path.resolve(rootDir, path)`; absolute paths pass through.
271
+ * `config.rootDir` is the base directory for resolving relative image paths
272
+ * declared in the deck's frontmatter or named slots. When empty, image paths are
273
+ * returned unchanged — callers that already produce absolute paths (or don't need
274
+ * resolution, e.g. unit tests) rely on the pass-through. When set, relative paths
275
+ * are resolved to absolute via `path.resolve(rootDir, path)`; absolute paths pass
276
+ * through. `config.codeTheme` / `config.mermaid` / `config.mermaidVariant` /
277
+ * `config.outputDir` feed the code and mermaid compiles.
281
278
  */
282
- export function compileDeck(doc, layouts, rootDir = "", assets = {}) {
279
+ export async function compileDeck(doc, config) {
280
+ const { layouts, rootDir, assets } = config;
283
281
  const theme = doc.global[RESERVED_KEY.THEME];
284
282
  if (theme === undefined) {
285
283
  throw new Error(`Missing required "${RESERVED_KEY.THEME}" in global frontmatter`);
@@ -288,8 +286,10 @@ export function compileDeck(doc, layouts, rootDir = "", assets = {}) {
288
286
  if (unknownGlobal.length > 0) {
289
287
  throw new Error(`Unknown key(s) in global frontmatter: ${unknownGlobal.join(", ")}. Valid keys: ${[...KNOWN_GLOBAL_KEYS].join(", ")}`);
290
288
  }
291
- // Validate every layout's key spaces up front (once per layout), so a broken
292
- // theme fails fast regardless of which layouts this deck's slides use.
289
+ // Validate the layout list as a whole (one pass), then each layout's key
290
+ // spaces up front (once per layout), so a broken theme fails fast regardless
291
+ // of which layouts this deck's slides use.
292
+ assertUniqueSlideNumbers(layouts);
293
293
  for (const layout of layouts)
294
294
  validateLayout(layout);
295
295
  // Index each catalog asset's resolved path → its declared type, so an image
@@ -300,10 +300,32 @@ export function compileDeck(doc, layouts, rootDir = "", assets = {}) {
300
300
  assetTypeByPath.set(resolveImagePath(rootDir, entry.path), entry.type);
301
301
  }
302
302
  }
303
- const deck = {
304
- theme: String(theme),
305
- steps: doc.slides.map((slide) => compileStep(slide, layouts, rootDir, assetTypeByPath)),
303
+ // Resolve a body/`::name::` `$category.name` reference against the theme's
304
+ // curated asset catalog. Anchored ⇒ the whole ref is the reference or it is
305
+ // nothing; a found entry wraps through the same path→ImageFit mapping as a
306
+ // frontmatter image (`toImageFill`), so a body image has no second fit story.
307
+ const resolveAssetRef = (ref) => {
308
+ const match = ASSET_REF_RE.exec(ref);
309
+ if (!match) {
310
+ throw new Error(`Asset reference "${ref}" must be in the form $category.name (e.g. $logos.primary).`);
311
+ }
312
+ const [, category, name] = match;
313
+ const entry = assets[category]?.[name];
314
+ if (!entry) {
315
+ const available = Object.entries(assets)
316
+ .flatMap(([cat, group]) => Object.keys(group).map((n) => `$${cat}.${n}`))
317
+ .join(", ");
318
+ throw new Error(`Unknown asset reference "${ref}". Available: ${available}`);
319
+ }
320
+ return toImageFill(resolveImagePath(rootDir, entry.path), entry.type);
306
321
  };
322
+ // Slides compile in order: a slide's structural errors (unknown layout/key,
323
+ // bad asset ref, accept-type mismatch) fire before its own content is rendered.
324
+ const steps = [];
325
+ for (const slide of doc.slides) {
326
+ steps.push(await compileStep(slide, config, assetTypeByPath, resolveAssetRef));
327
+ }
328
+ const deck = { theme: String(theme), steps };
307
329
  const output = doc.global[RESERVED_KEY.OUTPUT];
308
330
  if (output !== undefined) {
309
331
  deck.output = String(output);
@@ -1,13 +1,13 @@
1
- import type { AssetCatalog, CompilerDeck, CompilerLayout } from "./types.js";
2
- export declare function compileMarkdownDeck(source: string, layouts: CompilerLayout[], rootDir?: string, assets?: AssetCatalog): CompilerDeck;
3
- export { compileDeck, RESERVED_KEY } from "./deckCompiler.js";
4
- export { parseGfmTable, parseInlineRuns, parseProseLine, parseStyledParagraph } from "./parsers.js";
5
- export { CodeResolver, highlightCode, isCodeBlock } from "./resolvers/code.js";
6
- export { isMermaidBlock, MermaidResolver } from "./resolvers/mermaid.js";
7
- export type { MermaidConfig, MermaidVariant } from "./resolvers/mermaidTheme.js";
8
- export type { ResolveContext, Resolver } from "./resolvers/resolver.js";
9
- export { isFence, RESOLVERS, resolveFences } from "./resolvers/resolver.js";
1
+ import type { CompilerConfig, CompilerDeck } from "./types.js";
2
+ export declare function compileMarkdownDeck(source: string, config: CompilerConfig): Promise<CompilerDeck>;
3
+ export { highlightCode } from "./blocks/code.js";
4
+ export type { MermaidConfig, MermaidVariant } from "./blocks/mermaidTheme.js";
5
+ export { type BlockContext, type BlockHandler, parseSlotContent } from "./blocks/registry.js";
6
+ export { compileDeck } from "./deckCompiler.js";
7
+ export { parseInlineRuns } from "./inline.js";
8
+ export { parseRegion } from "./mdast.js";
9
+ export { loadThemeConfig, parseThemeConfig, ThemeConfigSchema } from "./schema/themeConfigSchema.js";
10
10
  export type { ParsedDocument, RawSlide } from "./slideParser.js";
11
11
  export { parseSlideDocument } from "./slideParser.js";
12
- export type { AssetCatalog, AssetEntry, CodeFence, CompilerCodeSlot, CompilerConfig, CompilerDeck, CompilerDeckStep, CompilerImageParameter, CompilerLayout, CompilerMermaidSlot, CompilerParameter, CompilerSlot, CompilerTableSlot, CompilerTemplateParameter, CompilerTextSlot, CompilerThemeConfig, MarkdownBlock, MermaidFence, ResolvedCompilerDeck, ResolvedCompilerDeckStep, } from "./types.js";
13
- export { AssetType, CompilerSlotType, FenceType, ParameterType } from "./types.js";
12
+ export type { AssetCatalog, AssetEntry, CompilerBlock, CompilerConfig, CompilerDeck, CompilerDeckStep, CompilerImageParameter, CompilerLayout, CompilerParameter, CompilerSlot, CompilerTemplateParameter, CompilerThemeConfig, EngineFill, Limit, } from "./types.js";
13
+ export { AcceptType, AssetType, ParameterType, RESERVED_KEY } from "./types.js";
@@ -1,13 +1,14 @@
1
1
  import { compileDeck } from "./deckCompiler.js";
2
2
  import { parseSlideDocument } from "./slideParser.js";
3
- export function compileMarkdownDeck(source, layouts, rootDir = "", assets = {}) {
3
+ export function compileMarkdownDeck(source, config) {
4
4
  const doc = parseSlideDocument(source);
5
- return compileDeck(doc, layouts, rootDir, assets);
5
+ return compileDeck(doc, config);
6
6
  }
7
- export { compileDeck, RESERVED_KEY } from "./deckCompiler.js";
8
- export { parseGfmTable, parseInlineRuns, parseProseLine, parseStyledParagraph } from "./parsers.js";
9
- export { CodeResolver, highlightCode, isCodeBlock } from "./resolvers/code.js";
10
- export { isMermaidBlock, MermaidResolver } from "./resolvers/mermaid.js";
11
- export { isFence, RESOLVERS, resolveFences } from "./resolvers/resolver.js";
7
+ export { highlightCode } from "./blocks/code.js";
8
+ export { parseSlotContent } from "./blocks/registry.js";
9
+ export { compileDeck } from "./deckCompiler.js";
10
+ export { parseInlineRuns } from "./inline.js";
11
+ export { parseRegion } from "./mdast.js";
12
+ export { loadThemeConfig, parseThemeConfig, ThemeConfigSchema } from "./schema/themeConfigSchema.js";
12
13
  export { parseSlideDocument } from "./slideParser.js";
13
- export { AssetType, CompilerSlotType, FenceType, ParameterType } from "./types.js";
14
+ export { AcceptType, AssetType, ParameterType, RESERVED_KEY } from "./types.js";