@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/export-cli.ts
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* Export CLI commands: set-theme, export-html, export-pdf, export (batch).
|
|
3
3
|
*
|
|
4
4
|
* These are registered onto the main Commander program in cli.ts.
|
|
5
|
+
* Uses unified error handling via CLIError and shared utility functions.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import { Command } from "commander";
|
|
8
|
-
import {
|
|
9
|
-
import { join,
|
|
9
|
+
import { readFileSync, readdirSync, statSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
10
11
|
import chalk from "chalk";
|
|
11
12
|
import ora from "ora";
|
|
12
13
|
import {
|
|
@@ -18,6 +19,13 @@ import {
|
|
|
18
19
|
resolveTheme,
|
|
19
20
|
} from "./theme-store.js";
|
|
20
21
|
import { ColamdRenderer, type RenderInput } from "./renderer.js";
|
|
22
|
+
import {
|
|
23
|
+
resolveInputPath,
|
|
24
|
+
resolveOutputPath,
|
|
25
|
+
getDocumentTitle,
|
|
26
|
+
CLIError,
|
|
27
|
+
ErrorCode,
|
|
28
|
+
} from "./utils/cli-utils.js";
|
|
21
29
|
|
|
22
30
|
// ── set-theme ──
|
|
23
31
|
|
|
@@ -29,54 +37,62 @@ function registerSetTheme(program: Command): void {
|
|
|
29
37
|
.option("--css <file>", "Register as custom theme using this CSS file")
|
|
30
38
|
.option("--default", "Set as the default export theme")
|
|
31
39
|
.option("--remove", "Unregister a custom theme")
|
|
32
|
-
.action((name: string, options: { css?: string; default?: boolean; remove?: boolean }) => {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
.action(async (name: string, options: { css?: string; default?: boolean; remove?: boolean }) => {
|
|
41
|
+
try {
|
|
42
|
+
// Remove
|
|
43
|
+
if (options.remove) {
|
|
44
|
+
const removed = await storeUnregister(name);
|
|
45
|
+
if (removed) {
|
|
46
|
+
console.log(chalk.green(`✔ Unregistered custom theme "${name}"`));
|
|
47
|
+
} else {
|
|
48
|
+
console.log(chalk.yellow(`Theme "${name}" is not registered.`));
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
39
51
|
}
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
52
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
console.
|
|
48
|
-
|
|
53
|
+
// Register custom theme
|
|
54
|
+
if (options.css) {
|
|
55
|
+
const cssPath = resolveInputPath(options.css);
|
|
56
|
+
await storeRegister(name, cssPath);
|
|
57
|
+
console.log(chalk.green(`✔ Registered custom theme "${name}" → ${cssPath}`));
|
|
58
|
+
if (options.default) {
|
|
59
|
+
await storeSetDefault(name);
|
|
60
|
+
console.log(chalk.green(`✔ Set "${name}" as default theme`));
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
49
63
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
64
|
+
|
|
65
|
+
// Set as default (built-in or already registered)
|
|
66
|
+
if (!BUILTIN_THEMES.includes(name as any)) {
|
|
67
|
+
const config = await getConfig();
|
|
68
|
+
if (!(name in config.themes)) {
|
|
69
|
+
throw new CLIError(
|
|
70
|
+
`Unknown theme "${name}". ` +
|
|
71
|
+
chalk.dim(`Built-in: ${BUILTIN_THEMES.join(", ")}. `) +
|
|
72
|
+
chalk.dim("Or register a custom theme with --css <file>."),
|
|
73
|
+
ErrorCode.THEME_ERROR
|
|
74
|
+
);
|
|
75
|
+
}
|
|
55
76
|
}
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
77
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
);
|
|
68
|
-
|
|
78
|
+
await storeSetDefault(name);
|
|
79
|
+
console.log(chalk.green(`✔ Default theme set to "${name}"`));
|
|
80
|
+
|
|
81
|
+
// Show current config
|
|
82
|
+
const config = await getConfig();
|
|
83
|
+
console.log(chalk.dim(`\n Default: ${config.defaultTheme}`));
|
|
84
|
+
const customs = Object.keys(config.themes);
|
|
85
|
+
if (customs.length) {
|
|
86
|
+
console.log(chalk.dim(` Custom themes: ${customs.join(", ")}`));
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (err instanceof CLIError) {
|
|
90
|
+
console.error(chalk.red(err.message));
|
|
91
|
+
process.exit(err.code);
|
|
92
|
+
} else {
|
|
93
|
+
console.error(chalk.red(`Unexpected error: ${(err as Error).message}`));
|
|
94
|
+
process.exit(ErrorCode.UNKNOWN);
|
|
69
95
|
}
|
|
70
|
-
}
|
|
71
|
-
storeSetDefault(name);
|
|
72
|
-
console.log(chalk.green(`✔ Default theme set to "${name}"`));
|
|
73
|
-
|
|
74
|
-
// Show current config
|
|
75
|
-
const config = getConfig();
|
|
76
|
-
console.log(chalk.dim(`\n Default: ${config.defaultTheme}`));
|
|
77
|
-
const customs = Object.keys(config.themes);
|
|
78
|
-
if (customs.length) {
|
|
79
|
-
console.log(chalk.dim(` Custom themes: ${customs.join(", ")}`));
|
|
80
96
|
}
|
|
81
97
|
});
|
|
82
98
|
}
|
|
@@ -91,42 +107,47 @@ function registerExportHTML(program: Command): void {
|
|
|
91
107
|
.option("-t, --theme <name>", "Theme name (built-in, custom, or CSS file path)")
|
|
92
108
|
.option("-o, --output <path>", "Output HTML file path")
|
|
93
109
|
.action(async (input: string, options: { theme?: string; output?: string }) => {
|
|
94
|
-
const inputPath = isAbsolute(input) ? input : resolve(process.cwd(), input);
|
|
95
|
-
if (!existsSync(inputPath)) {
|
|
96
|
-
console.error(chalk.red(`File not found: ${inputPath}`));
|
|
97
|
-
process.exit(1);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const md = readFileSync(inputPath, "utf-8");
|
|
101
|
-
const title = basename(inputPath, ".md");
|
|
102
|
-
const outputPath = options.output
|
|
103
|
-
? (isAbsolute(options.output) ? options.output : resolve(process.cwd(), options.output))
|
|
104
|
-
: inputPath.replace(/\.md$/i, ".html");
|
|
105
|
-
|
|
106
|
-
let theme;
|
|
107
|
-
try {
|
|
108
|
-
theme = resolveTheme(options.theme);
|
|
109
|
-
} catch (err) {
|
|
110
|
-
console.error(chalk.red((err as Error).message));
|
|
111
|
-
process.exit(1);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const spinner = ora(`Rendering ${basename(inputPath)} → HTML (${theme.themeName})...`).start();
|
|
115
110
|
try {
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
111
|
+
// Resolve paths using shared utilities
|
|
112
|
+
const inputPath = resolveInputPath(input);
|
|
113
|
+
const title = getDocumentTitle(inputPath);
|
|
114
|
+
const outputPath = resolveOutputPath(inputPath, options.output, ".html");
|
|
115
|
+
|
|
116
|
+
// Read markdown content
|
|
117
|
+
const md = readFileSync(inputPath, "utf-8");
|
|
118
|
+
|
|
119
|
+
// Resolve theme with async API
|
|
120
|
+
const theme = await resolveTheme(options.theme);
|
|
121
|
+
|
|
122
|
+
// Render with progress indicator
|
|
123
|
+
const spinner = ora(`Rendering ${title} → HTML (${theme.themeName})...`).start();
|
|
124
|
+
try {
|
|
125
|
+
const { renderMarkdown } = await import("./renderer.js");
|
|
126
|
+
const result = await renderMarkdown({
|
|
127
|
+
markdown: md,
|
|
128
|
+
themeName: theme.themeName,
|
|
129
|
+
customCSS: theme.customCSS,
|
|
130
|
+
title,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const { writeFileSync } = await import("node:fs");
|
|
134
|
+
writeFileSync(outputPath, result.html, "utf-8");
|
|
135
|
+
spinner.succeed(chalk.green(`Exported: ${outputPath}`));
|
|
136
|
+
} catch (renderErr) {
|
|
137
|
+
spinner.fail(chalk.red(`Export failed: ${(renderErr as Error).message}`));
|
|
138
|
+
throw new CLIError(
|
|
139
|
+
`HTML rendering failed: ${(renderErr as Error).message}`,
|
|
140
|
+
ErrorCode.RENDER_ERROR,
|
|
141
|
+
renderErr as Error
|
|
142
|
+
);
|
|
143
|
+
}
|
|
127
144
|
} catch (err) {
|
|
128
|
-
|
|
129
|
-
|
|
145
|
+
if (err instanceof CLIError) {
|
|
146
|
+
process.exit(err.code);
|
|
147
|
+
} else {
|
|
148
|
+
console.error(chalk.red((err as Error).message));
|
|
149
|
+
process.exit(ErrorCode.FILE_NOT_FOUND);
|
|
150
|
+
}
|
|
130
151
|
}
|
|
131
152
|
});
|
|
132
153
|
}
|
|
@@ -142,45 +163,59 @@ function registerExportPDF(program: Command): void {
|
|
|
142
163
|
.option("-o, --output <path>", "Output PDF file path")
|
|
143
164
|
.option("--format <size>", "Page format: A4, Letter, A3", "A4")
|
|
144
165
|
.action(async (input: string, options: { theme?: string; output?: string; format?: string }) => {
|
|
145
|
-
const inputPath = isAbsolute(input) ? input : resolve(process.cwd(), input);
|
|
146
|
-
if (!existsSync(inputPath)) {
|
|
147
|
-
console.error(chalk.red(`File not found: ${inputPath}`));
|
|
148
|
-
process.exit(1);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const md = readFileSync(inputPath, "utf-8");
|
|
152
|
-
const title = basename(inputPath, ".md");
|
|
153
|
-
const outputPath = options.output
|
|
154
|
-
? (isAbsolute(options.output) ? options.output : resolve(process.cwd(), options.output))
|
|
155
|
-
: inputPath.replace(/\.md$/i, ".pdf");
|
|
156
|
-
|
|
157
|
-
let theme;
|
|
158
166
|
try {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
167
|
+
// Resolve paths using shared utilities
|
|
168
|
+
const inputPath = resolveInputPath(input);
|
|
169
|
+
const title = getDocumentTitle(inputPath);
|
|
170
|
+
const outputPath = resolveOutputPath(inputPath, options.output, ".pdf");
|
|
171
|
+
|
|
172
|
+
// Read markdown content
|
|
173
|
+
const md = readFileSync(inputPath, "utf-8");
|
|
174
|
+
|
|
175
|
+
// Validate format option
|
|
176
|
+
const validFormats = ["A4", "Letter", "A3"];
|
|
177
|
+
if (!validFormats.includes(options.format ?? "")) {
|
|
178
|
+
throw new CLIError(
|
|
179
|
+
`Invalid page format: "${options.format}". Valid formats: ${validFormats.join(", ")}`,
|
|
180
|
+
ErrorCode.INVALID_INPUT
|
|
181
|
+
);
|
|
182
|
+
}
|
|
164
183
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const { renderPDF } = await import("./renderer.js");
|
|
168
|
-
const pdfBuffer = await renderPDF(
|
|
169
|
-
{
|
|
170
|
-
markdown: md,
|
|
171
|
-
themeName: theme.themeName,
|
|
172
|
-
customCSS: theme.customCSS,
|
|
173
|
-
title,
|
|
174
|
-
},
|
|
175
|
-
{ format: options.format as any }
|
|
176
|
-
);
|
|
184
|
+
// Resolve theme with async API
|
|
185
|
+
const theme = await resolveTheme(options.theme);
|
|
177
186
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
187
|
+
// Render PDF with progress indicator
|
|
188
|
+
const spinner = ora(`Rendering ${title} → PDF (${theme.themeName})...`).start();
|
|
189
|
+
try {
|
|
190
|
+
const { renderPDF } = await import("./renderer.js");
|
|
191
|
+
const pdfBuffer = await renderPDF(
|
|
192
|
+
{
|
|
193
|
+
markdown: md,
|
|
194
|
+
themeName: theme.themeName,
|
|
195
|
+
customCSS: theme.customCSS,
|
|
196
|
+
title,
|
|
197
|
+
},
|
|
198
|
+
{ format: options.format as any }
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const { writeFileSync } = await import("node:fs");
|
|
202
|
+
writeFileSync(outputPath, pdfBuffer);
|
|
203
|
+
spinner.succeed(chalk.green(`Exported: ${outputPath}`));
|
|
204
|
+
} catch (renderErr) {
|
|
205
|
+
spinner.fail(chalk.red(`Export failed: ${(renderErr as Error).message}`));
|
|
206
|
+
throw new CLIError(
|
|
207
|
+
`PDF rendering failed: ${(renderErr as Error).message}`,
|
|
208
|
+
ErrorCode.RENDER_ERROR,
|
|
209
|
+
renderErr as Error
|
|
210
|
+
);
|
|
211
|
+
}
|
|
181
212
|
} catch (err) {
|
|
182
|
-
|
|
183
|
-
|
|
213
|
+
if (err instanceof CLIError) {
|
|
214
|
+
process.exit(err.code);
|
|
215
|
+
} else {
|
|
216
|
+
console.error(chalk.red((err as Error).message));
|
|
217
|
+
process.exit(ErrorCode.FILE_NOT_FOUND);
|
|
218
|
+
}
|
|
184
219
|
}
|
|
185
220
|
});
|
|
186
221
|
}
|
|
@@ -196,97 +231,117 @@ function registerExport(program: Command): void {
|
|
|
196
231
|
.option("-t, --theme <name>", "Theme name")
|
|
197
232
|
.option("-d, --output-dir <dir>", "Output directory", ".")
|
|
198
233
|
.action(async (inputs: string[], options: { format?: string; theme?: string; outputDir?: string }) => {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
234
|
+
try {
|
|
235
|
+
// Resolve glob inputs (shell usually expands, but handle edge cases)
|
|
236
|
+
const files: string[] = [];
|
|
237
|
+
for (const input of inputs) {
|
|
238
|
+
try {
|
|
239
|
+
const p = resolveInputPath(input);
|
|
240
|
+
if (statSync(p).isDirectory()) {
|
|
241
|
+
// Directory: include all .md files
|
|
242
|
+
const dirFiles = readdirSync(p)
|
|
243
|
+
.filter((f) => f.endsWith(".md"))
|
|
244
|
+
.map((f) => join(p, f));
|
|
245
|
+
files.push(...dirFiles);
|
|
246
|
+
} else {
|
|
247
|
+
files.push(p);
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
console.log(chalk.yellow(`Skipping (not found): ${input}`));
|
|
212
251
|
}
|
|
213
|
-
} else {
|
|
214
|
-
console.log(chalk.yellow(`Skipping (not found): ${input}`));
|
|
215
252
|
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
if (files.length === 0) {
|
|
219
|
-
console.error(chalk.red("No valid input files found."));
|
|
220
|
-
process.exit(1);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
let theme;
|
|
224
|
-
try {
|
|
225
|
-
theme = resolveTheme(options.theme);
|
|
226
|
-
} catch (err) {
|
|
227
|
-
console.error(chalk.red((err as Error).message));
|
|
228
|
-
process.exit(1);
|
|
229
|
-
}
|
|
230
253
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const fmt = options.format ?? "html";
|
|
235
|
-
|
|
236
|
-
// Ensure output directory exists
|
|
237
|
-
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
|
238
|
-
|
|
239
|
-
console.log(chalk.bold(`\nBatch export: ${files.length} file(s) → ${fmt.toUpperCase()}`));
|
|
240
|
-
console.log(chalk.dim(` Theme: ${theme.themeName}`));
|
|
241
|
-
console.log(chalk.dim(` Output: ${outDir}/\n`));
|
|
242
|
-
|
|
243
|
-
const renderer = new ColamdRenderer();
|
|
244
|
-
let success = 0;
|
|
245
|
-
let failed = 0;
|
|
254
|
+
if (files.length === 0) {
|
|
255
|
+
throw new CLIError("No valid input files found.", ErrorCode.FILE_NOT_FOUND);
|
|
256
|
+
}
|
|
246
257
|
|
|
247
|
-
|
|
248
|
-
await
|
|
258
|
+
// Resolve theme with async API
|
|
259
|
+
const theme = await resolveTheme(options.theme);
|
|
249
260
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const outPath = join(outDir, `${name}.${fmt === "pdf" ? "pdf" : "html"}`);
|
|
254
|
-
const spinner = ora(` ${name}.md`).start();
|
|
261
|
+
// Setup output directory
|
|
262
|
+
const outDir = resolve(process.cwd(), options.outputDir ?? ".");
|
|
263
|
+
const fmt = options.format ?? "html";
|
|
255
264
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
};
|
|
265
|
+
if (!["html", "pdf"].includes(fmt)) {
|
|
266
|
+
throw new CLIError(
|
|
267
|
+
`Invalid format: "${fmt}". Supported formats: html, pdf`,
|
|
268
|
+
ErrorCode.INVALID_INPUT
|
|
269
|
+
);
|
|
270
|
+
}
|
|
263
271
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
+
// Ensure output directory exists
|
|
273
|
+
const { existsSync: dirExists } = await import("node:fs");
|
|
274
|
+
if (!dirExists(outDir)) mkdirSync(outDir, { recursive: true });
|
|
275
|
+
|
|
276
|
+
console.log(chalk.bold(`\nBatch export: ${files.length} file(s) → ${fmt.toUpperCase()}`));
|
|
277
|
+
console.log(chalk.dim(` Theme: ${theme.themeName}`));
|
|
278
|
+
console.log(chalk.dim(` Output: ${outDir}/\n`));
|
|
279
|
+
|
|
280
|
+
// Initialize renderer for batch processing
|
|
281
|
+
const renderer = new ColamdRenderer();
|
|
282
|
+
let success = 0;
|
|
283
|
+
let failed = 0;
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
await renderer.init();
|
|
287
|
+
|
|
288
|
+
for (const file of files) {
|
|
289
|
+
const name = getDocumentTitle(file);
|
|
290
|
+
const md = readFileSync(file, "utf-8");
|
|
291
|
+
const outPath = join(outDir, `${name}.${fmt === "pdf" ? "pdf" : "html"}`);
|
|
292
|
+
const spinner = ora(` ${name}.md`).start();
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const input: RenderInput = {
|
|
296
|
+
markdown: md,
|
|
297
|
+
themeName: theme.themeName,
|
|
298
|
+
customCSS: theme.customCSS,
|
|
299
|
+
title: name,
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
if (fmt === "pdf") {
|
|
303
|
+
const pdfBuffer = await renderer.exportPDF(input);
|
|
304
|
+
const { writeFileSync } = await import("node:fs");
|
|
305
|
+
writeFileSync(outPath, pdfBuffer);
|
|
306
|
+
} else {
|
|
307
|
+
const result = await renderer.render(input);
|
|
308
|
+
const { writeFileSync } = await import("node:fs");
|
|
309
|
+
writeFileSync(outPath, result.html, "utf-8");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
spinner.succeed(`${name}.${fmt === "pdf" ? "pdf" : "html"}`);
|
|
313
|
+
success++;
|
|
314
|
+
} catch (fileErr) {
|
|
315
|
+
spinner.fail(`${name}.md — ${(fileErr as Error).message}`);
|
|
316
|
+
failed++;
|
|
272
317
|
}
|
|
273
|
-
|
|
274
|
-
spinner.succeed(`${name}.${fmt === "pdf" ? "pdf" : "html"}`);
|
|
275
|
-
success++;
|
|
276
|
-
} catch (err) {
|
|
277
|
-
spinner.fail(`${name}.md — ${(err as Error).message}`);
|
|
278
|
-
failed++;
|
|
279
318
|
}
|
|
319
|
+
} finally {
|
|
320
|
+
await renderer.close();
|
|
280
321
|
}
|
|
281
|
-
} finally {
|
|
282
|
-
await renderer.close();
|
|
283
|
-
}
|
|
284
322
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
323
|
+
// Summary
|
|
324
|
+
console.log(
|
|
325
|
+
chalk.bold(`\nDone: ${chalk.green(`${success} exported`)}`) +
|
|
326
|
+
(failed ? chalk.red(`, ${failed} failed`) : "") +
|
|
327
|
+
"\n"
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
if (failed > 0) {
|
|
331
|
+
throw new CLIError(
|
|
332
|
+
`${failed} file(s) failed to export`,
|
|
333
|
+
ErrorCode.RENDER_ERROR
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
} catch (err) {
|
|
337
|
+
if (err instanceof CLIError) {
|
|
338
|
+
console.error(chalk.red(err.message));
|
|
339
|
+
process.exit(err.code);
|
|
340
|
+
} else {
|
|
341
|
+
console.error(chalk.red((err as Error).message));
|
|
342
|
+
process.exit(ErrorCode.UNKNOWN);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
290
345
|
});
|
|
291
346
|
}
|
|
292
347
|
|
|
@@ -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
|
|
113
|
+
headers: { "User-Agent": "Mozilla/5.0 (compatible; ColaMD-Themes/1.0)" },
|
|
62
114
|
timeout: 15000,
|
|
63
|
-
maxRedirects:
|
|
115
|
+
maxRedirects: 3,
|
|
116
|
+
maxContentLength: 10 * 1024 * 1024,
|
|
117
|
+
responseType: "text",
|
|
64
118
|
});
|
|
65
119
|
|
|
66
120
|
const contentType = String(response.headers["content-type"] || "");
|