@tycoworks/tycoslide 0.7.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.
- package/README.md +6 -6
- package/SKILL.md +2 -1
- package/dist/cli.js +8 -107
- package/dist/engine/dom.d.ts +5 -0
- package/dist/engine/dom.js +19 -3
- package/dist/engine/fillers/filler.d.ts +21 -13
- package/dist/engine/fillers/filler.js +21 -27
- package/dist/engine/fillers/image.d.ts +46 -7
- package/dist/engine/fillers/image.js +78 -36
- package/dist/engine/fillers/table.d.ts +4 -3
- package/dist/engine/fillers/table.js +58 -9
- package/dist/engine/generate.d.ts +34 -18
- package/dist/engine/generate.js +234 -65
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/notes.d.ts +76 -0
- package/dist/engine/notes.js +313 -0
- package/dist/engine/types.d.ts +57 -24
- package/dist/engine/types.js +11 -7
- package/dist/index.d.ts +19 -25
- package/dist/index.js +65 -92
- package/dist/manifest.js +19 -29
- package/dist/markdown/blocks/code.d.ts +15 -0
- package/dist/markdown/blocks/code.js +50 -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 +18 -30
- package/dist/markdown/deckCompiler.js +167 -122
- 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 +99 -0
- package/dist/markdown/schema/themeConfigSchema.js +145 -0
- package/dist/markdown/types.d.ts +184 -137
- package/dist/markdown/types.js +30 -19
- package/package.json +7 -3
- package/syntax.md +25 -5
- 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 -89
- package/dist/markdown/resolvers/resolver.d.ts +0 -42
- 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 `` 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
|
+
}
|
|
@@ -1,36 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type ImageFill } from "../engine/index.js";
|
|
2
2
|
import type { ParsedDocument } from "./slideParser.js";
|
|
3
|
-
import {
|
|
3
|
+
import { AssetType, type CompilerConfig, type CompilerDeck } from "./types.js";
|
|
4
4
|
/**
|
|
5
|
-
* Wrap
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* fences through MermaidResolver, which wraps the rendered PNG directly).
|
|
9
|
-
*
|
|
10
|
-
* `path` must be absolute. Callers are responsible for resolution (see
|
|
11
|
-
* `resolve(rootDir, ...)` in the compiler / cli.ts).
|
|
12
|
-
*/
|
|
13
|
-
export declare function toImageFill(param: CompilerImageParameter, path: string): ImageFill;
|
|
14
|
-
/**
|
|
15
|
-
* Reserved keys in a deck's frontmatter — global (theme, output) and per-slide
|
|
16
|
-
* (layout, body). Exported so callers (e.g. cli.ts) reference the constants
|
|
17
|
-
* 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.
|
|
18
8
|
*/
|
|
19
|
-
export declare
|
|
20
|
-
readonly LAYOUT: "layout";
|
|
21
|
-
readonly BODY: "body";
|
|
22
|
-
readonly OUTPUT: "output";
|
|
23
|
-
readonly THEME: "theme";
|
|
24
|
-
};
|
|
9
|
+
export declare function toImageFill(path: string, type: AssetType): ImageFill;
|
|
25
10
|
/**
|
|
26
|
-
* Compile a parsed deck document against a
|
|
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`.
|
|
27
15
|
*
|
|
28
|
-
* `rootDir`
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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.
|
|
34
23
|
*/
|
|
35
|
-
export declare function compileDeck(doc: ParsedDocument,
|
|
36
|
-
export { FitMode };
|
|
24
|
+
export declare function compileDeck(doc: ParsedDocument, config: CompilerConfig): Promise<CompilerDeck>;
|
|
@@ -1,83 +1,41 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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 {
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*
|
|
13
|
-
* `path` must be absolute. Callers are responsible for resolution (see
|
|
14
|
-
* `resolve(rootDir, ...)` in the compiler / cli.ts).
|
|
15
|
-
*/
|
|
16
|
-
export function toImageFill(param, path) {
|
|
17
|
-
return { type: SlotType.Image, path, fit: param.fit };
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Reserved keys in a deck's frontmatter — global (theme, output) and per-slide
|
|
21
|
-
* (layout, body). Exported so callers (e.g. cli.ts) reference the constants
|
|
22
|
-
* instead of literal strings.
|
|
23
|
-
*/
|
|
24
|
-
export const RESERVED_KEY = {
|
|
25
|
-
LAYOUT: "layout",
|
|
26
|
-
BODY: "body",
|
|
27
|
-
OUTPUT: "output",
|
|
28
|
-
THEME: "theme",
|
|
6
|
+
import { AssetType, ParameterType, RESERVED_KEY, } from "./types.js";
|
|
7
|
+
/** Map each semantic asset type to the engine's object-fit directive. */
|
|
8
|
+
const FIT_FOR = {
|
|
9
|
+
[AssetType.Icon]: ImageFit.ScaleDown,
|
|
10
|
+
[AssetType.Image]: ImageFit.Contain,
|
|
11
|
+
[AssetType.Background]: ImageFit.Cover,
|
|
29
12
|
};
|
|
30
|
-
const CODE_FENCE_RE = /^```(\w+)\n([\s\S]*?)```\s*$/;
|
|
31
|
-
function toTextFill(text) {
|
|
32
|
-
const paragraphs = text
|
|
33
|
-
.split(/\r?\n/)
|
|
34
|
-
.filter((line) => line.trim() !== "")
|
|
35
|
-
.map(parseStyledParagraph);
|
|
36
|
-
return { paragraphs };
|
|
37
|
-
}
|
|
38
|
-
function parseSlotContent(text) {
|
|
39
|
-
const fence = CODE_FENCE_RE.exec(text.trim());
|
|
40
|
-
if (fence) {
|
|
41
|
-
if (fence[1] === FenceType.Mermaid) {
|
|
42
|
-
const block = { type: FenceType.Mermaid, definition: fence[2].replace(/\n$/, "") };
|
|
43
|
-
return block;
|
|
44
|
-
}
|
|
45
|
-
const block = { type: FenceType.Code, language: fence[1], source: fence[2].replace(/\n$/, "") };
|
|
46
|
-
return block;
|
|
47
|
-
}
|
|
48
|
-
const table = parseGfmTable(text);
|
|
49
|
-
if (table)
|
|
50
|
-
return table;
|
|
51
|
-
return toTextFill(text);
|
|
52
|
-
}
|
|
53
13
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* fill/fence looks like.
|
|
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.
|
|
58
17
|
*/
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
18
|
+
export function toImageFill(path, type) {
|
|
19
|
+
return { type: SlotType.Image, path, fit: FIT_FOR[type] };
|
|
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*)$/;
|
|
65
25
|
/**
|
|
66
|
-
* Assert that a region's parsed block
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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.
|
|
69
31
|
*/
|
|
70
|
-
function assertSlotRegion(slot,
|
|
71
|
-
if (!
|
|
72
|
-
|
|
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}.`);
|
|
73
37
|
}
|
|
74
38
|
}
|
|
75
|
-
function isImageParameter(param) {
|
|
76
|
-
return param.type === ParameterType.Image;
|
|
77
|
-
}
|
|
78
|
-
function isTemplateParameter(param) {
|
|
79
|
-
return param.type === ParameterType.Template;
|
|
80
|
-
}
|
|
81
39
|
/**
|
|
82
40
|
* Resolve a user-supplied image path against the deck's root directory.
|
|
83
41
|
* When `rootDir` is empty, the path is returned unchanged so callers that
|
|
@@ -89,6 +47,26 @@ function resolveImagePath(rootDir, path) {
|
|
|
89
47
|
return path;
|
|
90
48
|
return resolve(rootDir, path);
|
|
91
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
|
+
}
|
|
92
70
|
/**
|
|
93
71
|
* Validate a layout's key spaces once, independent of any slide, so every later
|
|
94
72
|
* frontmatter lookup and content-map write is unambiguous. Two spaces must each
|
|
@@ -122,50 +100,67 @@ function validateLayout(layout) {
|
|
|
122
100
|
contentKeys.add(key);
|
|
123
101
|
};
|
|
124
102
|
for (const param of layout.parameters) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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;
|
|
133
117
|
}
|
|
134
|
-
for (const key of keys)
|
|
135
|
-
claimAuthorKey(key, `template parameter "${param.shapeName}"`);
|
|
136
|
-
claimContentKey(param.shapeName, "template parameter");
|
|
137
118
|
}
|
|
138
119
|
}
|
|
139
120
|
for (const slot of layout.slots) {
|
|
140
121
|
claimContentKey(slot.key, "slot");
|
|
141
122
|
}
|
|
142
123
|
}
|
|
143
|
-
function compileStep(slide,
|
|
124
|
+
async function compileStep(slide, config, assetTypeByPath, resolveAssetRef) {
|
|
125
|
+
const { layouts, rootDir } = config;
|
|
144
126
|
const { frontmatter, body, slots, index } = slide;
|
|
145
127
|
const layout = frontmatter[RESERVED_KEY.LAYOUT];
|
|
146
128
|
if (layout === undefined) {
|
|
147
129
|
throw new Error(`Slide ${index}: missing required "${RESERVED_KEY.LAYOUT}" in frontmatter`);
|
|
148
130
|
}
|
|
131
|
+
// Speaker notes are slide-level metadata, stripped from frontmatter before
|
|
132
|
+
// slot/param resolution — exactly like `layout`. Coerce to string if present.
|
|
133
|
+
// An empty `notes:` key parses as YAML null; treat it as absent (loose `==`)
|
|
134
|
+
// so it doesn't write the literal string "null".
|
|
135
|
+
const notesRaw = frontmatter[RESERVED_KEY.NOTES];
|
|
136
|
+
const notes = notesRaw == null ? undefined : String(notesRaw);
|
|
149
137
|
const layoutName = String(layout);
|
|
150
138
|
const layoutDef = layouts.find((l) => l.name === layoutName);
|
|
151
139
|
if (!layoutDef) {
|
|
152
140
|
const known = layouts.map((l) => l.name).join(", ");
|
|
153
141
|
throw new Error(`Slide ${index}: unknown layout "${layoutName}". Available layouts: ${known}`);
|
|
154
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);
|
|
155
147
|
// Map each author-facing key to its owning parameter: template keys → the template
|
|
156
148
|
// parameter that declares them, image keys → the image parameter. validateLayout
|
|
157
149
|
// (run once per layout in compileDeck) has already proven these key spaces are
|
|
158
150
|
// collision-free, so a later lookup is unambiguous.
|
|
159
|
-
const templateParams =
|
|
151
|
+
const templateParams = [];
|
|
160
152
|
const imageByKey = new Map();
|
|
161
153
|
const templateParamByKey = new Map();
|
|
162
154
|
for (const param of layoutDef.parameters) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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;
|
|
169
164
|
}
|
|
170
165
|
}
|
|
171
166
|
const slotsByKey = new Map(layoutDef.slots.map((s) => [s.key, s]));
|
|
@@ -174,11 +169,17 @@ function compileStep(slide, layouts, rootDir) {
|
|
|
174
169
|
// (gathered per parameter, expanded together once every line is read).
|
|
175
170
|
const valuesByTemplateParam = new Map();
|
|
176
171
|
for (const [key, value] of Object.entries(frontmatter)) {
|
|
177
|
-
if (key === RESERVED_KEY.LAYOUT)
|
|
172
|
+
if (key === RESERVED_KEY.LAYOUT || key === RESERVED_KEY.NOTES)
|
|
178
173
|
continue;
|
|
179
174
|
const image = imageByKey.get(key);
|
|
180
175
|
if (image) {
|
|
181
|
-
|
|
176
|
+
const imgPath = resolveImagePath(rootDir, String(value));
|
|
177
|
+
const assetType = assetTypeByPath.get(imgPath);
|
|
178
|
+
if (assetType === undefined) {
|
|
179
|
+
throw new Error(`Slide image "${image.key}": "${value}" has no asset-catalog entry, so no type. ` +
|
|
180
|
+
`Add it to the theme's assets with a type (icon | image | background).`);
|
|
181
|
+
}
|
|
182
|
+
content[image.key] = toImageFill(imgPath, assetType);
|
|
182
183
|
continue;
|
|
183
184
|
}
|
|
184
185
|
const templateParam = templateParamByKey.get(key);
|
|
@@ -189,10 +190,9 @@ function compileStep(slide, layouts, rootDir) {
|
|
|
189
190
|
valuesByTemplateParam.set(templateParam, bucket);
|
|
190
191
|
}
|
|
191
192
|
bucket.set(key, String(value));
|
|
192
|
-
continue;
|
|
193
193
|
}
|
|
194
|
-
|
|
195
|
-
|
|
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.
|
|
196
196
|
}
|
|
197
197
|
// Expand each template parameter whose keys were supplied. Filling any key fills the
|
|
198
198
|
// parameter as a whole — a missing key throws (fail-fast in templateToSegments). A
|
|
@@ -217,9 +217,18 @@ function compileStep(slide, layouts, rootDir) {
|
|
|
217
217
|
if (!bodySlot) {
|
|
218
218
|
throw new Error(`Slide ${index}: layout "${layoutName}" does not accept body content. Valid slots: ${[...slotsByKey.keys()].join(", ")}`);
|
|
219
219
|
}
|
|
220
|
-
const parsedBody = parseSlotContent(body
|
|
221
|
-
|
|
222
|
-
|
|
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();
|
|
223
232
|
}
|
|
224
233
|
// `::name::` regions resolve against the layout's slots.
|
|
225
234
|
for (const [name, text] of Object.entries(slots)) {
|
|
@@ -228,9 +237,10 @@ function compileStep(slide, layouts, rootDir) {
|
|
|
228
237
|
throw new Error(`Slide ${index}: unknown slot "::${name}::" in layout "${layoutName}". ` +
|
|
229
238
|
`Valid slots: ${[...slotsByKey.keys()].join(", ")}`);
|
|
230
239
|
}
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
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();
|
|
234
244
|
}
|
|
235
245
|
// Required image parameters (missing frontmatter key) and required slots
|
|
236
246
|
// (missing region) throw with layout + key context. Required template parameters are
|
|
@@ -245,23 +255,29 @@ function compileStep(slide, layouts, rootDir) {
|
|
|
245
255
|
throw new Error(`Slide ${index}: layout "${layoutName}" requires slot "${slot.key}"; none provided`);
|
|
246
256
|
}
|
|
247
257
|
}
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
258
|
+
// Content values are already engine fills — code fences highlighted to TextFill
|
|
259
|
+
// and mermaid fences rendered to ImageFill by their handler's `compile`.
|
|
260
|
+
const step = { layout: layoutName, content };
|
|
261
|
+
if (notes !== undefined)
|
|
262
|
+
step.notes = notes;
|
|
263
|
+
return step;
|
|
252
264
|
}
|
|
253
|
-
const KNOWN_GLOBAL_KEYS = new Set([RESERVED_KEY.THEME, RESERVED_KEY.OUTPUT]);
|
|
254
265
|
/**
|
|
255
|
-
* Compile a parsed deck document against a
|
|
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`.
|
|
256
270
|
*
|
|
257
|
-
* `rootDir`
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
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.
|
|
263
278
|
*/
|
|
264
|
-
export function compileDeck(doc,
|
|
279
|
+
export async function compileDeck(doc, config) {
|
|
280
|
+
const { layouts, rootDir, assets } = config;
|
|
265
281
|
const theme = doc.global[RESERVED_KEY.THEME];
|
|
266
282
|
if (theme === undefined) {
|
|
267
283
|
throw new Error(`Missing required "${RESERVED_KEY.THEME}" in global frontmatter`);
|
|
@@ -270,20 +286,49 @@ export function compileDeck(doc, layouts, rootDir = "") {
|
|
|
270
286
|
if (unknownGlobal.length > 0) {
|
|
271
287
|
throw new Error(`Unknown key(s) in global frontmatter: ${unknownGlobal.join(", ")}. Valid keys: ${[...KNOWN_GLOBAL_KEYS].join(", ")}`);
|
|
272
288
|
}
|
|
273
|
-
// Validate
|
|
274
|
-
//
|
|
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);
|
|
275
293
|
for (const layout of layouts)
|
|
276
294
|
validateLayout(layout);
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
295
|
+
// Index each catalog asset's resolved path → its declared type, so an image
|
|
296
|
+
// filled by path inherits the scaling tolerance intrinsic to its pixels.
|
|
297
|
+
const assetTypeByPath = new Map();
|
|
298
|
+
for (const group of Object.values(assets)) {
|
|
299
|
+
for (const entry of Object.values(group)) {
|
|
300
|
+
assetTypeByPath.set(resolveImagePath(rootDir, entry.path), entry.type);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
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);
|
|
280
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 };
|
|
281
329
|
const output = doc.global[RESERVED_KEY.OUTPUT];
|
|
282
330
|
if (output !== undefined) {
|
|
283
331
|
deck.output = String(output);
|
|
284
332
|
}
|
|
285
333
|
return deck;
|
|
286
334
|
}
|
|
287
|
-
// Re-export FitMode so callers that build ImageFills by hand can import it
|
|
288
|
-
// through the compiler surface without reaching into the engine.
|
|
289
|
-
export { FitMode };
|