@bytechain.cn/colamd-themes 0.3.0 → 0.3.2
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/.openclaw/skills/colamd-themes/SKILL.md +15 -124
- package/.trae/rules/colamd-themes.md +15 -124
- package/CHANGELOG.md +142 -3
- package/README.md +12 -1
- package/README_CN.md +12 -1
- package/RELEASENOTE.md +212 -0
- package/dist/cli.js +278 -2
- package/dist/cli.js.map +1 -1
- package/dist/export-cli.d.ts +1 -0
- package/dist/export-cli.js +208 -176
- package/dist/export-cli.js.map +1 -1
- package/dist/extractors/url-extractor.d.ts +6 -0
- package/dist/extractors/url-extractor.js +51 -2
- package/dist/extractors/url-extractor.js.map +1 -1
- package/dist/generator.d.ts +4 -0
- package/dist/generator.js +74 -7
- package/dist/generator.js.map +1 -1
- package/dist/renderer.d.ts +1 -1
- package/dist/renderer.js +87 -14
- package/dist/renderer.js.map +1 -1
- package/dist/theme-store.d.ts +7 -6
- package/dist/theme-store.js +84 -34
- package/dist/theme-store.js.map +1 -1
- package/dist/utils/cli-utils.d.ts +55 -0
- package/dist/utils/cli-utils.js +80 -0
- package/dist/utils/cli-utils.js.map +1 -0
- package/package.json +1 -1
- package/skills/colamd-themes/SKILL.md +30 -0
- package/src/cli.ts +289 -2
- package/src/export-cli.ts +248 -193
- package/src/extractors/url-extractor.ts +56 -2
- package/src/generator.ts +88 -7
- package/src/renderer.ts +96 -14
- package/src/theme-store.ts +93 -35
- package/src/utils/cli-utils.ts +84 -0
- package/.claude/skills/colamd-themes-preview.skill.md +0 -333
- package/.claude/skills/colamd-themes.skill.md +0 -155
- package/.hermes/skills/tools/colamd-themes/SKILL.md +0 -159
- package/.opencode/skills/colamd-themes/SKILL.md +0 -162
package/src/generator.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** CSS theme file generator — v3.0 seed palette pipeline. */
|
|
2
2
|
|
|
3
3
|
import Handlebars from "handlebars";
|
|
4
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from "node:fs";
|
|
5
5
|
import { join, dirname, isAbsolute } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import type { ThemeStyle } from "./models.js";
|
|
@@ -20,14 +20,95 @@ Handlebars.registerHelper("rgba", (hex: unknown, alpha: unknown) => {
|
|
|
20
20
|
return toRgba(String(hex), Number(alpha));
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
// ── Template Cache with Size Limits and Auto-Invalidation ──
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
interface CachedTemplate {
|
|
26
|
+
compiled: HandlebarsTemplateDelegate<ThemeStyle>;
|
|
27
|
+
mtimeMs: number;
|
|
28
|
+
compiledAt: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TEMPLATE_CACHE_MAX_SIZE = 10;
|
|
32
|
+
const TEMPLATE_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
33
|
+
const templateCache = new Map<string, CachedTemplate>();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get or compile a Handlebars template with caching.
|
|
37
|
+
*
|
|
38
|
+
* Features:
|
|
39
|
+
* - LRU-style cache (max 10 templates)
|
|
40
|
+
* - File modification time detection for auto-invalidation
|
|
41
|
+
* - TTL-based expiration (30 minutes)
|
|
42
|
+
* - Automatic cleanup of expired entries on access
|
|
43
|
+
*/
|
|
44
|
+
function getTemplate(path?: string): HandlebarsTemplateDelegate<ThemeStyle> {
|
|
45
|
+
const key = path || templatePath;
|
|
46
|
+
|
|
47
|
+
// Check cache hit
|
|
48
|
+
const cached = templateCache.get(key);
|
|
49
|
+
if (cached) {
|
|
50
|
+
// Check TTL expiration
|
|
51
|
+
if (Date.now() - cached.compiledAt > TEMPLATE_CACHE_TTL_MS) {
|
|
52
|
+
templateCache.delete(key);
|
|
53
|
+
}
|
|
54
|
+
// Check file modification time
|
|
55
|
+
else {
|
|
56
|
+
try {
|
|
57
|
+
const stats = statSync(key);
|
|
58
|
+
if (stats.mtimeMs > cached.mtimeMs) {
|
|
59
|
+
templateCache.delete(key);
|
|
60
|
+
} else {
|
|
61
|
+
return cached.compiled;
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
return cached.compiled;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Enforce max cache size (evict oldest entry)
|
|
70
|
+
if (templateCache.size >= TEMPLATE_CACHE_MAX_SIZE) {
|
|
71
|
+
let oldestKey: string | null = null;
|
|
72
|
+
let oldestTime = Infinity;
|
|
73
|
+
|
|
74
|
+
for (const [k, v] of templateCache.entries()) {
|
|
75
|
+
if (v.compiledAt < oldestTime) {
|
|
76
|
+
oldestTime = v.compiledAt;
|
|
77
|
+
oldestKey = k;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (oldestKey) {
|
|
82
|
+
templateCache.delete(oldestKey);
|
|
83
|
+
}
|
|
29
84
|
}
|
|
30
|
-
|
|
85
|
+
|
|
86
|
+
// Compile new template
|
|
87
|
+
const source = readFileSync(key, "utf-8");
|
|
88
|
+
const compiled = Handlebars.compile(source, { noEscape: true });
|
|
89
|
+
|
|
90
|
+
// Get modification time for invalidation
|
|
91
|
+
let mtimeMs = 0;
|
|
92
|
+
try {
|
|
93
|
+
mtimeMs = statSync(key).mtimeMs;
|
|
94
|
+
} catch {
|
|
95
|
+
mtimeMs = Date.now();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
templateCache.set(key, {
|
|
99
|
+
compiled,
|
|
100
|
+
mtimeMs,
|
|
101
|
+
compiledAt: Date.now(),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return compiled;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Clear all cached templates to free memory.
|
|
109
|
+
*/
|
|
110
|
+
export function clearTemplateCache(): void {
|
|
111
|
+
templateCache.clear();
|
|
31
112
|
}
|
|
32
113
|
|
|
33
114
|
// ── Pipeline ──
|
package/src/renderer.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import puppeteer, { type Browser, type Page } from "puppeteer";
|
|
16
16
|
import { createServer, type Server } from "node:http";
|
|
17
17
|
import { readFileSync, existsSync, createReadStream } from "node:fs";
|
|
18
|
-
import { resolve, dirname } from "node:path";
|
|
18
|
+
import { resolve, dirname, join } from "node:path";
|
|
19
19
|
|
|
20
20
|
// ── Types ──
|
|
21
21
|
|
|
@@ -69,6 +69,7 @@ function findRendererPath(): string {
|
|
|
69
69
|
const SHELL_HTML = `<!DOCTYPE html>
|
|
70
70
|
<html><head>
|
|
71
71
|
<meta charset="utf-8">
|
|
72
|
+
<link rel="stylesheet" href="/colamd.css">
|
|
72
73
|
<style>body{margin:0;padding:0}#editor{width:100%;min-height:100vh}</style>
|
|
73
74
|
</head><body><div id="editor"></div></body></html>`;
|
|
74
75
|
|
|
@@ -78,6 +79,7 @@ interface LocalServer {
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
function startLocalServer(rendererPath: string): Promise<LocalServer> {
|
|
82
|
+
const cssPath = join(dirname(rendererPath), "colamd.css");
|
|
81
83
|
return new Promise((resolveStart, reject) => {
|
|
82
84
|
const server: Server = createServer((req, res) => {
|
|
83
85
|
if (req.url === "/" || req.url === "/index.html") {
|
|
@@ -86,6 +88,9 @@ function startLocalServer(rendererPath: string): Promise<LocalServer> {
|
|
|
86
88
|
} else if (req.url === "/colamd-renderer.js") {
|
|
87
89
|
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
|
|
88
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);
|
|
89
94
|
} else {
|
|
90
95
|
res.writeHead(404);
|
|
91
96
|
res.end();
|
|
@@ -106,6 +111,69 @@ function startLocalServer(rendererPath: string): Promise<LocalServer> {
|
|
|
106
111
|
});
|
|
107
112
|
}
|
|
108
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
|
+
|
|
109
177
|
// ── Renderer ──
|
|
110
178
|
|
|
111
179
|
export class ColamdRenderer {
|
|
@@ -199,10 +267,13 @@ export class ColamdRenderer {
|
|
|
199
267
|
return (window as any).__colamdHandle.buildExportHTML();
|
|
200
268
|
});
|
|
201
269
|
|
|
270
|
+
// Post-process: supplement any CSS variables that colamd-renderer.js may not include
|
|
271
|
+
const supplementedHtml = supplementExportHTML(html);
|
|
272
|
+
|
|
202
273
|
// Inject custom title if provided
|
|
203
274
|
const finalHtml = input.title
|
|
204
|
-
?
|
|
205
|
-
:
|
|
275
|
+
? supplementedHtml.replace(/<title>[^<]*<\/title>/, `<title>${input.title}</title>`)
|
|
276
|
+
: supplementedHtml;
|
|
206
277
|
|
|
207
278
|
return { html: finalHtml };
|
|
208
279
|
}
|
|
@@ -220,8 +291,10 @@ export class ColamdRenderer {
|
|
|
220
291
|
const pdfPage = await this.browser!.newPage();
|
|
221
292
|
await pdfPage.setContent(html, { waitUntil: "load", timeout: 30000 });
|
|
222
293
|
|
|
223
|
-
//
|
|
224
|
-
|
|
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.
|
|
225
298
|
|
|
226
299
|
const pdfBuffer = await pdfPage.pdf({
|
|
227
300
|
format: options.format ?? "A4",
|
|
@@ -233,17 +306,26 @@ export class ColamdRenderer {
|
|
|
233
306
|
return Buffer.from(pdfBuffer);
|
|
234
307
|
}
|
|
235
308
|
|
|
236
|
-
/** Shut down the browser and local server. */
|
|
309
|
+
/** Shut down the browser and local server with proper resource cleanup. */
|
|
237
310
|
async close(): Promise<void> {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
this.
|
|
241
|
-
|
|
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 {
|
|
242
323
|
this.ready = false;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
324
|
+
|
|
325
|
+
if (this.server) {
|
|
326
|
+
await this.server.close();
|
|
327
|
+
this.server = null;
|
|
328
|
+
}
|
|
247
329
|
}
|
|
248
330
|
}
|
|
249
331
|
}
|
package/src/theme-store.ts
CHANGED
|
@@ -2,15 +2,20 @@
|
|
|
2
2
|
* Theme configuration persistence.
|
|
3
3
|
*
|
|
4
4
|
* Stores default theme and registered custom themes in ~/.colamd-themes/config.json
|
|
5
|
+
* Uses async file I/O to avoid blocking the event loop.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
8
|
+
import { mkdir, readFile, writeFile, access } from "node:fs/promises";
|
|
9
|
+
import { statSync } from "node:fs";
|
|
10
|
+
import { join, basename } from "node:path";
|
|
9
11
|
import { homedir } from "node:os";
|
|
10
12
|
|
|
11
13
|
const CONFIG_DIR = join(homedir(), ".colamd-themes");
|
|
12
14
|
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
13
15
|
|
|
16
|
+
/** Maximum allowed CSS file size (1MB) to prevent OOM attacks */
|
|
17
|
+
const MAX_CSS_SIZE = 1024 * 1024;
|
|
18
|
+
|
|
14
19
|
/** Built-in themes provided by @bytechain.cn/colamd */
|
|
15
20
|
export const BUILTIN_THEMES = ["light", "dark", "elegant", "newsprint"] as const;
|
|
16
21
|
export type BuiltinTheme = (typeof BUILTIN_THEMES)[number];
|
|
@@ -22,30 +27,35 @@ export interface ThemeConfig {
|
|
|
22
27
|
themes: Record<string, string>;
|
|
23
28
|
}
|
|
24
29
|
|
|
25
|
-
function readConfig(): ThemeConfig {
|
|
26
|
-
if (!existsSync(CONFIG_FILE)) return { defaultTheme: "elegant", themes: {} };
|
|
30
|
+
async function readConfig(): Promise<ThemeConfig> {
|
|
27
31
|
try {
|
|
28
|
-
|
|
32
|
+
await access(CONFIG_FILE);
|
|
33
|
+
const data = await readFile(CONFIG_FILE, "utf-8");
|
|
34
|
+
return JSON.parse(data) as ThemeConfig;
|
|
29
35
|
} catch {
|
|
30
36
|
return { defaultTheme: "elegant", themes: {} };
|
|
31
37
|
}
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
function writeConfig(config: ThemeConfig): void {
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
async function writeConfig(config: ThemeConfig): Promise<void> {
|
|
41
|
+
try {
|
|
42
|
+
await access(CONFIG_DIR);
|
|
43
|
+
} catch {
|
|
44
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
/** Get the full config. */
|
|
40
|
-
export function getConfig(): ThemeConfig {
|
|
50
|
+
export async function getConfig(): Promise<ThemeConfig> {
|
|
41
51
|
return readConfig();
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
/** Set the default theme (built-in name or registered custom name). */
|
|
45
|
-
export function setDefaultTheme(name: string): void {
|
|
46
|
-
const config = readConfig();
|
|
55
|
+
export async function setDefaultTheme(name: string): Promise<void> {
|
|
56
|
+
const config = await readConfig();
|
|
47
57
|
config.defaultTheme = name;
|
|
48
|
-
writeConfig(config);
|
|
58
|
+
await writeConfig(config);
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
/**
|
|
@@ -53,21 +63,64 @@ export function setDefaultTheme(name: string): void {
|
|
|
53
63
|
* @param name Unique name (will be prefixed with "custom:" when applied)
|
|
54
64
|
* @param cssPath Absolute path to the CSS file
|
|
55
65
|
*/
|
|
56
|
-
export function registerTheme(name: string, cssPath: string): void {
|
|
57
|
-
const config = readConfig();
|
|
66
|
+
export async function registerTheme(name: string, cssPath: string): Promise<void> {
|
|
67
|
+
const config = await readConfig();
|
|
58
68
|
config.themes[name] = cssPath;
|
|
59
|
-
writeConfig(config);
|
|
69
|
+
await writeConfig(config);
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
/** Remove a registered custom theme. */
|
|
63
|
-
export function unregisterTheme(name: string): boolean {
|
|
64
|
-
const config = readConfig();
|
|
73
|
+
export async function unregisterTheme(name: string): Promise<boolean> {
|
|
74
|
+
const config = await readConfig();
|
|
65
75
|
if (!(name in config.themes)) return false;
|
|
66
76
|
delete config.themes[name];
|
|
67
|
-
writeConfig(config);
|
|
77
|
+
await writeConfig(config);
|
|
68
78
|
return true;
|
|
69
79
|
}
|
|
70
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Sanitize theme name from file path.
|
|
83
|
+
* Removes special characters and limits length to prevent injection.
|
|
84
|
+
*/
|
|
85
|
+
function sanitizeName(path: string): string {
|
|
86
|
+
return basename(path, ".css")
|
|
87
|
+
.replace(/[^a-zA-Z0-9_-]/g, "-")
|
|
88
|
+
.replace(/-+/g, "-")
|
|
89
|
+
.slice(0, 50) || "custom";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Validate CSS file before reading.
|
|
94
|
+
* Checks extension, size, and basic content validity.
|
|
95
|
+
*/
|
|
96
|
+
async function validateCssFile(filePath: string): Promise<void> {
|
|
97
|
+
// Check file extension
|
|
98
|
+
if (!filePath.toLowerCase().endsWith(".css")) {
|
|
99
|
+
throw new Error(`Theme file must have .css extension: ${filePath}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Check file size (synchronous stat is acceptable for metadata)
|
|
103
|
+
let stats;
|
|
104
|
+
try {
|
|
105
|
+
stats = statSync(filePath);
|
|
106
|
+
} catch {
|
|
107
|
+
throw new Error(`Cannot access theme file: ${filePath}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (stats.size > MAX_CSS_SIZE) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Theme file too large (${(stats.size / 1024).toFixed(1)}KB). ` +
|
|
113
|
+
`Maximum size: ${(MAX_CSS_SIZE / 1024).toFixed(0)}MB`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Read and validate content
|
|
118
|
+
const content = await readFile(filePath, "utf-8");
|
|
119
|
+
if (content.length < 10) {
|
|
120
|
+
throw new Error(`Theme file appears to be empty or invalid: ${filePath}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
71
124
|
/**
|
|
72
125
|
* Resolve a theme name to the arguments needed by the ColaMD renderer.
|
|
73
126
|
*
|
|
@@ -75,20 +128,19 @@ export function unregisterTheme(name: string): boolean {
|
|
|
75
128
|
* @returns { themeName, customCSS? } — themeName is either a built-in name
|
|
76
129
|
* or "custom:<name>"; customCSS is the CSS content for custom themes.
|
|
77
130
|
*/
|
|
78
|
-
export function resolveTheme(name?: string): {
|
|
131
|
+
export async function resolveTheme(name?: string): Promise<{
|
|
79
132
|
themeName: string;
|
|
80
133
|
customCSS?: string;
|
|
81
|
-
} {
|
|
82
|
-
const config = readConfig();
|
|
134
|
+
}> {
|
|
135
|
+
const config = await readConfig();
|
|
83
136
|
const resolved = name ?? config.defaultTheme;
|
|
84
137
|
|
|
85
138
|
// Check if it's a registered custom theme
|
|
86
139
|
if (config.themes[resolved]) {
|
|
87
140
|
const cssPath = config.themes[resolved];
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
91
|
-
return { themeName: `custom:${resolved}`, customCSS: readFileSync(cssPath, "utf-8") };
|
|
141
|
+
await validateCssFile(cssPath);
|
|
142
|
+
const customCSS = await readFile(cssPath, "utf-8");
|
|
143
|
+
return { themeName: `custom:${resolved}`, customCSS };
|
|
92
144
|
}
|
|
93
145
|
|
|
94
146
|
// Check if it's a built-in theme
|
|
@@ -97,16 +149,22 @@ export function resolveTheme(name?: string): {
|
|
|
97
149
|
}
|
|
98
150
|
|
|
99
151
|
// Treat as a direct CSS file path
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
152
|
+
try {
|
|
153
|
+
await access(resolved);
|
|
154
|
+
} catch {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`Unknown theme: "${resolved}". Use a built-in theme (${BUILTIN_THEMES.join(", ")}), ` +
|
|
157
|
+
`a registered custom theme, or a path to a .css file.`
|
|
158
|
+
);
|
|
106
159
|
}
|
|
107
160
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
);
|
|
161
|
+
// Validate and read direct CSS file
|
|
162
|
+
await validateCssFile(resolved);
|
|
163
|
+
const nameFromPath = sanitizeName(resolved);
|
|
164
|
+
const customCSS = await readFile(resolved, "utf-8");
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
themeName: `custom:${nameFromPath}`,
|
|
168
|
+
customCSS,
|
|
169
|
+
};
|
|
112
170
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utility functions for CLI commands.
|
|
3
|
+
* Provides consistent file path resolution and error handling.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { resolve, isAbsolute, basename } from "node:path";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve input file path with validation.
|
|
11
|
+
* Converts relative paths to absolute and checks existence.
|
|
12
|
+
*
|
|
13
|
+
* @param input File path provided by user
|
|
14
|
+
* @returns Resolved absolute path
|
|
15
|
+
* @throws Error if file does not exist
|
|
16
|
+
*/
|
|
17
|
+
export function resolveInputPath(input: string): string {
|
|
18
|
+
const path = isAbsolute(input) ? input : resolve(process.cwd(), input);
|
|
19
|
+
if (!existsSync(path)) {
|
|
20
|
+
throw new Error(`File not found: ${path}`);
|
|
21
|
+
}
|
|
22
|
+
return path;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve output file path with sensible defaults.
|
|
27
|
+
* If output path is not specified, derives it from input path by replacing extension.
|
|
28
|
+
*
|
|
29
|
+
* @param inputPath Resolved absolute input path
|
|
30
|
+
* @param output Optional explicit output path
|
|
31
|
+
* @param ext Target extension (default: ".html")
|
|
32
|
+
* @returns Resolved absolute output path
|
|
33
|
+
*/
|
|
34
|
+
export function resolveOutputPath(inputPath: string, output?: string, ext = ".html"): string {
|
|
35
|
+
if (output) {
|
|
36
|
+
return isAbsolute(output) ? output : resolve(process.cwd(), output);
|
|
37
|
+
}
|
|
38
|
+
return inputPath.replace(/\.md$/i, ext);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get base filename without extension for use as document title.
|
|
43
|
+
*
|
|
44
|
+
* @param filePath Absolute or relative file path
|
|
45
|
+
* @returns Filename without extension
|
|
46
|
+
*/
|
|
47
|
+
export function getDocumentTitle(filePath: string): string {
|
|
48
|
+
return basename(filePath, ".md");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Standard error codes for CLI operations.
|
|
53
|
+
* Enables consistent error handling across all commands.
|
|
54
|
+
*/
|
|
55
|
+
export enum ErrorCode {
|
|
56
|
+
/** Input file not found */
|
|
57
|
+
FILE_NOT_FOUND = 1,
|
|
58
|
+
/** Theme resolution failed */
|
|
59
|
+
THEME_ERROR = 2,
|
|
60
|
+
/** Rendering/export failed */
|
|
61
|
+
RENDER_ERROR = 3,
|
|
62
|
+
/** Invalid arguments or configuration */
|
|
63
|
+
INVALID_INPUT = 4,
|
|
64
|
+
/** Unknown/unexpected error */
|
|
65
|
+
UNKNOWN = 99,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Custom error class for CLI operations with structured error codes.
|
|
70
|
+
* Replaces direct process.exit() calls for better error propagation.
|
|
71
|
+
*/
|
|
72
|
+
export class CLIError extends Error {
|
|
73
|
+
constructor(
|
|
74
|
+
message: string,
|
|
75
|
+
public code: ErrorCode = ErrorCode.UNKNOWN,
|
|
76
|
+
public cause?: Error
|
|
77
|
+
) {
|
|
78
|
+
super(message);
|
|
79
|
+
this.name = "CLIError";
|
|
80
|
+
if (cause) {
|
|
81
|
+
this.stack = `${this.message}\nCaused by: ${cause.stack}`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|