dsh-deeptutor 0.1.3 → 0.1.5

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