dsh-deeptutor 0.1.2 → 0.1.4
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/README.md +169 -136
- package/README.zh.md +125 -107
- package/cordis.patch.yml +17 -17
- package/package.json +60 -56
- package/scripts/install-profile.mjs +198 -0
- package/scripts/md-to-html.js +353 -353
- package/src/cli-exec.ts +251 -251
- package/src/config.ts +70 -70
- package/src/html-render.ts +53 -53
- package/src/http-api.ts +83 -83
- package/src/index.ts +449 -449
- package/src/turn.ts +174 -174
package/scripts/md-to-html.js
CHANGED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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();
|