@tycoworks/tycoslide 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/SKILL.md +249 -0
- package/bin/tycoslide.js +2 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +197 -0
- package/dist/engine/dom.d.ts +92 -0
- package/dist/engine/dom.js +354 -0
- package/dist/engine/fillers/filler.d.ts +22 -0
- package/dist/engine/fillers/filler.js +53 -0
- package/dist/engine/fillers/image.d.ts +19 -0
- package/dist/engine/fillers/image.js +105 -0
- package/dist/engine/fillers/table.d.ts +21 -0
- package/dist/engine/fillers/table.js +62 -0
- package/dist/engine/fillers/template.d.ts +27 -0
- package/dist/engine/fillers/template.js +221 -0
- package/dist/engine/fillers/text.d.ts +28 -0
- package/dist/engine/fillers/text.js +29 -0
- package/dist/engine/generate.d.ts +51 -0
- package/dist/engine/generate.js +161 -0
- package/dist/engine/index.d.ts +8 -0
- package/dist/engine/index.js +7 -0
- package/dist/engine/types.d.ts +128 -0
- package/dist/engine/types.js +22 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +146 -0
- package/dist/manifest.d.ts +7 -0
- package/dist/manifest.js +88 -0
- package/dist/markdown/deckCompiler.d.ts +36 -0
- package/dist/markdown/deckCompiler.js +289 -0
- package/dist/markdown/index.d.ts +13 -0
- package/dist/markdown/index.js +13 -0
- package/dist/markdown/parsers.d.ts +32 -0
- package/dist/markdown/parsers.js +233 -0
- package/dist/markdown/resolvers/code.d.ts +17 -0
- package/dist/markdown/resolvers/code.js +44 -0
- package/dist/markdown/resolvers/mermaid.d.ts +14 -0
- package/dist/markdown/resolvers/mermaid.js +89 -0
- package/dist/markdown/resolvers/mermaidTheme.d.ts +27 -0
- package/dist/markdown/resolvers/mermaidTheme.js +113 -0
- package/dist/markdown/resolvers/resolver.d.ts +42 -0
- package/dist/markdown/resolvers/resolver.js +52 -0
- package/dist/markdown/slideParser.d.ts +14 -0
- package/dist/markdown/slideParser.js +196 -0
- package/dist/markdown/textTemplate.d.ts +15 -0
- package/dist/markdown/textTemplate.js +75 -0
- package/dist/markdown/types.d.ts +239 -0
- package/dist/markdown/types.js +40 -0
- package/package.json +41 -0
- package/syntax.md +291 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { StyledParagraph, TableFill, TextRun } from "../engine/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Parse inline markdown formatting in a single line of text into TextRun arrays.
|
|
4
|
+
* Handles **bold**, *italic*, ***bold italic***, ~~strikethrough~~, ++underline++,
|
|
5
|
+
* [link](url), and `inline code`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseInlineRuns(text: string): TextRun[];
|
|
8
|
+
/**
|
|
9
|
+
* Parse a raw prose line into a StyledParagraph with bullet detection and inline
|
|
10
|
+
* formatting. The caller provides the raw line including leading whitespace and
|
|
11
|
+
* bullet markers.
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseStyledParagraph(raw: string): StyledParagraph;
|
|
14
|
+
/**
|
|
15
|
+
* Parse a single line of prose into its structural components.
|
|
16
|
+
*
|
|
17
|
+
* Lines beginning with `- ` or `* ` (optionally preceded by spaces) are
|
|
18
|
+
* recognized as bullet items. Every 2 spaces of leading indent on a bullet
|
|
19
|
+
* line increase its level by 1. A leading dash without a trailing space is
|
|
20
|
+
* NOT a bullet (e.g. `-foo`).
|
|
21
|
+
*/
|
|
22
|
+
export declare function parseProseLine(raw: string): {
|
|
23
|
+
text: string;
|
|
24
|
+
bullet: boolean;
|
|
25
|
+
level: number;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Parse a GFM table into TableFill (StyledParagraph[] cells). Returns null
|
|
29
|
+
* for non-table text. Cells carry rich runs so inline formatting works
|
|
30
|
+
* inside tables for free.
|
|
31
|
+
*/
|
|
32
|
+
export declare function parseGfmTable(text: string): TableFill | null;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import remarkGfm from "remark-gfm";
|
|
2
|
+
import remarkIns from "remark-ins";
|
|
3
|
+
import remarkParse from "remark-parse";
|
|
4
|
+
import { unified } from "unified";
|
|
5
|
+
/** mdast node type discriminators we handle. Backing const so switch/if cases
|
|
6
|
+
* reference named tokens rather than raw magic strings. */
|
|
7
|
+
const MdastType = {
|
|
8
|
+
Text: "text",
|
|
9
|
+
InlineCode: "inlineCode",
|
|
10
|
+
Strong: "strong",
|
|
11
|
+
Emphasis: "emphasis",
|
|
12
|
+
Delete: "delete",
|
|
13
|
+
Insert: "insert",
|
|
14
|
+
Link: "link",
|
|
15
|
+
Break: "break",
|
|
16
|
+
Paragraph: "paragraph",
|
|
17
|
+
Heading: "heading",
|
|
18
|
+
};
|
|
19
|
+
/** The subset of MdastType values that are PhrasingContent (dispatched by walkPhrasing). */
|
|
20
|
+
const PHRASING_TYPES = new Set([
|
|
21
|
+
MdastType.Text,
|
|
22
|
+
MdastType.InlineCode,
|
|
23
|
+
MdastType.Strong,
|
|
24
|
+
MdastType.Emphasis,
|
|
25
|
+
MdastType.Delete,
|
|
26
|
+
MdastType.Insert,
|
|
27
|
+
MdastType.Link,
|
|
28
|
+
MdastType.Break,
|
|
29
|
+
]);
|
|
30
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
31
|
+
// INLINE / PROSE PARSING
|
|
32
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
33
|
+
/**
|
|
34
|
+
* Parse inline markdown formatting in a single line of text into TextRun arrays.
|
|
35
|
+
* Handles **bold**, *italic*, ***bold italic***, ~~strikethrough~~, ++underline++,
|
|
36
|
+
* [link](url), and `inline code`.
|
|
37
|
+
*/
|
|
38
|
+
export function parseInlineRuns(text) {
|
|
39
|
+
if (!text)
|
|
40
|
+
return [{ text: "" }];
|
|
41
|
+
// Fast path: no formatting characters means plain text.
|
|
42
|
+
if (!text.includes("*") && !text.includes("[") && !text.includes("`") && !text.includes("~") && !text.includes("+")) {
|
|
43
|
+
return [{ text }];
|
|
44
|
+
}
|
|
45
|
+
// remark-parse establishes the processor as Processor<Root>; remarkGfm and
|
|
46
|
+
// remarkIns extend the parser but don't transform the tree shape, so
|
|
47
|
+
// runSync's output is still a Root. Unified's TailTree generic defaults to
|
|
48
|
+
// undefined and widens runSync's return to the base Node, hence the narrowing
|
|
49
|
+
// cast — kept to a single hop, no double-cast.
|
|
50
|
+
const processor = unified().use(remarkParse).use(remarkGfm).use(remarkIns);
|
|
51
|
+
const tree = processor.runSync(processor.parse(text));
|
|
52
|
+
const runs = walkInlineRoot(tree, {});
|
|
53
|
+
return runs.length > 0 ? runs : [{ text: "" }];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Parse a raw prose line into a StyledParagraph with bullet detection and inline
|
|
57
|
+
* formatting. The caller provides the raw line including leading whitespace and
|
|
58
|
+
* bullet markers.
|
|
59
|
+
*/
|
|
60
|
+
export function parseStyledParagraph(raw) {
|
|
61
|
+
const { indent, bullet, text } = detectBullet(raw);
|
|
62
|
+
if (bullet) {
|
|
63
|
+
return {
|
|
64
|
+
runs: parseInlineRuns(text),
|
|
65
|
+
bullet: { level: Math.floor(indent / 2) },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { runs: parseInlineRuns(text) };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Parse a single line of prose into its structural components.
|
|
72
|
+
*
|
|
73
|
+
* Lines beginning with `- ` or `* ` (optionally preceded by spaces) are
|
|
74
|
+
* recognized as bullet items. Every 2 spaces of leading indent on a bullet
|
|
75
|
+
* line increase its level by 1. A leading dash without a trailing space is
|
|
76
|
+
* NOT a bullet (e.g. `-foo`).
|
|
77
|
+
*/
|
|
78
|
+
export function parseProseLine(raw) {
|
|
79
|
+
const { indent, bullet, text } = detectBullet(raw);
|
|
80
|
+
return { text, bullet, level: bullet ? Math.floor(indent / 2) : 0 };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Splits a raw line into leading spaces, an optional `- `/`* ` marker, and the
|
|
84
|
+
* remaining text in one pass. Group 1 is the run of leading spaces; group 2 is
|
|
85
|
+
* the marker plus its trailing whitespace when present; group 3 is everything
|
|
86
|
+
* after. A marker only matches when whitespace follows it, so `-foo` falls
|
|
87
|
+
* through group 2 and stays plain text.
|
|
88
|
+
*/
|
|
89
|
+
const BULLET_LINE = /^( *)([-*]\s+)?(.*)$/;
|
|
90
|
+
/** Shared bullet-detection primitive used by parseStyledParagraph + parseProseLine. */
|
|
91
|
+
function detectBullet(raw) {
|
|
92
|
+
const [, leading, marker, rest] = raw.match(BULLET_LINE);
|
|
93
|
+
return { indent: leading.length, bullet: marker !== undefined, text: rest };
|
|
94
|
+
}
|
|
95
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
96
|
+
// GFM TABLE PARSING
|
|
97
|
+
// ══════════════════════════════════════════════════════════════════════════════
|
|
98
|
+
const TABLE_SEPARATOR_RE = /^\|?([\s:]*-{3,}[\s:]*\|)+[\s:]*-{3,}[\s:]*\|?$/;
|
|
99
|
+
function parseTableRow(line) {
|
|
100
|
+
const trimmed = line.trim();
|
|
101
|
+
const stripped = trimmed.startsWith("|") ? trimmed.slice(1) : trimmed;
|
|
102
|
+
const chopped = stripped.endsWith("|") ? stripped.slice(0, -1) : stripped;
|
|
103
|
+
return chopped.split("|").map((c) => c.trim());
|
|
104
|
+
}
|
|
105
|
+
function cellToParagraph(text) {
|
|
106
|
+
return { runs: parseInlineRuns(text) };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Parse a GFM table into TableFill (StyledParagraph[] cells). Returns null
|
|
110
|
+
* for non-table text. Cells carry rich runs so inline formatting works
|
|
111
|
+
* inside tables for free.
|
|
112
|
+
*/
|
|
113
|
+
export function parseGfmTable(text) {
|
|
114
|
+
const lines = text.split(/\r?\n/).filter((l) => l.trim());
|
|
115
|
+
if (lines.length < 2)
|
|
116
|
+
return null;
|
|
117
|
+
if (!TABLE_SEPARATOR_RE.test(lines[1]))
|
|
118
|
+
return null;
|
|
119
|
+
const headers = parseTableRow(lines[0]);
|
|
120
|
+
if (headers.length === 0)
|
|
121
|
+
return null;
|
|
122
|
+
const rows = [];
|
|
123
|
+
for (let i = 2; i < lines.length; i++) {
|
|
124
|
+
rows.push(parseTableRow(lines[i]).map(cellToParagraph));
|
|
125
|
+
}
|
|
126
|
+
return { headers: headers.map(cellToParagraph), rows };
|
|
127
|
+
}
|
|
128
|
+
function makeRun(text, state) {
|
|
129
|
+
const run = { text };
|
|
130
|
+
if (state.bold)
|
|
131
|
+
run.bold = true;
|
|
132
|
+
if (state.italic)
|
|
133
|
+
run.italic = true;
|
|
134
|
+
if (state.strikethrough)
|
|
135
|
+
run.strikethrough = true;
|
|
136
|
+
if (state.underline)
|
|
137
|
+
run.underline = true;
|
|
138
|
+
if (state.link)
|
|
139
|
+
run.link = state.link;
|
|
140
|
+
return run;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Walk a `PhrasingContent` node, converting inline formatting into TextRun[].
|
|
144
|
+
*
|
|
145
|
+
* The `node.type` case strings are the mdast library's own discriminators;
|
|
146
|
+
* TypeScript narrows each case to the concrete node interface (Text, Strong,
|
|
147
|
+
* Emphasis, …) so misspellings and dropped cases are compile errors, not
|
|
148
|
+
* silent fallthroughs. `remark-ins` augments PhrasingContentMap with
|
|
149
|
+
* "insert", so that case narrows to the Insert node type.
|
|
150
|
+
*/
|
|
151
|
+
function walkPhrasing(node, state) {
|
|
152
|
+
switch (node.type) {
|
|
153
|
+
case MdastType.Text:
|
|
154
|
+
return [makeRun(node.value, state)];
|
|
155
|
+
case MdastType.InlineCode:
|
|
156
|
+
return [makeRun(node.value, state)];
|
|
157
|
+
case MdastType.Strong:
|
|
158
|
+
return walkPhrasingChildren(node.children, { ...state, bold: true });
|
|
159
|
+
case MdastType.Emphasis:
|
|
160
|
+
return walkPhrasingChildren(node.children, { ...state, italic: true });
|
|
161
|
+
case MdastType.Delete:
|
|
162
|
+
return walkPhrasingChildren(node.children, { ...state, strikethrough: true });
|
|
163
|
+
case MdastType.Insert:
|
|
164
|
+
return walkPhrasingChildren(node.children, { ...state, underline: true });
|
|
165
|
+
case MdastType.Link:
|
|
166
|
+
return walkPhrasingChildren(node.children, { ...state, link: node.url });
|
|
167
|
+
case MdastType.Break:
|
|
168
|
+
// A hard line break inside a single "line" is treated as a space.
|
|
169
|
+
return [makeRun(" ", state)];
|
|
170
|
+
default:
|
|
171
|
+
// Any other phrasing node with a literal `value` (footnote references,
|
|
172
|
+
// etc.) — emit its text if it has one, otherwise nothing.
|
|
173
|
+
if ("value" in node && typeof node.value === "string") {
|
|
174
|
+
return [makeRun(node.value, state)];
|
|
175
|
+
}
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function walkPhrasingChildren(children, state) {
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const child of children)
|
|
182
|
+
out.push(...walkPhrasing(child, state));
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
/** Walk the root's children, entering each block-level container's phrasing. */
|
|
186
|
+
function walkInlineRoot(root, state) {
|
|
187
|
+
const out = [];
|
|
188
|
+
for (const child of root.children)
|
|
189
|
+
out.push(...walkBlock(child, state));
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Enter a block-level node. remarkParse on a single line produces a `Root`
|
|
194
|
+
* whose children include block-level nodes (Paragraph, most commonly), which
|
|
195
|
+
* in turn hold phrasing content. Anything unexpected (Heading, Blockquote,
|
|
196
|
+
* List, …) recurses via its phrasing-holding children when applicable.
|
|
197
|
+
*/
|
|
198
|
+
function walkBlock(node, state) {
|
|
199
|
+
switch (node.type) {
|
|
200
|
+
case MdastType.Paragraph:
|
|
201
|
+
return walkPhrasingChildren(node.children, state);
|
|
202
|
+
case MdastType.Heading:
|
|
203
|
+
return walkPhrasingChildren(node.children, state);
|
|
204
|
+
default:
|
|
205
|
+
// Fall back: some block nodes carry phrasing content among their
|
|
206
|
+
// children. Recurse into typed children when the shape is known;
|
|
207
|
+
// otherwise return nothing rather than guess.
|
|
208
|
+
if ("children" in node && Array.isArray(node.children)) {
|
|
209
|
+
const out = [];
|
|
210
|
+
for (const child of node.children) {
|
|
211
|
+
out.push(...walkUnknown(child, state));
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Handle a child of unknown shape: dispatch to phrasing- or block-level walker
|
|
220
|
+
* based on the node's type discriminator. Preserves discriminated-union
|
|
221
|
+
* narrowing by explicitly re-checking the type against known mdast unions.
|
|
222
|
+
*/
|
|
223
|
+
function walkUnknown(node, state) {
|
|
224
|
+
if (typeof node !== "object" || node === null || !("type" in node))
|
|
225
|
+
return [];
|
|
226
|
+
const typed = node;
|
|
227
|
+
// Phrasing-content types we handle directly:
|
|
228
|
+
if (PHRASING_TYPES.has(typed.type))
|
|
229
|
+
return walkPhrasing(node, state);
|
|
230
|
+
// Otherwise treat as a block-content node; walkBlock will recurse or
|
|
231
|
+
// return nothing.
|
|
232
|
+
return walkBlock(node, state);
|
|
233
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { StyledParagraph } from "../../engine/index.js";
|
|
2
|
+
import { type CodeFence } from "../types.js";
|
|
3
|
+
import type { Resolver } from "./resolver.js";
|
|
4
|
+
/** Discriminator for CodeFence values. Doubles as `CodeResolver.matches`. */
|
|
5
|
+
export declare function isCodeBlock(v: unknown): v is CodeFence;
|
|
6
|
+
/**
|
|
7
|
+
* Run Shiki over a code block, producing StyledParagraph[] with per-token
|
|
8
|
+
* color runs. Blank lines become paragraphs with a single empty run.
|
|
9
|
+
*/
|
|
10
|
+
export declare function highlightCode(code: string, language: string, theme: string): Promise<StyledParagraph[]>;
|
|
11
|
+
/**
|
|
12
|
+
* Resolve one CodeFence into a TextFill by Shiki-highlighting its source.
|
|
13
|
+
* Theme resolution is strict: the fence's slot MUST declare `codeTheme`. There
|
|
14
|
+
* is no theme-wide fallback — a code-capable slot with no theme throws with the
|
|
15
|
+
* offending slot and layout names.
|
|
16
|
+
*/
|
|
17
|
+
export declare const CodeResolver: Resolver<CodeFence>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { CompilerSlotType, FenceType } from "../types.js";
|
|
2
|
+
/** Discriminator for CodeFence values. Doubles as `CodeResolver.matches`. */
|
|
3
|
+
export function isCodeBlock(v) {
|
|
4
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) && v.type === FenceType.Code;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Run Shiki over a code block, producing StyledParagraph[] with per-token
|
|
8
|
+
* color runs. Blank lines become paragraphs with a single empty run.
|
|
9
|
+
*/
|
|
10
|
+
export async function highlightCode(code, language, theme) {
|
|
11
|
+
const { createHighlighter } = await import("shiki");
|
|
12
|
+
const lang = language;
|
|
13
|
+
const thm = theme;
|
|
14
|
+
const highlighter = await createHighlighter({ themes: [thm], langs: [lang] });
|
|
15
|
+
const { tokens } = highlighter.codeToTokens(code, { lang, theme: thm });
|
|
16
|
+
return tokens.map((line) => ({
|
|
17
|
+
runs: line.length === 0
|
|
18
|
+
? [{ text: "" }]
|
|
19
|
+
: line.map((token) => {
|
|
20
|
+
const run = { text: token.content };
|
|
21
|
+
if (token.color)
|
|
22
|
+
run.color = token.color.replace(/^#/, "");
|
|
23
|
+
return run;
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolve one CodeFence into a TextFill by Shiki-highlighting its source.
|
|
29
|
+
* Theme resolution is strict: the fence's slot MUST declare `codeTheme`. There
|
|
30
|
+
* is no theme-wide fallback — a code-capable slot with no theme throws with the
|
|
31
|
+
* offending slot and layout names.
|
|
32
|
+
*/
|
|
33
|
+
export const CodeResolver = {
|
|
34
|
+
matches: isCodeBlock,
|
|
35
|
+
async resolve(fence, ctx) {
|
|
36
|
+
const theme = ctx.slot.type === CompilerSlotType.Code ? ctx.slot.codeTheme : undefined;
|
|
37
|
+
if (!theme) {
|
|
38
|
+
throw new Error(`Slide layout "${ctx.layout.name}" slot "${ctx.key}": no "codeTheme" declared on the slot. ` +
|
|
39
|
+
"Every code-capable slot must declare its own theme (CompilerContentSlot.codeTheme).");
|
|
40
|
+
}
|
|
41
|
+
const paragraphs = await highlightCode(fence.source, fence.language, theme);
|
|
42
|
+
return { paragraphs };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type MermaidFence } from "../types.js";
|
|
2
|
+
import type { Resolver } from "./resolver.js";
|
|
3
|
+
/** Discriminator for MermaidFence values. Doubles as `MermaidResolver.matches`. */
|
|
4
|
+
export declare function isMermaidBlock(v: unknown): v is MermaidFence;
|
|
5
|
+
/**
|
|
6
|
+
* Resolve one MermaidFence into an ImageFill by rendering it to a PNG (cached
|
|
7
|
+
* under `<outputDir>/.tycoslide-cache/mermaid/<hash>.png`). Fit is always `contain` —
|
|
8
|
+
* mermaid diagrams are shown in their entirety.
|
|
9
|
+
*
|
|
10
|
+
* Resolution is strict: the deck's theme MUST carry a `mermaid` block, the
|
|
11
|
+
* fence's slot MUST declare `mermaidVariant`, and that variant MUST exist in
|
|
12
|
+
* the theme — each missing piece throws by name.
|
|
13
|
+
*/
|
|
14
|
+
export declare const MermaidResolver: Resolver<MermaidFence>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { FitMode, SlotType } from "../../engine/index.js";
|
|
7
|
+
import { CompilerSlotType, FenceType } from "../types.js";
|
|
8
|
+
import { buildMermaidRenderConfig, injectClassDefs, validateMermaidDefinition, } from "./mermaidTheme.js";
|
|
9
|
+
/** Discriminator for MermaidFence values. Doubles as `MermaidResolver.matches`. */
|
|
10
|
+
export function isMermaidBlock(v) {
|
|
11
|
+
return (typeof v === "object" && v !== null && !Array.isArray(v) && v.type === FenceType.Mermaid);
|
|
12
|
+
}
|
|
13
|
+
function findMmdc() {
|
|
14
|
+
try {
|
|
15
|
+
const resolved = execFileSync("npx", ["--no-install", "which", "mmdc"], {
|
|
16
|
+
encoding: "utf-8",
|
|
17
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
18
|
+
}).trim();
|
|
19
|
+
if (resolved)
|
|
20
|
+
return resolved;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// fall through
|
|
24
|
+
}
|
|
25
|
+
throw new Error("mermaid-cli is required for mermaid diagrams. Install it: npm i -D @mermaid-js/mermaid-cli");
|
|
26
|
+
}
|
|
27
|
+
function hashKey(definition, variantName) {
|
|
28
|
+
return createHash("sha256").update(variantName).update("\n").update(definition).digest("hex").slice(0, 16);
|
|
29
|
+
}
|
|
30
|
+
function ensureCacheDir(config) {
|
|
31
|
+
const base = resolve(config.outputDir ?? process.cwd(), ".tycoslide-cache", "mermaid");
|
|
32
|
+
mkdirSync(base, { recursive: true });
|
|
33
|
+
return base;
|
|
34
|
+
}
|
|
35
|
+
function renderOne(definition, variantName, variant, cacheDir, mmdcPath) {
|
|
36
|
+
const validated = validateMermaidDefinition(definition);
|
|
37
|
+
const processed = injectClassDefs(validated, variant.accents, variant.accentOpacity, variant.accentTextColor, variant.surface, variant.groupCornerRadius);
|
|
38
|
+
const key = hashKey(processed, variantName);
|
|
39
|
+
const outputPath = join(cacheDir, `${key}.png`);
|
|
40
|
+
if (existsSync(outputPath))
|
|
41
|
+
return outputPath;
|
|
42
|
+
const config = buildMermaidRenderConfig(variant);
|
|
43
|
+
const scratch = tmpdir();
|
|
44
|
+
const configPath = join(scratch, `tycoslide-mermaid-config-${key}.json`);
|
|
45
|
+
const inputPath = join(scratch, `tycoslide-mermaid-${key}.mmd`);
|
|
46
|
+
writeFileSync(configPath, JSON.stringify(config));
|
|
47
|
+
writeFileSync(inputPath, processed);
|
|
48
|
+
try {
|
|
49
|
+
execFileSync(mmdcPath, ["-i", inputPath, "-o", outputPath, "--configFile", configPath, "-b", "transparent", "-s", "2"], { stdio: ["pipe", "pipe", "pipe"], timeout: 30_000 });
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
const stderr = e.stderr?.toString() ?? e.message;
|
|
53
|
+
throw new Error(`Mermaid render failed:\n${stderr}`);
|
|
54
|
+
}
|
|
55
|
+
return outputPath;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Resolve one MermaidFence into an ImageFill by rendering it to a PNG (cached
|
|
59
|
+
* under `<outputDir>/.tycoslide-cache/mermaid/<hash>.png`). Fit is always `contain` —
|
|
60
|
+
* mermaid diagrams are shown in their entirety.
|
|
61
|
+
*
|
|
62
|
+
* Resolution is strict: the deck's theme MUST carry a `mermaid` block, the
|
|
63
|
+
* fence's slot MUST declare `mermaidVariant`, and that variant MUST exist in
|
|
64
|
+
* the theme — each missing piece throws by name.
|
|
65
|
+
*/
|
|
66
|
+
export const MermaidResolver = {
|
|
67
|
+
matches: isMermaidBlock,
|
|
68
|
+
async resolve(fence, ctx) {
|
|
69
|
+
const { config } = ctx;
|
|
70
|
+
if (!config.mermaid) {
|
|
71
|
+
throw new Error('Deck contains mermaid diagrams, but theme has no "mermaid" block. ' +
|
|
72
|
+
"Add mermaid color configuration to theme.json.");
|
|
73
|
+
}
|
|
74
|
+
const variantName = ctx.slot.type === CompilerSlotType.Mermaid ? ctx.slot.mermaidVariant : undefined;
|
|
75
|
+
if (variantName === undefined) {
|
|
76
|
+
throw new Error(`Layout "${ctx.layout.name}": mermaid slot "${ctx.key}" has no "mermaidVariant" declared. ` +
|
|
77
|
+
"Every slot with type=mermaid must declare its variant explicitly.");
|
|
78
|
+
}
|
|
79
|
+
const variant = config.mermaid[variantName];
|
|
80
|
+
if (!variant) {
|
|
81
|
+
throw new Error(`Slide layout "${ctx.layout.name}": mermaid variant "${variantName}" not found in theme. ` +
|
|
82
|
+
`Available variants: ${Object.keys(config.mermaid).join(", ")}`);
|
|
83
|
+
}
|
|
84
|
+
const mmdcPath = findMmdc();
|
|
85
|
+
const cacheDir = ensureCacheDir(config);
|
|
86
|
+
const pngPath = renderOne(fence.definition, variantName, variant, cacheDir, mmdcPath);
|
|
87
|
+
return { type: SlotType.Image, path: pngPath, fit: FitMode.Contain };
|
|
88
|
+
},
|
|
89
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mermaid theme types and definition-processing utilities.
|
|
3
|
+
*
|
|
4
|
+
* Owner of MermaidVariant / MermaidConfig — these types are compiler-facing
|
|
5
|
+
* (the engine has no idea mermaid exists). `resolvers/mermaid.ts` consumes
|
|
6
|
+
* them to build --configFile input for `mmdc`.
|
|
7
|
+
*/
|
|
8
|
+
export type MermaidVariant = {
|
|
9
|
+
primary: string;
|
|
10
|
+
primaryContrast: string;
|
|
11
|
+
text: string;
|
|
12
|
+
line: string;
|
|
13
|
+
surface: string;
|
|
14
|
+
surfaceBorder: string;
|
|
15
|
+
fontFamily: string;
|
|
16
|
+
accents: string[];
|
|
17
|
+
accentOpacity: number;
|
|
18
|
+
accentTextColor: string;
|
|
19
|
+
groupCornerRadius: number;
|
|
20
|
+
};
|
|
21
|
+
export type MermaidConfig = Record<string, MermaidVariant>;
|
|
22
|
+
export declare function validateMermaidDefinition(definition: string): string;
|
|
23
|
+
export declare function extractGroups(definition: string): string[];
|
|
24
|
+
export declare function buildClassDefs(groups: string[], accents: string[], accentOpacity: number, accentTextColor: string): string;
|
|
25
|
+
export declare function buildSubgraphStyles(definition: string, groupColor: string, accentOpacity: number, groupCornerRadius: number): string;
|
|
26
|
+
export declare function buildMermaidRenderConfig(variant: MermaidVariant): object;
|
|
27
|
+
export declare function injectClassDefs(definition: string, accents: string[], accentOpacity: number, accentTextColor: string, groupColor: string, groupCornerRadius: number): string;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mermaid theme types and definition-processing utilities.
|
|
3
|
+
*
|
|
4
|
+
* Owner of MermaidVariant / MermaidConfig — these types are compiler-facing
|
|
5
|
+
* (the engine has no idea mermaid exists). `resolvers/mermaid.ts` consumes
|
|
6
|
+
* them to build --configFile input for `mmdc`.
|
|
7
|
+
*/
|
|
8
|
+
const FORBIDDEN_PATTERNS = [/^\s*style\s+\S+\s+/, /^\s*linkStyle\s+/, /^\s*classDef\s+/, /^\s*%%\{init/];
|
|
9
|
+
export function validateMermaidDefinition(definition) {
|
|
10
|
+
const forbidden = [];
|
|
11
|
+
for (const line of definition.split("\n")) {
|
|
12
|
+
if (FORBIDDEN_PATTERNS.some((p) => p.test(line))) {
|
|
13
|
+
forbidden.push(line.trim());
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (forbidden.length > 0) {
|
|
17
|
+
throw new Error(`Mermaid: found ${forbidden.length} forbidden style directive(s). ` +
|
|
18
|
+
`Use theme classes instead (e.g. "class NodeId backend"):\n` +
|
|
19
|
+
forbidden.map((s) => ` - ${s}`).join("\n"));
|
|
20
|
+
}
|
|
21
|
+
return definition;
|
|
22
|
+
}
|
|
23
|
+
export function extractGroups(definition) {
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
const groups = [];
|
|
26
|
+
const classPattern = /^\s*class\s+[\w,]+\s+(\w+)/gm;
|
|
27
|
+
let match = null;
|
|
28
|
+
while ((match = classPattern.exec(definition)) !== null) {
|
|
29
|
+
const name = match[1];
|
|
30
|
+
if (!seen.has(name)) {
|
|
31
|
+
seen.add(name);
|
|
32
|
+
groups.push(name);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const inlinePattern = /:::(\w+)/g;
|
|
36
|
+
while ((match = inlinePattern.exec(definition)) !== null) {
|
|
37
|
+
const name = match[1];
|
|
38
|
+
if (!seen.has(name)) {
|
|
39
|
+
seen.add(name);
|
|
40
|
+
groups.push(name);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return groups;
|
|
44
|
+
}
|
|
45
|
+
export function buildClassDefs(groups, accents, accentOpacity, accentTextColor) {
|
|
46
|
+
if (groups.length === 0 || accents.length === 0)
|
|
47
|
+
return "";
|
|
48
|
+
const alpha = Math.round((accentOpacity / 100) * 255)
|
|
49
|
+
.toString(16)
|
|
50
|
+
.padStart(2, "0");
|
|
51
|
+
return groups
|
|
52
|
+
.map((name, i) => {
|
|
53
|
+
const color = accents[i % accents.length];
|
|
54
|
+
return `classDef ${name} fill:${color}${alpha},stroke:${color},color:${accentTextColor}`;
|
|
55
|
+
})
|
|
56
|
+
.join("\n");
|
|
57
|
+
}
|
|
58
|
+
export function buildSubgraphStyles(definition, groupColor, accentOpacity, groupCornerRadius) {
|
|
59
|
+
const alpha = Math.round((accentOpacity / 100) * 255)
|
|
60
|
+
.toString(16)
|
|
61
|
+
.padStart(2, "0");
|
|
62
|
+
const fillColor = `${groupColor}${alpha}`;
|
|
63
|
+
const subgraphPattern = /subgraph\s+(\w+)/g;
|
|
64
|
+
const ids = [];
|
|
65
|
+
let match = null;
|
|
66
|
+
while ((match = subgraphPattern.exec(definition)) !== null) {
|
|
67
|
+
ids.push(match[1]);
|
|
68
|
+
}
|
|
69
|
+
if (ids.length === 0)
|
|
70
|
+
return "";
|
|
71
|
+
const radiusPx = Math.round(groupCornerRadius);
|
|
72
|
+
const radiusPart = radiusPx > 0 ? `,rx:${radiusPx},ry:${radiusPx}` : "";
|
|
73
|
+
return ids.map((id) => `style ${id} fill:${fillColor}${radiusPart}`).join("\n");
|
|
74
|
+
}
|
|
75
|
+
export function buildMermaidRenderConfig(variant) {
|
|
76
|
+
return {
|
|
77
|
+
startOnLoad: false,
|
|
78
|
+
securityLevel: "loose",
|
|
79
|
+
theme: "base",
|
|
80
|
+
themeVariables: {
|
|
81
|
+
fontFamily: variant.fontFamily,
|
|
82
|
+
background: "transparent",
|
|
83
|
+
primaryColor: variant.primary,
|
|
84
|
+
primaryTextColor: variant.primaryContrast,
|
|
85
|
+
primaryBorderColor: variant.surfaceBorder,
|
|
86
|
+
lineColor: variant.line,
|
|
87
|
+
secondaryColor: variant.surface,
|
|
88
|
+
tertiaryColor: variant.surface,
|
|
89
|
+
textColor: variant.text,
|
|
90
|
+
titleColor: variant.text,
|
|
91
|
+
nodeTextColor: variant.text,
|
|
92
|
+
clusterBkg: variant.surface,
|
|
93
|
+
clusterBorder: variant.surfaceBorder,
|
|
94
|
+
edgeLabelBackground: variant.surface,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export function injectClassDefs(definition, accents, accentOpacity, accentTextColor, groupColor, groupCornerRadius) {
|
|
99
|
+
const flowchartPattern = /^(\s*(?:flowchart|graph)\s+\w*\s*\n)/m;
|
|
100
|
+
const match = definition.match(flowchartPattern);
|
|
101
|
+
if (!match) {
|
|
102
|
+
return definition;
|
|
103
|
+
}
|
|
104
|
+
const groups = extractGroups(definition);
|
|
105
|
+
const classDefs = buildClassDefs(groups, accents, accentOpacity, accentTextColor);
|
|
106
|
+
const subgraphStyles = buildSubgraphStyles(definition, groupColor, accentOpacity, groupCornerRadius);
|
|
107
|
+
const [fullMatch] = match;
|
|
108
|
+
let result = classDefs ? definition.replace(fullMatch, `${fullMatch}${classDefs}\n`) : definition;
|
|
109
|
+
if (subgraphStyles) {
|
|
110
|
+
result = `${result.trimEnd()}\n${subgraphStyles}`;
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ImageFill, TextFill } from "../../engine/index.js";
|
|
2
|
+
import { type CodeFence, type CompilerConfig, type CompilerDeck, type CompilerLayout, type CompilerSlot, FenceType, type MarkdownBlock, type MermaidFence } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Everything a resolver needs to turn one fence into a fill: the step's layout
|
|
5
|
+
* (its name feeds diagnostics), the slot the fence fills (carries the per-slot
|
|
6
|
+
* config a resolver reads — `codeTheme` / `mermaidVariant`), the slot key (error
|
|
7
|
+
* messages), and the theme-level config (mermaid variants, output dir).
|
|
8
|
+
*/
|
|
9
|
+
export type ResolveContext = {
|
|
10
|
+
layout: CompilerLayout;
|
|
11
|
+
slot: CompilerSlot;
|
|
12
|
+
key: string;
|
|
13
|
+
config: CompilerConfig;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* A compiler-side strategy that turns ONE unresolved fence into the engine fill
|
|
17
|
+
* it becomes. Mirrors the engine's `Filler` in shape only — it shares no code,
|
|
18
|
+
* interface, or registry with the engine layer. A resolver owns solely its
|
|
19
|
+
* transform: it reads only its own slot field and never walks the deck.
|
|
20
|
+
*/
|
|
21
|
+
export interface Resolver<F extends CodeFence | MermaidFence> {
|
|
22
|
+
matches(v: MarkdownBlock): v is F;
|
|
23
|
+
/** Transform one fence into the engine fill it resolves to. */
|
|
24
|
+
resolve(fence: F, ctx: ResolveContext): Promise<TextFill | ImageFill>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Registry keyed by the fence discriminator (`FenceType`). A missing entry for
|
|
28
|
+
* a fence's `type` throws at lookup — every fence kind must register a resolver.
|
|
29
|
+
* Mirrors the engine's `FILLERS`; `any` widens the value type exactly as
|
|
30
|
+
* `Filler<any>` does, since each entry already narrows its own fence.
|
|
31
|
+
*/
|
|
32
|
+
export declare const RESOLVERS: Record<FenceType, Resolver<any>>;
|
|
33
|
+
/** A content value is a fence when it carries a `type` that names a FenceType. */
|
|
34
|
+
export declare function isFence(v: MarkdownBlock): v is CodeFence | MermaidFence;
|
|
35
|
+
/**
|
|
36
|
+
* The single walk that replaces the two bespoke fence walkers. For each step,
|
|
37
|
+
* resolve every fence in `step.content` in place: dispatch on the fence's `type`
|
|
38
|
+
* to its resolver, hand it the fence plus its slot config, and swap the resolved
|
|
39
|
+
* fill back into `step.content[key]`. Traversal lives here; each resolver owns
|
|
40
|
+
* only its transform.
|
|
41
|
+
*/
|
|
42
|
+
export declare function resolveFences(deck: CompilerDeck, config: CompilerConfig): Promise<void>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { FenceType, } from "../types.js";
|
|
2
|
+
import { CodeResolver } from "./code.js";
|
|
3
|
+
import { MermaidResolver } from "./mermaid.js";
|
|
4
|
+
/**
|
|
5
|
+
* Registry keyed by the fence discriminator (`FenceType`). A missing entry for
|
|
6
|
+
* a fence's `type` throws at lookup — every fence kind must register a resolver.
|
|
7
|
+
* Mirrors the engine's `FILLERS`; `any` widens the value type exactly as
|
|
8
|
+
* `Filler<any>` does, since each entry already narrows its own fence.
|
|
9
|
+
*/
|
|
10
|
+
export const RESOLVERS = {
|
|
11
|
+
[FenceType.Code]: CodeResolver,
|
|
12
|
+
[FenceType.Mermaid]: MermaidResolver,
|
|
13
|
+
};
|
|
14
|
+
/** A content value is a fence when it carries a `type` that names a FenceType. */
|
|
15
|
+
export function isFence(v) {
|
|
16
|
+
return "type" in v && Object.values(FenceType).includes(v.type);
|
|
17
|
+
}
|
|
18
|
+
/** Find a layout by name; throws if absent (deckCompiler guarantees presence). */
|
|
19
|
+
function layoutByName(config, name) {
|
|
20
|
+
const layout = config.layouts.find((l) => l.name === name);
|
|
21
|
+
if (!layout)
|
|
22
|
+
throw new Error(`resolveFences: unknown layout "${name}".`);
|
|
23
|
+
return layout;
|
|
24
|
+
}
|
|
25
|
+
/** Find a slot by key within a layout; throws if absent (a fence always has one). */
|
|
26
|
+
function slotByKey(layout, key) {
|
|
27
|
+
const slot = layout.slots.find((s) => s.key === key);
|
|
28
|
+
if (!slot)
|
|
29
|
+
throw new Error(`resolveFences: layout "${layout.name}" has no slot "${key}".`);
|
|
30
|
+
return slot;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The single walk that replaces the two bespoke fence walkers. For each step,
|
|
34
|
+
* resolve every fence in `step.content` in place: dispatch on the fence's `type`
|
|
35
|
+
* to its resolver, hand it the fence plus its slot config, and swap the resolved
|
|
36
|
+
* fill back into `step.content[key]`. Traversal lives here; each resolver owns
|
|
37
|
+
* only its transform.
|
|
38
|
+
*/
|
|
39
|
+
export async function resolveFences(deck, config) {
|
|
40
|
+
for (const step of deck.steps) {
|
|
41
|
+
if (!step.content)
|
|
42
|
+
continue;
|
|
43
|
+
const layout = layoutByName(config, step.layout);
|
|
44
|
+
for (const [key, value] of Object.entries(step.content)) {
|
|
45
|
+
if (!isFence(value))
|
|
46
|
+
continue;
|
|
47
|
+
const resolver = RESOLVERS[value.type];
|
|
48
|
+
const slot = slotByKey(layout, key);
|
|
49
|
+
step.content[key] = await resolver.resolve(value, { layout, slot, key, config });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|