@bytechain.cn/colamd-themes 0.3.2 → 0.5.1

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 (99) hide show
  1. package/.claude/skills/colamd-themes-preview.skill.md +333 -0
  2. package/.claude/skills/colamd-themes.skill.md +10 -0
  3. package/.dsh/skills/colamd-themes/SKILL.md +14 -0
  4. package/.github/supported-topics.yml +17 -0
  5. package/.hermes/skills/tools/colamd-themes/SKILL.md +14 -0
  6. package/.openclaw/skills/colamd-themes/SKILL.md +1 -37
  7. package/.opencode/skills/colamd-themes/SKILL.md +17 -0
  8. package/.trae/rules/colamd-themes.md +0 -36
  9. package/CHANGELOG.md +331 -241
  10. package/DSH_SUPPORT_SUMMARY.md +87 -0
  11. package/README.md +414 -384
  12. package/README_CN.md +412 -384
  13. package/RELEASENOTE.md +541 -389
  14. package/dist/cli.js +36 -55
  15. package/dist/cli.js.map +1 -1
  16. package/dist/color-utils.d.ts +1 -0
  17. package/dist/color-utils.js +25 -6
  18. package/dist/color-utils.js.map +1 -1
  19. package/dist/extractors/pdf-extractor.js +6 -1
  20. package/dist/extractors/pdf-extractor.js.map +1 -1
  21. package/dist/extractors/url-extractor.d.ts +0 -4
  22. package/dist/extractors/url-extractor.js +13 -126
  23. package/dist/extractors/url-extractor.js.map +1 -1
  24. package/dist/generator.d.ts +1 -1
  25. package/dist/generator.js +32 -11
  26. package/dist/generator.js.map +1 -1
  27. package/dist/models.d.ts +5 -4
  28. package/dist/models.js +2 -3
  29. package/dist/models.js.map +1 -1
  30. package/dist/renderer.d.ts +6 -3
  31. package/dist/renderer.js +54 -93
  32. package/dist/renderer.js.map +1 -1
  33. package/dist/templates/template.css +220 -498
  34. package/dist/templates/theme-paradigm.md +530 -658
  35. package/dist/templates/theme-template.hbs +256 -370
  36. package/dist/theme-store.js +19 -3
  37. package/dist/theme-store.js.map +1 -1
  38. package/dist/utils/document-paradigm.d.ts +12 -0
  39. package/dist/utils/document-paradigm.js +109 -0
  40. package/dist/utils/document-paradigm.js.map +1 -0
  41. package/dist/validators.d.ts +4 -1
  42. package/dist/validators.js +115 -1
  43. package/dist/validators.js.map +1 -1
  44. package/mcp-server/dist/index.d.ts +13 -0
  45. package/mcp-server/dist/index.d.ts.map +1 -0
  46. package/mcp-server/dist/index.js +0 -0
  47. package/mcp-server/dist/index.js.map +1 -0
  48. package/mcp-server/package-lock.json +1759 -0
  49. package/mcp-server/package.json +24 -0
  50. package/mcp-server/src/index.ts +0 -0
  51. package/mcp-server/tsconfig.json +19 -0
  52. package/package.json +54 -36
  53. package/skills/build-skills.sh +30 -5
  54. package/skills/colamd-themes/SKILL.md +180 -178
  55. package/src/cli.ts +474 -487
  56. package/src/color-systems.ts +289 -289
  57. package/src/color-utils.ts +137 -120
  58. package/src/contrast.ts +66 -66
  59. package/src/docx-style-paradigm.ts +811 -811
  60. package/src/export-cli.ts +356 -356
  61. package/src/extractors/base.ts +8 -8
  62. package/src/extractors/docx-extractor.ts +417 -417
  63. package/src/extractors/pdf-extractor.ts +533 -527
  64. package/src/extractors/url-extractor.ts +846 -981
  65. package/src/generator.ts +197 -169
  66. package/src/index.ts +29 -29
  67. package/src/models.ts +212 -212
  68. package/src/renderer.ts +310 -359
  69. package/src/templates/template.css +220 -498
  70. package/src/templates/theme-paradigm.md +530 -658
  71. package/src/templates/theme-template.hbs +256 -370
  72. package/src/theme-store.ts +185 -170
  73. package/src/types/pdf-parse.d.ts +12 -12
  74. package/src/utils/cli-utils.ts +84 -84
  75. package/src/utils/document-paradigm.ts +118 -0
  76. package/src/validators.ts +298 -176
  77. package/themes/academic-paper-pt.css +353 -1805
  78. package/themes/academic-paper.css +350 -1099
  79. package/themes/academic-style.css +342 -2430
  80. package/themes/colly.css +256 -370
  81. package/themes/elegant.css +288 -671
  82. package/themes/forest-ink.css +240 -664
  83. package/themes/guizang.css +250 -732
  84. package/themes/hermes-security.css +279 -0
  85. package/themes/meeting-minutes.css +256 -370
  86. package/themes/pixso-design.css +278 -1261
  87. package/themes/salvator-blog.css +256 -370
  88. package/themes/shimo-theme.css +256 -0
  89. package/themes/swiss-design.css +285 -746
  90. package/themes/template.css +220 -498
  91. package/themes/trae-blue.css +280 -737
  92. package/themes/word-contract.css +249 -0
  93. package/themes-preview/demo-content.md +187 -187
  94. package/tsconfig.json +18 -18
  95. package/src/mermaid-presets.ts +0 -142
  96. package/themes/swiss-design copy.css +0 -746
  97. package/themes-preview/colly-preview.html +0 -623
  98. package/themes-preview/meeting-minutes-preview.html +0 -625
  99. package/themes-preview/salvator-blog-preview.html +0 -623
package/src/renderer.ts CHANGED
@@ -1,359 +1,310 @@
1
- /**
2
- * Puppeteer-based rendering engine for ColaMD HTML/PDF export.
3
- *
4
- * Uses @bytechain.cn/colamd/renderer to create a headless Milkdown editor,
5
- * apply themes, render mermaid/math, and export standalone HTML or PDF.
6
- *
7
- * Architecture:
8
- * Local HTTP server (serves renderer.js + page HTML)
9
- * → Puppeteer page (same-origin, no CORS)
10
- * → createColaMDEditor()
11
- * → applyTheme() + setMarkdown() + ensureAllPluginsRendered()
12
- * → buildExportHTML() / page.pdf()
13
- */
14
-
15
- import puppeteer, { type Browser, type Page } from "puppeteer";
16
- import { createServer, type Server } from "node:http";
17
- import { readFileSync, existsSync, createReadStream } from "node:fs";
18
- import { resolve, dirname, join } from "node:path";
19
-
20
- // ── Types ──
21
-
22
- /** Input for rendering a markdown document. */
23
- export interface RenderInput {
24
- /** Markdown content string */
25
- markdown: string;
26
- /** Theme name: built-in (light/dark/elegant/newsprint) or "custom:<name>" */
27
- themeName?: string;
28
- /** Custom CSS content (used with "custom:<name>" themes) */
29
- customCSS?: string;
30
- /** Document title for the HTML <title> */
31
- title?: string;
32
- }
33
-
34
- /** Result of a render operation. */
35
- export interface RenderResult {
36
- /** Complete standalone HTML document */
37
- html: string;
38
- }
39
-
40
- /** PDF export options. */
41
- export interface PDFExportOptions {
42
- /** Page format */
43
- format?: "A4" | "Letter" | "A3";
44
- /** Print background colors/images */
45
- printBackground?: boolean;
46
- /** Page margins */
47
- margin?: { top?: string; right?: string; bottom?: string; left?: string };
48
- }
49
-
50
- // ── File resolution ──
51
-
52
- /** Locate the ColaMD renderer ESM bundle in node_modules. */
53
- function findRendererPath(): string {
54
- const candidates = [
55
- resolve(dirname(new URL(import.meta.url).pathname), "../node_modules/@bytechain.cn/colamd/dist/lib/colamd-renderer.js"),
56
- resolve(process.cwd(), "node_modules/@bytechain.cn/colamd/dist/lib/colamd-renderer.js"),
57
- ];
58
- for (const p of candidates) {
59
- if (existsSync(p)) return p;
60
- }
61
- throw new Error(
62
- "Cannot find @bytechain.cn/colamd renderer. " +
63
- "Install it: npm install @bytechain.cn/colamd"
64
- );
65
- }
66
-
67
- // ── Local HTTP server ──
68
-
69
- const SHELL_HTML = `<!DOCTYPE html>
70
- <html><head>
71
- <meta charset="utf-8">
72
- <link rel="stylesheet" href="/colamd.css">
73
- <style>body{margin:0;padding:0}#editor{width:100%;min-height:100vh}</style>
74
- </head><body><div id="editor"></div></body></html>`;
75
-
76
- interface LocalServer {
77
- port: number;
78
- close(): Promise<void>;
79
- }
80
-
81
- function startLocalServer(rendererPath: string): Promise<LocalServer> {
82
- const cssPath = join(dirname(rendererPath), "colamd.css");
83
- return new Promise((resolveStart, reject) => {
84
- const server: Server = createServer((req, res) => {
85
- if (req.url === "/" || req.url === "/index.html") {
86
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
87
- res.end(SHELL_HTML);
88
- } else if (req.url === "/colamd-renderer.js") {
89
- res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
90
- createReadStream(rendererPath).pipe(res);
91
- } else if (req.url === "/colamd.css") {
92
- res.writeHead(200, { "Content-Type": "text/css; charset=utf-8" });
93
- createReadStream(cssPath).pipe(res);
94
- } else {
95
- res.writeHead(404);
96
- res.end();
97
- }
98
- });
99
-
100
- server.listen(0, "127.0.0.1", () => {
101
- const addr = server.address();
102
- const port = typeof addr === "object" && addr ? addr.port : 0;
103
- resolveStart({
104
- port,
105
- close: () =>
106
- new Promise((resolveClose) => server.close(() => resolveClose())),
107
- });
108
- });
109
-
110
- server.on("error", reject);
111
- });
112
- }
113
-
114
- // ── Export CSS variable supplementation ──
115
-
116
- /**
117
- * CSS variables that colamd-renderer.js's buildExportHTML() may not include.
118
- * Injected as a post-processing step so we don't need to patch node_modules.
119
- */
120
- const EXTRA_CSS_VARIABLES: Record<string, string> = {
121
- "--seed-accent": "var(--accent-color, #333)", // hardcoded — not in colamd.css
122
- "--seed-accent-light": "var(--accent-light, #e0e0e0)", // hardcoded — not in colamd.css
123
- "--seed-accent-dark": "var(--accent-dark, #111)", // hardcoded — not in colamd.css
124
- "--seed-surface": "var(--bg-color, #fff)", // chains to colamd.css --bg-color
125
- "--seed-panel": "var(--surface-color, #f5f5f5)", // hardcoded — not in colamd.css
126
- "--seed-panel-alt": "var(--surface-alt, #eee)", // hardcoded — not in colamd.css
127
- "--seed-ink": "var(--text-color, #333)", // chains to colamd.css --text-color
128
- "--seed-ink-muted": "var(--text-muted, #888)", // chains to colamd.css --text-muted
129
- "--seed-ink-dim": "var(--text-dim, #aaa)", // hardcoded — not in colamd.css
130
- "--seed-border": "var(--border-color, #ddd)", // chains to colamd.css --border-color
131
- "--seed-border-strong": "var(--table-border, #ccc)", // hardcoded — not in colamd.css
132
- "--heading-color": "var(--text-color, #333)", // chains to colamd.css --text-color
133
- "--code-color": "var(--code-block-text, #333)", // chains to colamd.css --code-block-text
134
- "--hr-color": "var(--border-color, #ddd)", // chains to colamd.css --border-color
135
- "--table-header-text": "var(--text-color, #333)", // chains to colamd.css --text-color
136
- "--table-border": "var(--border-color, #ddd)", // chains to colamd.css --border-color
137
- "--scrollbar-thumb": "var(--border-color, #ccc)", // chains to colamd.css --border-color
138
- "--scrollbar-thumb-hover": "var(--text-muted, #999)", // chains to colamd.css --text-muted
139
- "--content-width": "780px", // hardcoded — no colamd.css equivalent
140
- "--line-height-body": "1.75", // hardcoded — no colamd.css equivalent
141
- "--line-height-heading": "1.3", // hardcoded — no colamd.css equivalent
142
- "--line-height-code": "1.6", // hardcoded — no colamd.css equivalent
143
- };
144
-
145
- /**
146
- * Supplement a buildExportHTML() result with extra CSS variables
147
- * that colamd-renderer.js may not include. Only adds variables
148
- * that are not already present in the HTML's <style> block.
149
- */
150
- function supplementExportHTML(html: string): string {
151
- // Find the existing CSS variables (--name: value; or --name:value;)
152
- const existing = new Set<string>();
153
- const varRe = /(--[\w-]+)\s*:/g;
154
- let vm: RegExpExecArray | null;
155
- while ((vm = varRe.exec(html)) !== null) {
156
- existing.add(vm[1]);
157
- }
158
-
159
- // Build style block for only missing variables
160
- const missingLines: string[] = [];
161
- for (const [name, fallback] of Object.entries(EXTRA_CSS_VARIABLES)) {
162
- if (!existing.has(name)) {
163
- missingLines.push(` ${name}: ${fallback};`);
164
- }
165
- }
166
-
167
- if (missingLines.length === 0) return html;
168
-
169
- // Inject into :root block, or before first style rule
170
- const supplement = `\n /* colamd-themes supplement */\n${missingLines.join("\n")}\n`;
171
- return html.replace(
172
- /(<style[^>]*>)/i,
173
- (match, styleTag) => `${styleTag}:root {\n${supplement}}`
174
- );
175
- }
176
-
177
- // ── Renderer ──
178
-
179
- export class ColamdRenderer {
180
- private browser: Browser | null = null;
181
- private page: Page | null = null;
182
- private server: LocalServer | null = null;
183
- private ready = false;
184
-
185
- /** Launch Puppeteer and prepare the editor page. */
186
- async init(): Promise<void> {
187
- if (this.browser) return;
188
-
189
- // Start local HTTP server for the renderer bundle
190
- const rendererPath = findRendererPath();
191
- this.server = await startLocalServer(rendererPath);
192
- const origin = `http://127.0.0.1:${this.server.port}`;
193
-
194
- // Launch Puppeteer
195
- this.browser = await puppeteer.launch({
196
- headless: true,
197
- args: ["--no-sandbox", "--disable-setuid-sandbox"],
198
- });
199
- this.page = await this.browser.newPage();
200
-
201
- // Navigate to shell page (same origin as renderer script)
202
- await this.page.goto(`${origin}/`, { waitUntil: "domcontentloaded" });
203
-
204
- // Load the ColaMD renderer as ESM (same-origin, no CORS)
205
- await this.page.addScriptTag({
206
- type: "module",
207
- content: `
208
- import { createColaMDEditor } from '${origin}/colamd-renderer.js';
209
- window.__colamdReady = createColaMDEditor({
210
- rootId: 'editor',
211
- editable: false
212
- }).then(handle => {
213
- window.__colamdHandle = handle;
214
- return true;
215
- }).catch(err => {
216
- window.__colamdError = err.message;
217
- return false;
218
- });
219
- `,
220
- });
221
-
222
- // Wait for the editor to initialize
223
- await this.page.waitForFunction(
224
- () => (window as any).__colamdHandle || (window as any).__colamdError,
225
- { timeout: 30000 }
226
- );
227
-
228
- const error = await this.page.evaluate(() => (window as any).__colamdError);
229
- if (error) throw new Error(`Editor init failed: ${error}`);
230
-
231
- this.ready = true;
232
- }
233
-
234
- /**
235
- * Render markdown to a standalone HTML document.
236
- */
237
- async render(input: RenderInput): Promise<RenderResult> {
238
- if (!this.ready || !this.page) await this.init();
239
- const page = this.page!;
240
-
241
- // Apply theme
242
- if (input.themeName) {
243
- await page.evaluate(
244
- (name: string, css: string | undefined) => {
245
- (window as any).__colamdHandle.applyTheme(name, css || undefined);
246
- },
247
- input.themeName,
248
- input.customCSS
249
- );
250
- }
251
-
252
- // Set markdown content
253
- await page.evaluate((md: string) => {
254
- (window as any).__colamdHandle.setMarkdown(md);
255
- }, input.markdown);
256
-
257
- // Wait for all plugins (mermaid, math) to finish rendering
258
- await page.evaluate(async () => {
259
- await (window as any).__colamdHandle.ensureAllPluginsRendered();
260
- });
261
-
262
- // Additional settle time for complex diagrams
263
- await new Promise((r) => setTimeout(r, 500));
264
-
265
- // Build export HTML
266
- const html = await page.evaluate(() => {
267
- return (window as any).__colamdHandle.buildExportHTML();
268
- });
269
-
270
- // Post-process: supplement any CSS variables that colamd-renderer.js may not include
271
- const supplementedHtml = supplementExportHTML(html);
272
-
273
- // Inject custom title if provided
274
- const finalHtml = input.title
275
- ? supplementedHtml.replace(/<title>[^<]*<\/title>/, `<title>${input.title}</title>`)
276
- : supplementedHtml;
277
-
278
- return { html: finalHtml };
279
- }
280
-
281
- /**
282
- * Render markdown and export as PDF.
283
- */
284
- async exportPDF(input: RenderInput, options: PDFExportOptions = {}): Promise<Buffer> {
285
- if (!this.ready || !this.page) await this.init();
286
-
287
- // First render to HTML
288
- const { html } = await this.render(input);
289
-
290
- // Load the HTML in a fresh page for PDF generation
291
- const pdfPage = await this.browser!.newPage();
292
- await pdfPage.setContent(html, { waitUntil: "load", timeout: 30000 });
293
-
294
- // NOTE: Do NOT use emulateMediaType("screen") here.
295
- // The CSS template includes comprehensive @media print rules (SECTION 6)
296
- // with !important declarations and print-color-adjust: exact for proper styling.
297
- // Using screen mode would prevent these print-specific styles from applying.
298
-
299
- const pdfBuffer = await pdfPage.pdf({
300
- format: options.format ?? "A4",
301
- printBackground: options.printBackground ?? true,
302
- margin: options.margin ?? { top: "15mm", right: "15mm", bottom: "15mm", left: "15mm" },
303
- });
304
-
305
- await pdfPage.close();
306
- return Buffer.from(pdfBuffer);
307
- }
308
-
309
- /** Shut down the browser and local server with proper resource cleanup. */
310
- async close(): Promise<void> {
311
- try {
312
- // Explicitly close page first to prevent zombie processes
313
- if (this.page) {
314
- await this.page.close().catch(() => {});
315
- this.page = null;
316
- }
317
-
318
- if (this.browser) {
319
- await this.browser.close();
320
- this.browser = null;
321
- }
322
- } finally {
323
- this.ready = false;
324
-
325
- if (this.server) {
326
- await this.server.close();
327
- this.server = null;
328
- }
329
- }
330
- }
331
- }
332
-
333
- /**
334
- * One-shot render: init → render → close.
335
- * Convenience for single-file export.
336
- */
337
- export async function renderMarkdown(input: RenderInput): Promise<RenderResult> {
338
- const renderer = new ColamdRenderer();
339
- try {
340
- return await renderer.render(input);
341
- } finally {
342
- await renderer.close();
343
- }
344
- }
345
-
346
- /**
347
- * One-shot PDF export: init → render PDF → close.
348
- */
349
- export async function renderPDF(
350
- input: RenderInput,
351
- options?: PDFExportOptions
352
- ): Promise<Buffer> {
353
- const renderer = new ColamdRenderer();
354
- try {
355
- return await renderer.exportPDF(input, options);
356
- } finally {
357
- await renderer.close();
358
- }
359
- }
1
+ /**
2
+ * Puppeteer-based rendering engine for ColaMD HTML/PDF export.
3
+ *
4
+ * Uses the @bytechain.cn/colamd 2.x library build (dist/lib/colamd-renderer.js +
5
+ * colamd.css, requires ≥2.7.8-rc.1) to create a headless editor, apply themes,
6
+ * render mermaid/math, and export standalone HTML or PDF. Theme CSS targets the
7
+ * ColaMD 2.7.8-rc.1 contract (machine-readable as themes/contract.json in the
8
+ * npm package) and is consumed directly as `:root` declarations.
9
+ *
10
+ * Architecture:
11
+ * Local HTTP server (serves renderer.js + page HTML)
12
+ * → Puppeteer page (same-origin, no CORS)
13
+ * → createColaMDEditor()
14
+ * → applyTheme() + setMarkdown() + ensureAllPluginsRendered()
15
+ * → buildExportHTML({ title }) / page.pdf()
16
+ */
17
+
18
+ import puppeteer, { type Browser, type Page } from "puppeteer";
19
+ import { createServer, type Server } from "node:http";
20
+ import { readFileSync, existsSync, createReadStream } from "node:fs";
21
+ import { resolve, dirname, join } from "node:path";
22
+
23
+ // ── Types ──
24
+
25
+ /** Input for rendering a markdown document. */
26
+ export interface RenderInput {
27
+ /** Markdown content string */
28
+ markdown: string;
29
+ /** Theme name: built-in (light/dark/elegant/newsprint) or "custom:<name>" */
30
+ themeName?: string;
31
+ /** Custom CSS content (used with "custom:<name>" themes) */
32
+ customCSS?: string;
33
+ /** Document title for the HTML <title> */
34
+ title?: string;
35
+ }
36
+
37
+ /** Result of a render operation. */
38
+ export interface RenderResult {
39
+ /** Complete standalone HTML document */
40
+ html: string;
41
+ }
42
+
43
+ /** PDF export options. */
44
+ export interface PDFExportOptions {
45
+ /** Page format */
46
+ format?: "A4" | "Letter" | "A3";
47
+ /** Print background colors/images */
48
+ printBackground?: boolean;
49
+ /** Page margins */
50
+ margin?: { top?: string; right?: string; bottom?: string; left?: string };
51
+ }
52
+
53
+ // ── File resolution ──
54
+
55
+ /** Locate the ColaMD renderer ESM bundle in node_modules. */
56
+ function findRendererPath(): string {
57
+ const candidates = [
58
+ resolve(dirname(new URL(import.meta.url).pathname), "../node_modules/@bytechain.cn/colamd/dist/lib/colamd-renderer.js"),
59
+ resolve(process.cwd(), "node_modules/@bytechain.cn/colamd/dist/lib/colamd-renderer.js"),
60
+ ];
61
+ for (const p of candidates) {
62
+ if (existsSync(p)) return p;
63
+ }
64
+ // Detect a 2.x distribution without the library entry (e.g. the 2.7.7 app
65
+ // package, or a desktop-only build): give an actionable error.
66
+ for (const base of candidates.map((p) => resolve(p, "../../.."))) {
67
+ const pkgJson = join(base, "package.json");
68
+ if (existsSync(pkgJson)) {
69
+ try {
70
+ const version = JSON.parse(readFileSync(pkgJson, "utf-8")).version as string;
71
+ if (version.startsWith("2.")) {
72
+ throw new Error(
73
+ `@bytechain.cn/colamd ${version} 不含 colamd-renderer.js 库入口(2.7.8 起恢复提供)。` +
74
+ "请升级: npm install @bytechain.cn/colamd@^2.7.8-rc.1"
75
+ );
76
+ }
77
+ } catch (e) {
78
+ if (e instanceof Error && e.message.includes("库入口")) throw e;
79
+ }
80
+ }
81
+ }
82
+ throw new Error(
83
+ "Cannot find @bytechain.cn/colamd renderer. " +
84
+ "Install it: npm install @bytechain.cn/colamd@^2.7.8-rc.1"
85
+ );
86
+ }
87
+
88
+ // ── Local HTTP server ──
89
+
90
+ const SHELL_HTML = `<!DOCTYPE html>
91
+ <html><head>
92
+ <meta charset="utf-8">
93
+ <link rel="stylesheet" href="/colamd.css">
94
+ <style>body{margin:0;padding:0}#editor{width:100%;min-height:100vh}</style>
95
+ </head><body><div id="editor"></div></body></html>`;
96
+
97
+ interface LocalServer {
98
+ port: number;
99
+ close(): Promise<void>;
100
+ }
101
+
102
+ function startLocalServer(rendererPath: string): Promise<LocalServer> {
103
+ const cssPath = join(dirname(rendererPath), "colamd.css");
104
+ return new Promise((resolveStart, reject) => {
105
+ const server: Server = createServer((req, res) => {
106
+ if (req.url === "/" || req.url === "/index.html") {
107
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
108
+ res.end(SHELL_HTML);
109
+ } else if (req.url === "/colamd-renderer.js") {
110
+ res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
111
+ createReadStream(rendererPath).pipe(res);
112
+ } else if (req.url === "/colamd.css") {
113
+ res.writeHead(200, { "Content-Type": "text/css; charset=utf-8" });
114
+ createReadStream(cssPath).pipe(res);
115
+ } else {
116
+ res.writeHead(404);
117
+ res.end();
118
+ }
119
+ });
120
+
121
+ server.listen(0, "127.0.0.1", () => {
122
+ const addr = server.address();
123
+ const port = typeof addr === "object" && addr ? addr.port : 0;
124
+ resolveStart({
125
+ port,
126
+ close: () =>
127
+ new Promise((resolveClose) => server.close(() => resolveClose())),
128
+ });
129
+ });
130
+
131
+ server.on("error", reject);
132
+ });
133
+ }
134
+
135
+ // ── Renderer ──
136
+
137
+ export class ColamdRenderer {
138
+ private browser: Browser | null = null;
139
+ private page: Page | null = null;
140
+ private server: LocalServer | null = null;
141
+ private ready = false;
142
+
143
+ /** Launch Puppeteer and prepare the editor page. */
144
+ async init(): Promise<void> {
145
+ if (this.browser) return;
146
+
147
+ // Start local HTTP server for the renderer bundle
148
+ const rendererPath = findRendererPath();
149
+ this.server = await startLocalServer(rendererPath);
150
+ const origin = `http://127.0.0.1:${this.server.port}`;
151
+
152
+ // Launch Puppeteer (use "shell" mode — chrome-headless-shell binary is more
153
+ // stable on this Windows environment than the full Chrome --headless=new mode)
154
+ this.browser = await puppeteer.launch({
155
+ headless: "shell",
156
+ args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
157
+ });
158
+ this.page = await this.browser.newPage();
159
+
160
+ // Navigate to shell page (same origin as renderer script)
161
+ await this.page.goto(`${origin}/`, { waitUntil: "domcontentloaded" });
162
+
163
+ // Load the ColaMD renderer as ESM (same-origin, no CORS)
164
+ await this.page.addScriptTag({
165
+ type: "module",
166
+ content: `
167
+ import { createColaMDEditor } from '${origin}/colamd-renderer.js';
168
+ window.__colamdReady = createColaMDEditor({
169
+ rootId: 'editor',
170
+ editable: false
171
+ }).then(handle => {
172
+ window.__colamdHandle = handle;
173
+ return true;
174
+ }).catch(err => {
175
+ window.__colamdError = err.message;
176
+ return false;
177
+ });
178
+ `,
179
+ });
180
+
181
+ // Wait for the editor to initialize
182
+ await this.page.waitForFunction(
183
+ () => (window as any).__colamdHandle || (window as any).__colamdError,
184
+ { timeout: 30000 }
185
+ );
186
+
187
+ const error = await this.page.evaluate(() => (window as any).__colamdError);
188
+ if (error) throw new Error(`Editor init failed: ${error}`);
189
+
190
+ this.ready = true;
191
+ }
192
+
193
+ /**
194
+ * Render markdown to a standalone HTML document.
195
+ */
196
+ async render(input: RenderInput): Promise<RenderResult> {
197
+ if (!this.ready || !this.page) await this.init();
198
+ const page = this.page!;
199
+
200
+ // Apply theme — the 2.x renderer consumes :root contract CSS directly
201
+ if (input.themeName) {
202
+ await page.evaluate(
203
+ (name: string, css: string | undefined) => {
204
+ (window as any).__colamdHandle.applyTheme(name, css || undefined);
205
+ },
206
+ input.themeName,
207
+ input.customCSS || undefined
208
+ );
209
+ }
210
+
211
+ // Set markdown content
212
+ await page.evaluate((md: string) => {
213
+ (window as any).__colamdHandle.setMarkdown(md);
214
+ }, input.markdown);
215
+
216
+ // Wait for all plugins (mermaid, math) to finish rendering
217
+ await page.evaluate(async () => {
218
+ await (window as any).__colamdHandle.ensureAllPluginsRendered();
219
+ });
220
+
221
+ // Additional settle time for complex diagrams
222
+ await new Promise((r) => setTimeout(r, 500));
223
+
224
+ // Build export HTML (2.x API: title goes through the options object)
225
+ const html = await page.evaluate((title: string | undefined) => {
226
+ return (window as any).__colamdHandle.buildExportHTML({ title });
227
+ }, input.title);
228
+
229
+ return { html };
230
+ }
231
+
232
+ /**
233
+ * Render markdown and export as PDF.
234
+ */
235
+ async exportPDF(input: RenderInput, options: PDFExportOptions = {}): Promise<Buffer> {
236
+ if (!this.ready || !this.page) await this.init();
237
+
238
+ // First render to HTML
239
+ const { html } = await this.render(input);
240
+
241
+ // Load the HTML in a fresh page for PDF generation
242
+ const pdfPage = await this.browser!.newPage();
243
+ await pdfPage.setContent(html, { waitUntil: "load", timeout: 30000 });
244
+
245
+ // NOTE: Do NOT use emulateMediaType("screen") here.
246
+ // The CSS template includes comprehensive @media print rules (SECTION 6)
247
+ // with !important declarations and print-color-adjust: exact for proper styling.
248
+ // Using screen mode would prevent these print-specific styles from applying.
249
+
250
+ const pdfBuffer = await pdfPage.pdf({
251
+ format: options.format ?? "A4",
252
+ printBackground: options.printBackground ?? true,
253
+ margin: options.margin ?? { top: "15mm", right: "15mm", bottom: "15mm", left: "15mm" },
254
+ });
255
+
256
+ await pdfPage.close();
257
+ return Buffer.from(pdfBuffer);
258
+ }
259
+
260
+ /** Shut down the browser and local server with proper resource cleanup. */
261
+ async close(): Promise<void> {
262
+ try {
263
+ // Explicitly close page first to prevent zombie processes
264
+ if (this.page) {
265
+ await this.page.close().catch(() => {});
266
+ this.page = null;
267
+ }
268
+
269
+ if (this.browser) {
270
+ await this.browser.close();
271
+ this.browser = null;
272
+ }
273
+ } finally {
274
+ this.ready = false;
275
+
276
+ if (this.server) {
277
+ await this.server.close();
278
+ this.server = null;
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ /**
285
+ * One-shot render: init → render → close.
286
+ * Convenience for single-file export.
287
+ */
288
+ export async function renderMarkdown(input: RenderInput): Promise<RenderResult> {
289
+ const renderer = new ColamdRenderer();
290
+ try {
291
+ return await renderer.render(input);
292
+ } finally {
293
+ await renderer.close();
294
+ }
295
+ }
296
+
297
+ /**
298
+ * One-shot PDF export: init → render PDF → close.
299
+ */
300
+ export async function renderPDF(
301
+ input: RenderInput,
302
+ options?: PDFExportOptions
303
+ ): Promise<Buffer> {
304
+ const renderer = new ColamdRenderer();
305
+ try {
306
+ return await renderer.exportPDF(input, options);
307
+ } finally {
308
+ await renderer.close();
309
+ }
310
+ }