@bytechain.cn/colamd-themes 0.3.0 → 0.3.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.
@@ -56,11 +56,65 @@ export class UrlExtractor implements Extractor {
56
56
  return /^https?:\/\//i.test(source);
57
57
  }
58
58
 
59
+ /**
60
+ * Validate URL to prevent SSRF attacks and ensure security.
61
+ * - Only allows http/https protocols
62
+ * - Blocks private network addresses (localhost, 10.x, 172.16-31.x, 192.168.x, 169.254.x)
63
+ */
64
+ private validateUrl(url: string): void {
65
+ let parsed: URL;
66
+ try {
67
+ parsed = new URL(url);
68
+ } catch {
69
+ throw new Error(`Invalid URL format: ${url}`);
70
+ }
71
+
72
+ // Protocol whitelist
73
+ if (!["http:", "https:"].includes(parsed.protocol)) {
74
+ throw new Error(`Unsupported protocol "${parsed.protocol}". Only HTTP and HTTPS are allowed.`);
75
+ }
76
+
77
+ // Block private network ranges to prevent SSRF
78
+ const hostname = parsed.hostname.toLowerCase();
79
+ const isPrivate =
80
+ hostname === "localhost" ||
81
+ hostname === "127.0.0.1" ||
82
+ hostname === "::1" ||
83
+ hostname === "[::1]" ||
84
+ hostname.startsWith("10.") ||
85
+ hostname.startsWith("172.16.") ||
86
+ hostname.startsWith("172.17.") ||
87
+ hostname.startsWith("172.18.") ||
88
+ hostname.startsWith("172.19.") ||
89
+ hostname.startsWith("172.20.") ||
90
+ hostname.startsWith("172.21.") ||
91
+ hostname.startsWith("172.22.") ||
92
+ hostname.startsWith("172.23.") ||
93
+ hostname.startsWith("172.24.") ||
94
+ hostname.startsWith("172.25.") ||
95
+ hostname.startsWith("172.26.") ||
96
+ hostname.startsWith("172.27.") ||
97
+ hostname.startsWith("172.28.") ||
98
+ hostname.startsWith("172.29.") ||
99
+ hostname.startsWith("172.30.") ||
100
+ hostname.startsWith("172.31.") ||
101
+ hostname.startsWith("192.168.") ||
102
+ hostname.startsWith("169.254.");
103
+
104
+ if (isPrivate) {
105
+ throw new Error(`Private network access denied: ${hostname}. Only public URLs are allowed for security reasons.`);
106
+ }
107
+ }
108
+
59
109
  async extract(source: string): Promise<ThemeStyle> {
110
+ this.validateUrl(source);
111
+
60
112
  const response = await axios.get(source, {
61
- headers: { "User-Agent": "ColaMD-Themes/1.0 (Theme Extractor)" },
113
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; ColaMD-Themes/1.0)" },
62
114
  timeout: 15000,
63
- maxRedirects: 5,
115
+ maxRedirects: 3,
116
+ maxContentLength: 10 * 1024 * 1024,
117
+ responseType: "text",
64
118
  });
65
119
 
66
120
  const contentType = String(response.headers["content-type"] || "");
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
- let compiledTemplate: HandlebarsTemplateDelegate<ThemeStyle> | null = null;
23
+ // ── Template Cache with Size Limits and Auto-Invalidation ──
24
24
 
25
- function getTemplate(): HandlebarsTemplateDelegate<ThemeStyle> {
26
- if (!compiledTemplate) {
27
- const source = readFileSync(templatePath, "utf-8");
28
- compiledTemplate = Handlebars.compile(source, { noEscape: true });
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
- return compiledTemplate;
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
@@ -220,8 +220,10 @@ export class ColamdRenderer {
220
220
  const pdfPage = await this.browser!.newPage();
221
221
  await pdfPage.setContent(html, { waitUntil: "load", timeout: 30000 });
222
222
 
223
- // Emulate screen media to preserve theme colors
224
- await pdfPage.emulateMediaType("screen");
223
+ // NOTE: Do NOT use emulateMediaType("screen") here.
224
+ // The CSS template includes comprehensive @media print rules (SECTION 6)
225
+ // with !important declarations and print-color-adjust: exact for proper styling.
226
+ // Using screen mode would prevent these print-specific styles from applying.
225
227
 
226
228
  const pdfBuffer = await pdfPage.pdf({
227
229
  format: options.format ?? "A4",
@@ -233,17 +235,26 @@ export class ColamdRenderer {
233
235
  return Buffer.from(pdfBuffer);
234
236
  }
235
237
 
236
- /** Shut down the browser and local server. */
238
+ /** Shut down the browser and local server with proper resource cleanup. */
237
239
  async close(): Promise<void> {
238
- if (this.browser) {
239
- await this.browser.close();
240
- this.browser = null;
241
- this.page = null;
240
+ try {
241
+ // Explicitly close page first to prevent zombie processes
242
+ if (this.page) {
243
+ await this.page.close().catch(() => {});
244
+ this.page = null;
245
+ }
246
+
247
+ if (this.browser) {
248
+ await this.browser.close();
249
+ this.browser = null;
250
+ }
251
+ } finally {
242
252
  this.ready = false;
243
- }
244
- if (this.server) {
245
- await this.server.close();
246
- this.server = null;
253
+
254
+ if (this.server) {
255
+ await this.server.close();
256
+ this.server = null;
257
+ }
247
258
  }
248
259
  }
249
260
  }
@@ -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 { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
- import { join } from "node:path";
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
- return JSON.parse(readFileSync(CONFIG_FILE, "utf-8")) as ThemeConfig;
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
- if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
36
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
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
- if (!existsSync(cssPath)) {
89
- throw new Error(`Custom theme CSS file not found: ${cssPath}`);
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
- if (existsSync(resolved)) {
101
- const nameFromPath = resolved.replace(/\.css$/i, "").split("/").pop() ?? "custom";
102
- return {
103
- themeName: `custom:${nameFromPath}`,
104
- customCSS: readFileSync(resolved, "utf-8"),
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
- throw new Error(
109
- `Unknown theme: "${resolved}". Use a built-in theme (${BUILTIN_THEMES.join(", ")}), ` +
110
- `a registered custom theme, or a path to a .css file.`
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
+ }