dsh-deeptutor 0.1.7 → 0.1.8

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.
@@ -1,381 +1,381 @@
1
- #!/usr/bin/env node
2
- /**
3
- * md-to-html.js — zero-dependency Markdown → self-contained HTML converter.
4
- *
5
- * Usage:
6
- * node md-to-html.js <input.md> [--out out.html] [--title "T"] [--theme dark|light]
7
- * [--toc] [--lang zh-CN] [--no-collapse]
8
- * cat file.md | node md-to-html.js --title "T" --out out.html
9
- *
10
- * Features: headings, fenced code blocks, inline code/bold/italic/links, lists,
11
- * task lists, blockquotes, pipe tables, hr, quiz answer collapsing (<details>),
12
- * raw HTML passthrough, HTML escaping. Zero npm dependencies (Node >= 20.11 — uses import.meta.dirname).
13
- */
14
- "use strict";
15
-
16
- import fs from "node:fs";
17
- import path from "node:path";
18
-
19
- // ---------------------------------------------------------------------------
20
- // CLI arg parsing
21
- // ---------------------------------------------------------------------------
22
- function parseArgs(argv) {
23
- const opts = {
24
- out: null,
25
- title: null,
26
- theme: "light",
27
- toc: false,
28
- lang: "zh-CN",
29
- collapse: true,
30
- };
31
- const positional = [];
32
- const valueFlags = new Set(["--out", "--title", "--theme", "--lang"]);
33
- for (let i = 0; i < argv.length; i++) {
34
- const a = argv[i];
35
- if (a === "--toc") opts.toc = true;
36
- else if (a === "--no-collapse") opts.collapse = false;
37
- else if (a === "--help" || a === "-h") {
38
- printHelp();
39
- process.exit(0);
40
- } else if (valueFlags.has(a)) {
41
- const value = argv[i + 1];
42
- if (value === undefined || value.startsWith("--")) {
43
- fail(`Missing value for ${a} (e.g. --out out.html)`);
44
- }
45
- i++;
46
- if (a === "--out") opts.out = value;
47
- else if (a === "--title") opts.title = value;
48
- else if (a === "--theme") {
49
- if (value !== "dark" && value !== "light") {
50
- fail(`Invalid --theme value "${value}": expected dark or light`);
51
- }
52
- opts.theme = value;
53
- } else if (a === "--lang") opts.lang = value;
54
- } else if (a.startsWith("--")) {
55
- fail(`Unknown option: ${a} (run with --help for usage)`);
56
- } else positional.push(a);
57
- }
58
- return { opts, positional };
59
- }
60
-
61
- /** Print an error to stderr and exit non-zero (CLI contract: never silently swallow bad flags). */
62
- function fail(message) {
63
- console.error(`[html-doc] ${message}`);
64
- process.exit(1);
65
- }
66
-
67
- function printHelp() {
68
- console.log(`md-to-html.js — Markdown → self-contained HTML
69
-
70
- Usage:
71
- node md-to-html.js <input.md> [options]
72
- cat file.md | node md-to-html.js [options]
73
-
74
- Options:
75
- --out <file> output path (default: <input>.html, or stdout when stdin)
76
- --title <text> page title (default: input filename or "Document")
77
- --theme <mode> dark | light (default: light; toggle also available in page)
78
- --toc generate a table of contents from h2/h3 headings
79
- --lang <code> html lang attribute (default: zh-CN)
80
- --no-collapse disable quiz-answer collapsing
81
- --help show this help`);
82
- }
83
-
84
- // ---------------------------------------------------------------------------
85
- // Helpers
86
- // ---------------------------------------------------------------------------
87
- const esc = (s) =>
88
- s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
89
-
90
- /** Inline formatting: `code`, **bold**, *italic*, [text](url), ~~strike~~ */
91
- function inline(text) {
92
- // Protect already-raw-HTML spans? We escape everything except explicit HTML
93
- // blocks, so inline HTML is escaped (safe default). Code spans first:
94
- let out = "";
95
- const parts = text.split(/(`[^`\n]+`)/g);
96
- for (const p of parts) {
97
- if (p.startsWith("`") && p.endsWith("`") && p.length > 1) {
98
- out += `<code>${esc(p.slice(1, -1))}</code>`;
99
- } else {
100
- out += esc(p)
101
- .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
102
- .replace(/~~([^~]+)~~/g, "<del>$1</del>")
103
- .replace(/\*([^*\n]+)\*/g, "<em>$1</em>")
104
- .replace(
105
- /\[([^\]]+)\]\((https?:[^)\s]+)\)/g,
106
- '<a href="$2" target="_blank" rel="noopener">$1</a>',
107
- );
108
- }
109
- }
110
- return out;
111
- }
112
-
113
- /** Split a pipe-table row into cells, honoring escaped pipes. */
114
- function splitRow(line) {
115
- const l = line.trim().replace(/^\||\|$/g, "");
116
- return l.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, "|"));
117
- }
118
-
119
- const isTableSep = (cells) =>
120
- cells.length > 0 && cells.every((c) => /^:?-{2,}:?$/.test(c.replace(/\s/g, "")));
121
-
122
- // ---------------------------------------------------------------------------
123
- // Block parser
124
- // ---------------------------------------------------------------------------
125
- function parse(md, opts) {
126
- const lines = md.replace(/\r\n/g, "\n").split("\n");
127
- const body = [];
128
- const toc = [];
129
- let i = 0;
130
-
131
- // Heading regex incl. optional trailing closing #s
132
- const hRe = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
133
- // Quiz-answer heading (collapsed <details>)
134
- const ansRe = /^(#{1,6})\s+(.*(?:答案|解答|解析|Answer|Explanation|Solution).*)$/i;
135
-
136
- const flushList = (list) => {
137
- const tag = list.ordered ? "ol" : "ul";
138
- let html = `<${tag}>\n`;
139
- for (const item of list.items) {
140
- html +=
141
- item.task !== null
142
- ? `<li class="task${item.task ? " done" : ""}"><input type="checkbox"${item.task ? " checked" : ""} disabled> ${item.html}</li>\n`
143
- : `<li>${item.html}</li>\n`;
144
- }
145
- html += `</${tag}>`;
146
- body.push(html);
147
- };
148
-
149
- while (i < lines.length) {
150
- const line = lines[i];
151
-
152
- // Fenced code block
153
- if (/^```/.test(line)) {
154
- const lang = (line.match(/^```\s*([^\s]+)?/) || [])[1] || "";
155
- const buf = [];
156
- i++;
157
- while (i < lines.length && !/^```/.test(lines[i])) {
158
- buf.push(lines[i]);
159
- i++;
160
- }
161
- i++; // closing fence
162
- body.push(
163
- `<pre class="code-block${lang ? " has-lang" : ""}"><span class="lang-badge">${esc(lang)}</span><code>${esc(buf.join("\n"))}</code></pre>`,
164
- );
165
- continue;
166
- }
167
-
168
- // Raw HTML block: only the quiz-answer <details>/<summary> convention passes through
169
- // unescaped. Kept narrow on purpose — deeptutor_run feeds model/web_search-sourced
170
- // answers into this converter automatically, so a wider tag whitelist (div/table/p/...)
171
- // would let untrusted <script>/on*= content ride along into the generated local HTML.
172
- if (/^<(details|summary)\b/i.test(line.trim())) {
173
- const tag = /^<details\b/i.test(line.trim()) ? "details" : "summary";
174
- const closeRe = new RegExp(`</${tag}>`, "i");
175
- const buf = [line];
176
- i++;
177
- // Collect until the matching closing tag, not the first blank line/heading — the body
178
- // of a <details> block is free-form text and commonly contains blank lines.
179
- if (!closeRe.test(line)) {
180
- while (i < lines.length && !closeRe.test(lines[i])) {
181
- buf.push(lines[i]);
182
- i++;
183
- }
184
- if (i < lines.length) {
185
- buf.push(lines[i]);
186
- i++;
187
- }
188
- }
189
- body.push(buf.join("\n"));
190
- continue;
191
- }
192
-
193
- // Blank line
194
- if (line.trim() === "") {
195
- i++;
196
- continue;
197
- }
198
-
199
- // Horizontal rule
200
- if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) {
201
- body.push("<hr>");
202
- i++;
203
- continue;
204
- }
205
-
206
- // Heading
207
- if (hRe.test(line)) {
208
- const m = line.match(hRe);
209
- const level = m[1].length;
210
- const text = m[2].trim();
211
- const slug = `h-${body.length}-${
212
- text
213
- .toLowerCase()
214
- .replace(/[^\w\u4e00-\u9fa5]+/g, "-")
215
- .replace(/^-+|-+$/g, "") || "sec"
216
- }`;
217
- if (opts.toc && level <= 3) toc.push({ level, text, slug });
218
- const inner = inline(text);
219
-
220
- if (opts.collapse && ansRe.test(line) && level >= 2) {
221
- // Quiz answer: start a <details> block; collect until next heading
222
- const titleInner = inner.replace(
223
- /^(答案与解析|答案解析|答案|解答|解析|Answer|Explanation|Solution)\s*[::]?\s*/i,
224
- "",
225
- );
226
- const buf = [
227
- `<details class="answer"><summary><span class="answer-tag">答案</span>${titleInner}</summary>`,
228
- '<div class="answer-body">',
229
- ];
230
- i++;
231
- while (i < lines.length && !hRe.test(lines[i]) && !/^```/.test(lines[i])) {
232
- const l = lines[i];
233
- if (l.trim() === "") {
234
- buf.push("");
235
- i++;
236
- continue;
237
- }
238
- buf.push(`<p>${inline(l)}</p>`);
239
- i++;
240
- }
241
- buf.push("</div></details>");
242
- body.push(buf.join("\n"));
243
- continue;
244
- }
245
-
246
- body.push(`<h${level} id="${slug}">${inner}</h${level}>`);
247
- i++;
248
- continue;
249
- }
250
-
251
- // Blockquote
252
- if (/^>\s?/.test(line)) {
253
- const buf = [];
254
- while (i < lines.length && /^>\s?/.test(lines[i])) {
255
- buf.push(lines[i].replace(/^>\s?/, ""));
256
- i++;
257
- }
258
- body.push(
259
- `<blockquote>${parse(buf.join("\n"), { ...opts, collapse: false }).html}</blockquote>`,
260
- );
261
- continue;
262
- }
263
-
264
- // List (ul/ol/task)
265
- if (/^\s*([-*+]|\d+\.)\s+/.test(line)) {
266
- const list = { ordered: /^\s*\d+\.\s+/.test(line), items: [] };
267
- const indentRe = /^(\s*)/;
268
- const baseIndent = (line.match(indentRe) || ["", ""])[1].length;
269
- const isTaskRe = /^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.*)$/;
270
-
271
- while (i < lines.length && /^\s*([-*+]|\d+\.)\s+/.test(lines[i])) {
272
- const cur = lines[i];
273
- const indent = (cur.match(indentRe) || ["", ""])[1].length;
274
- if (indent > baseIndent) {
275
- /* nested list: keep simple, treat as continuation */
276
- }
277
- const tm = cur.match(isTaskRe);
278
- if (tm) {
279
- list.items.push({ task: tm[3].toLowerCase() === "x", html: inline(tm[4]) });
280
- } else {
281
- const content = cur.replace(/^\s*([-*+]|\d+\.)\s+/, "");
282
- list.items.push({ task: null, html: inline(content) });
283
- }
284
- i++;
285
- }
286
- flushList(list);
287
- continue;
288
- }
289
-
290
- // Table
291
- if (line.includes("|") && i + 1 < lines.length && isTableSep(splitRow(lines[i + 1]))) {
292
- const header = splitRow(lines[i]);
293
- i += 2;
294
- const rows = [];
295
- while (i < lines.length && lines[i].includes("|") && lines[i].trim() !== "") {
296
- rows.push(splitRow(lines[i]));
297
- i++;
298
- }
299
- let html = `<div class="table-wrap"><table><thead><tr>${header.map((c) => `<th>${inline(c)}</th>`).join("")}</tr></thead><tbody>`;
300
- for (const r of rows) html += `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`;
301
- html += "</tbody></table></div>";
302
- body.push(html);
303
- continue;
304
- }
305
-
306
- // Paragraph (collect consecutive lines)
307
- const buf = [line];
308
- i++;
309
- while (
310
- i < lines.length &&
311
- lines[i].trim() !== "" &&
312
- !hRe.test(lines[i]) &&
313
- !/^```/.test(lines[i]) &&
314
- !/^\s*([-*+]|\d+\.)\s+/.test(lines[i]) &&
315
- !/^>\s?/.test(lines[i]) &&
316
- !/^\s*(---|\*\*\*|___)\s*$/.test(lines[i])
317
- ) {
318
- buf.push(lines[i]);
319
- i++;
320
- }
321
- body.push(`<p>${inline(buf.join(" "))}</p>`);
322
- }
323
-
324
- return { html: body.join("\n"), toc };
325
- }
326
-
327
- // ---------------------------------------------------------------------------
328
- // Main
329
- // ---------------------------------------------------------------------------
330
- function main() {
331
- const { opts, positional } = parseArgs(process.argv.slice(2));
332
- const inputFile = positional[0] || null;
333
-
334
- let md, baseName;
335
- if (inputFile) {
336
- md = fs.readFileSync(inputFile, "utf8");
337
- baseName = path.basename(inputFile, path.extname(inputFile));
338
- } else {
339
- md = fs.readFileSync(0, "utf8"); // stdin
340
- baseName = "document";
341
- }
342
-
343
- const title = opts.title || baseName;
344
- const { html, toc } = parse(md, opts);
345
-
346
- const tocHtml =
347
- opts.toc && toc.length
348
- ? `<nav class="toc"><h2>目录</h2><ul>${toc.map((t) => `<li class="toc-l${t.level}"><a href="#${t.slug}">${esc(t.text)}</a></li>`).join("")}</ul></nav>`
349
- : "";
350
-
351
- const generated = new Date().toISOString().slice(0, 10);
352
- // The same converter ships in two locations (skills/html-doc/scripts/ and a
353
- // package-root scripts/ copy); resolve the template wherever we are.
354
- const templateCandidates = [
355
- path.join(import.meta.dirname, "..", "assets", "template.html"),
356
- path.join(import.meta.dirname, "..", "skills", "html-doc", "assets", "template.html"),
357
- ];
358
- const templatePath = templateCandidates.find((p) => fs.existsSync(p));
359
- if (!templatePath) {
360
- fail(`template.html not found (looked in: ${templateCandidates.join(", ")})`);
361
- }
362
- let template = fs.readFileSync(templatePath, "utf8");
363
- template = template
364
- .replace(/\{\{TITLE\}\}/g, esc(title))
365
- .replace(/\{\{BODY\}\}/g, html)
366
- .replace(/\{\{TOC\}\}/g, tocHtml)
367
- .replace(/\{\{THEME\}\}/g, opts.theme)
368
- .replace(/\{\{LANG\}\}/g, opts.lang)
369
- .replace(/\{\{GENERATED\}\}/g, generated);
370
-
371
- if (opts.out) {
372
- fs.writeFileSync(opts.out, template, "utf8");
373
- console.log(
374
- `[html-doc] wrote ${path.resolve(opts.out)} (${Buffer.byteLength(template)} bytes)`,
375
- );
376
- } else {
377
- process.stdout.write(template);
378
- }
379
- }
380
-
381
- main();
1
+ #!/usr/bin/env node
2
+ /**
3
+ * md-to-html.js — zero-dependency Markdown → self-contained HTML converter.
4
+ *
5
+ * Usage:
6
+ * node md-to-html.js <input.md> [--out out.html] [--title "T"] [--theme dark|light]
7
+ * [--toc] [--lang zh-CN] [--no-collapse]
8
+ * cat file.md | node md-to-html.js --title "T" --out out.html
9
+ *
10
+ * Features: headings, fenced code blocks, inline code/bold/italic/links, lists,
11
+ * task lists, blockquotes, pipe tables, hr, quiz answer collapsing (<details>),
12
+ * raw HTML passthrough, HTML escaping. Zero npm dependencies (Node >= 20.11 — uses import.meta.dirname).
13
+ */
14
+ "use strict";
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // CLI arg parsing
21
+ // ---------------------------------------------------------------------------
22
+ function parseArgs(argv) {
23
+ const opts = {
24
+ out: null,
25
+ title: null,
26
+ theme: "light",
27
+ toc: false,
28
+ lang: "zh-CN",
29
+ collapse: true,
30
+ };
31
+ const positional = [];
32
+ const valueFlags = new Set(["--out", "--title", "--theme", "--lang"]);
33
+ for (let i = 0; i < argv.length; i++) {
34
+ const a = argv[i];
35
+ if (a === "--toc") opts.toc = true;
36
+ else if (a === "--no-collapse") opts.collapse = false;
37
+ else if (a === "--help" || a === "-h") {
38
+ printHelp();
39
+ process.exit(0);
40
+ } else if (valueFlags.has(a)) {
41
+ const value = argv[i + 1];
42
+ if (value === undefined || value.startsWith("--")) {
43
+ fail(`Missing value for ${a} (e.g. --out out.html)`);
44
+ }
45
+ i++;
46
+ if (a === "--out") opts.out = value;
47
+ else if (a === "--title") opts.title = value;
48
+ else if (a === "--theme") {
49
+ if (value !== "dark" && value !== "light") {
50
+ fail(`Invalid --theme value "${value}": expected dark or light`);
51
+ }
52
+ opts.theme = value;
53
+ } else if (a === "--lang") opts.lang = value;
54
+ } else if (a.startsWith("--")) {
55
+ fail(`Unknown option: ${a} (run with --help for usage)`);
56
+ } else positional.push(a);
57
+ }
58
+ return { opts, positional };
59
+ }
60
+
61
+ /** Print an error to stderr and exit non-zero (CLI contract: never silently swallow bad flags). */
62
+ function fail(message) {
63
+ console.error(`[html-doc] ${message}`);
64
+ process.exit(1);
65
+ }
66
+
67
+ function printHelp() {
68
+ console.log(`md-to-html.js — Markdown → self-contained HTML
69
+
70
+ Usage:
71
+ node md-to-html.js <input.md> [options]
72
+ cat file.md | node md-to-html.js [options]
73
+
74
+ Options:
75
+ --out <file> output path (default: <input>.html, or stdout when stdin)
76
+ --title <text> page title (default: input filename or "Document")
77
+ --theme <mode> dark | light (default: light; toggle also available in page)
78
+ --toc generate a table of contents from h2/h3 headings
79
+ --lang <code> html lang attribute (default: zh-CN)
80
+ --no-collapse disable quiz-answer collapsing
81
+ --help show this help`);
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Helpers
86
+ // ---------------------------------------------------------------------------
87
+ const esc = (s) =>
88
+ s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
89
+
90
+ /** Inline formatting: `code`, **bold**, *italic*, [text](url), ~~strike~~ */
91
+ function inline(text) {
92
+ // Protect already-raw-HTML spans? We escape everything except explicit HTML
93
+ // blocks, so inline HTML is escaped (safe default). Code spans first:
94
+ let out = "";
95
+ const parts = text.split(/(`[^`\n]+`)/g);
96
+ for (const p of parts) {
97
+ if (p.startsWith("`") && p.endsWith("`") && p.length > 1) {
98
+ out += `<code>${esc(p.slice(1, -1))}</code>`;
99
+ } else {
100
+ out += esc(p)
101
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
102
+ .replace(/~~([^~]+)~~/g, "<del>$1</del>")
103
+ .replace(/\*([^*\n]+)\*/g, "<em>$1</em>")
104
+ .replace(
105
+ /\[([^\]]+)\]\((https?:[^)\s]+)\)/g,
106
+ '<a href="$2" target="_blank" rel="noopener">$1</a>',
107
+ );
108
+ }
109
+ }
110
+ return out;
111
+ }
112
+
113
+ /** Split a pipe-table row into cells, honoring escaped pipes. */
114
+ function splitRow(line) {
115
+ const l = line.trim().replace(/^\||\|$/g, "");
116
+ return l.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, "|"));
117
+ }
118
+
119
+ const isTableSep = (cells) =>
120
+ cells.length > 0 && cells.every((c) => /^:?-{2,}:?$/.test(c.replace(/\s/g, "")));
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Block parser
124
+ // ---------------------------------------------------------------------------
125
+ function parse(md, opts) {
126
+ const lines = md.replace(/\r\n/g, "\n").split("\n");
127
+ const body = [];
128
+ const toc = [];
129
+ let i = 0;
130
+
131
+ // Heading regex incl. optional trailing closing #s
132
+ const hRe = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
133
+ // Quiz-answer heading (collapsed <details>)
134
+ const ansRe = /^(#{1,6})\s+(.*(?:答案|解答|解析|Answer|Explanation|Solution).*)$/i;
135
+
136
+ const flushList = (list) => {
137
+ const tag = list.ordered ? "ol" : "ul";
138
+ let html = `<${tag}>\n`;
139
+ for (const item of list.items) {
140
+ html +=
141
+ item.task !== null
142
+ ? `<li class="task${item.task ? " done" : ""}"><input type="checkbox"${item.task ? " checked" : ""} disabled> ${item.html}</li>\n`
143
+ : `<li>${item.html}</li>\n`;
144
+ }
145
+ html += `</${tag}>`;
146
+ body.push(html);
147
+ };
148
+
149
+ while (i < lines.length) {
150
+ const line = lines[i];
151
+
152
+ // Fenced code block
153
+ if (/^```/.test(line)) {
154
+ const lang = (line.match(/^```\s*([^\s]+)?/) || [])[1] || "";
155
+ const buf = [];
156
+ i++;
157
+ while (i < lines.length && !/^```/.test(lines[i])) {
158
+ buf.push(lines[i]);
159
+ i++;
160
+ }
161
+ i++; // closing fence
162
+ body.push(
163
+ `<pre class="code-block${lang ? " has-lang" : ""}"><span class="lang-badge">${esc(lang)}</span><code>${esc(buf.join("\n"))}</code></pre>`,
164
+ );
165
+ continue;
166
+ }
167
+
168
+ // Raw HTML block: only the quiz-answer <details>/<summary> convention passes through
169
+ // unescaped. Kept narrow on purpose — deeptutor_run feeds model/web_search-sourced
170
+ // answers into this converter automatically, so a wider tag whitelist (div/table/p/...)
171
+ // would let untrusted <script>/on*= content ride along into the generated local HTML.
172
+ if (/^<(details|summary)\b/i.test(line.trim())) {
173
+ const tag = /^<details\b/i.test(line.trim()) ? "details" : "summary";
174
+ const closeRe = new RegExp(`</${tag}>`, "i");
175
+ const buf = [line];
176
+ i++;
177
+ // Collect until the matching closing tag, not the first blank line/heading — the body
178
+ // of a <details> block is free-form text and commonly contains blank lines.
179
+ if (!closeRe.test(line)) {
180
+ while (i < lines.length && !closeRe.test(lines[i])) {
181
+ buf.push(lines[i]);
182
+ i++;
183
+ }
184
+ if (i < lines.length) {
185
+ buf.push(lines[i]);
186
+ i++;
187
+ }
188
+ }
189
+ body.push(buf.join("\n"));
190
+ continue;
191
+ }
192
+
193
+ // Blank line
194
+ if (line.trim() === "") {
195
+ i++;
196
+ continue;
197
+ }
198
+
199
+ // Horizontal rule
200
+ if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) {
201
+ body.push("<hr>");
202
+ i++;
203
+ continue;
204
+ }
205
+
206
+ // Heading
207
+ if (hRe.test(line)) {
208
+ const m = line.match(hRe);
209
+ const level = m[1].length;
210
+ const text = m[2].trim();
211
+ const slug = `h-${body.length}-${
212
+ text
213
+ .toLowerCase()
214
+ .replace(/[^\w\u4e00-\u9fa5]+/g, "-")
215
+ .replace(/^-+|-+$/g, "") || "sec"
216
+ }`;
217
+ if (opts.toc && level <= 3) toc.push({ level, text, slug });
218
+ const inner = inline(text);
219
+
220
+ if (opts.collapse && ansRe.test(line) && level >= 2) {
221
+ // Quiz answer: start a <details> block; collect until next heading
222
+ const titleInner = inner.replace(
223
+ /^(答案与解析|答案解析|答案|解答|解析|Answer|Explanation|Solution)\s*[::]?\s*/i,
224
+ "",
225
+ );
226
+ const buf = [
227
+ `<details class="answer"><summary><span class="answer-tag">答案</span>${titleInner}</summary>`,
228
+ '<div class="answer-body">',
229
+ ];
230
+ i++;
231
+ while (i < lines.length && !hRe.test(lines[i]) && !/^```/.test(lines[i])) {
232
+ const l = lines[i];
233
+ if (l.trim() === "") {
234
+ buf.push("");
235
+ i++;
236
+ continue;
237
+ }
238
+ buf.push(`<p>${inline(l)}</p>`);
239
+ i++;
240
+ }
241
+ buf.push("</div></details>");
242
+ body.push(buf.join("\n"));
243
+ continue;
244
+ }
245
+
246
+ body.push(`<h${level} id="${slug}">${inner}</h${level}>`);
247
+ i++;
248
+ continue;
249
+ }
250
+
251
+ // Blockquote
252
+ if (/^>\s?/.test(line)) {
253
+ const buf = [];
254
+ while (i < lines.length && /^>\s?/.test(lines[i])) {
255
+ buf.push(lines[i].replace(/^>\s?/, ""));
256
+ i++;
257
+ }
258
+ body.push(
259
+ `<blockquote>${parse(buf.join("\n"), { ...opts, collapse: false }).html}</blockquote>`,
260
+ );
261
+ continue;
262
+ }
263
+
264
+ // List (ul/ol/task)
265
+ if (/^\s*([-*+]|\d+\.)\s+/.test(line)) {
266
+ const list = { ordered: /^\s*\d+\.\s+/.test(line), items: [] };
267
+ const indentRe = /^(\s*)/;
268
+ const baseIndent = (line.match(indentRe) || ["", ""])[1].length;
269
+ const isTaskRe = /^(\s*)([-*+]|\d+\.)\s+\[([ xX])\]\s+(.*)$/;
270
+
271
+ while (i < lines.length && /^\s*([-*+]|\d+\.)\s+/.test(lines[i])) {
272
+ const cur = lines[i];
273
+ const indent = (cur.match(indentRe) || ["", ""])[1].length;
274
+ if (indent > baseIndent) {
275
+ /* nested list: keep simple, treat as continuation */
276
+ }
277
+ const tm = cur.match(isTaskRe);
278
+ if (tm) {
279
+ list.items.push({ task: tm[3].toLowerCase() === "x", html: inline(tm[4]) });
280
+ } else {
281
+ const content = cur.replace(/^\s*([-*+]|\d+\.)\s+/, "");
282
+ list.items.push({ task: null, html: inline(content) });
283
+ }
284
+ i++;
285
+ }
286
+ flushList(list);
287
+ continue;
288
+ }
289
+
290
+ // Table
291
+ if (line.includes("|") && i + 1 < lines.length && isTableSep(splitRow(lines[i + 1]))) {
292
+ const header = splitRow(lines[i]);
293
+ i += 2;
294
+ const rows = [];
295
+ while (i < lines.length && lines[i].includes("|") && lines[i].trim() !== "") {
296
+ rows.push(splitRow(lines[i]));
297
+ i++;
298
+ }
299
+ let html = `<div class="table-wrap"><table><thead><tr>${header.map((c) => `<th>${inline(c)}</th>`).join("")}</tr></thead><tbody>`;
300
+ for (const r of rows) html += `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`;
301
+ html += "</tbody></table></div>";
302
+ body.push(html);
303
+ continue;
304
+ }
305
+
306
+ // Paragraph (collect consecutive lines)
307
+ const buf = [line];
308
+ i++;
309
+ while (
310
+ i < lines.length &&
311
+ lines[i].trim() !== "" &&
312
+ !hRe.test(lines[i]) &&
313
+ !/^```/.test(lines[i]) &&
314
+ !/^\s*([-*+]|\d+\.)\s+/.test(lines[i]) &&
315
+ !/^>\s?/.test(lines[i]) &&
316
+ !/^\s*(---|\*\*\*|___)\s*$/.test(lines[i])
317
+ ) {
318
+ buf.push(lines[i]);
319
+ i++;
320
+ }
321
+ body.push(`<p>${inline(buf.join(" "))}</p>`);
322
+ }
323
+
324
+ return { html: body.join("\n"), toc };
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Main
329
+ // ---------------------------------------------------------------------------
330
+ function main() {
331
+ const { opts, positional } = parseArgs(process.argv.slice(2));
332
+ const inputFile = positional[0] || null;
333
+
334
+ let md, baseName;
335
+ if (inputFile) {
336
+ md = fs.readFileSync(inputFile, "utf8");
337
+ baseName = path.basename(inputFile, path.extname(inputFile));
338
+ } else {
339
+ md = fs.readFileSync(0, "utf8"); // stdin
340
+ baseName = "document";
341
+ }
342
+
343
+ const title = opts.title || baseName;
344
+ const { html, toc } = parse(md, opts);
345
+
346
+ const tocHtml =
347
+ opts.toc && toc.length
348
+ ? `<nav class="toc"><h2>目录</h2><ul>${toc.map((t) => `<li class="toc-l${t.level}"><a href="#${t.slug}">${esc(t.text)}</a></li>`).join("")}</ul></nav>`
349
+ : "";
350
+
351
+ const generated = new Date().toISOString().slice(0, 10);
352
+ // The same converter ships in two locations (skills/html-doc/scripts/ and a
353
+ // package-root scripts/ copy); resolve the template wherever we are.
354
+ const templateCandidates = [
355
+ path.join(import.meta.dirname, "..", "assets", "template.html"),
356
+ path.join(import.meta.dirname, "..", "skills", "html-doc", "assets", "template.html"),
357
+ ];
358
+ const templatePath = templateCandidates.find((p) => fs.existsSync(p));
359
+ if (!templatePath) {
360
+ fail(`template.html not found (looked in: ${templateCandidates.join(", ")})`);
361
+ }
362
+ let template = fs.readFileSync(templatePath, "utf8");
363
+ template = template
364
+ .replace(/\{\{TITLE\}\}/g, esc(title))
365
+ .replace(/\{\{BODY\}\}/g, html)
366
+ .replace(/\{\{TOC\}\}/g, tocHtml)
367
+ .replace(/\{\{THEME\}\}/g, opts.theme)
368
+ .replace(/\{\{LANG\}\}/g, opts.lang)
369
+ .replace(/\{\{GENERATED\}\}/g, generated);
370
+
371
+ if (opts.out) {
372
+ fs.writeFileSync(opts.out, template, "utf8");
373
+ console.log(
374
+ `[html-doc] wrote ${path.resolve(opts.out)} (${Buffer.byteLength(template)} bytes)`,
375
+ );
376
+ } else {
377
+ process.stdout.write(template);
378
+ }
379
+ }
380
+
381
+ main();