adspecs 0.1.19 → 0.1.21

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.
Files changed (74) hide show
  1. package/.adspecs/feature.json +16 -16
  2. package/.adspecs/feature.yml +28 -28
  3. package/.adspecs/paths.json +17 -17
  4. package/.adspecs/templates/04-/345/211/215/347/253/257/345/212/237/350/203/275/350/256/276/350/256/241/346/250/241/346/235/277.md +1 -1
  5. package/.adspecs/templates/05-/345/220/216/347/253/257/344/273/273/345/212/241/346/270/205/345/215/225/346/250/241/346/235/277.md +613 -724
  6. package/.adspecs/templates/05b-/345/211/215/347/253/257/344/273/273/345/212/241/346/270/205/345/215/225/346/250/241/346/235/277.md +51 -51
  7. package/.claude-plugin/marketplace.json +23 -23
  8. package/.claude-plugin/plugin.json +18 -18
  9. package/.qoder-plugin/plugin.json +31 -31
  10. package/CLAUDE.md +1 -5
  11. package/INSTALL.md +3 -3
  12. package/README.md +395 -395
  13. package/bin/adspecs.js +129 -129
  14. package/hooks/commit-queue.js +245 -245
  15. package/hooks/hooks.json +63 -63
  16. package/hooks/session-start.js +44 -44
  17. package/hooks/wiki-queue.js +127 -127
  18. package/package.json +61 -61
  19. package/references/ant6-front-standard/index.md +99 -99
  20. package/references/antd-front-demo/public/mockServiceWorker.js +361 -361
  21. package/references/ecp-end-standard/index.md +63 -63
  22. package/references/python-end-standard/01-Python/345/220/216/347/253/257/347/274/226/347/240/201/350/247/204/350/214/203.md +372 -372
  23. package/references/python-end-standard/02-/346/225/260/346/215/256/345/272/223/350/256/276/350/256/241/344/270/216/344/275/277/347/224/250/350/247/204/350/214/203.md +226 -226
  24. package/references/python-end-standard/03-Celery/345/274/202/346/255/245/344/273/273/345/212/241/350/247/204/350/214/203.md +237 -237
  25. package/references/python-end-standard/04-Redis/344/275/277/347/224/250/350/247/204/350/214/203.md +231 -231
  26. package/scripts/postinstall.js +107 -107
  27. package/scripts/sync-version.js +105 -105
  28. package/skills/.claude/.wiki-update-queue +26 -26
  29. package/skills/adspecs-constitution/SKILL.md +157 -0
  30. package/skills/adspecs-export-word/SKILL.md +498 -498
  31. package/skills/adspecs-export-word/references/md-to-docx.js +862 -862
  32. package/skills/adspecs-export-word/references/package-lock.json +220 -220
  33. package/skills/adspecs-export-word/references/package.json +10 -10
  34. package/skills/adspecs-front-prototype/SKILL.md +405 -405
  35. package/skills/adspecs-front-spec/SKILL.md +4 -4
  36. package/skills/adspecs-front-tasks/SKILL.md +213 -173
  37. package/skills/adspecs-plan/SKILL.md +59 -69
  38. package/skills/adspecs-prd/SKILL.md +13 -5
  39. package/skills/adspecs-prd-to-demo/SKILL.md +532 -0
  40. package/skills/adspecs-tasks/SKILL.md +175 -204
  41. package/skills/adspecs-update-status/SKILL.md +382 -382
  42. package/skills/adspecs-utest/SKILL.md +107 -116
  43. package/skills/grill-me/SKILL.md +7 -0
  44. package/skills/grill-me/agents/openai.yaml +5 -0
  45. package/skills/playwright-cli/SKILL.md +420 -0
  46. package/skills/playwright-cli/references/element-attributes.md +23 -0
  47. package/skills/playwright-cli/references/playwright-tests.md +39 -0
  48. package/skills/playwright-cli/references/request-mocking.md +87 -0
  49. package/skills/playwright-cli/references/running-code.md +241 -0
  50. package/skills/playwright-cli/references/session-management.md +225 -0
  51. package/skills/playwright-cli/references/storage-state.md +275 -0
  52. package/skills/playwright-cli/references/test-generation.md +433 -0
  53. package/skills/playwright-cli/references/tracing.md +139 -0
  54. package/skills/playwright-cli/references/video-recording.md +143 -0
  55. package/skills/playwright-trace/SKILL.md +171 -0
  56. package/skills/project-init/SKILL.md +93 -22
  57. package/skills/project-init/references/front-demo/.claude/settings.local.json +9 -0
  58. package/skills/wiki-update/SKILL.md +232 -232
  59. package/src/commands/doctor.js +197 -197
  60. package/src/commands/init.js +83 -83
  61. package/src/commands/plugin.js +165 -165
  62. package/src/commands/update.js +87 -87
  63. package/src/lib/area-scanner.js +129 -129
  64. package/src/lib/copier.js +104 -104
  65. package/src/lib/dir-utils.js +161 -133
  66. package/src/lib/json-merge.js +114 -114
  67. package/src/lib/paths-defaults.js +37 -37
  68. package/src/lib/prompts.js +428 -347
  69. package/src/lib/readme-gen.js +143 -143
  70. package/src/lib/report.js +338 -327
  71. package/src/lib/scaffolder.js +551 -518
  72. package/src/lib/short-name.js +36 -36
  73. package/src/utils.js +80 -80
  74. package/references/antd-front-demo/.env +0 -15
@@ -1,862 +1,862 @@
1
- #!/usr/bin/env node
2
- /**
3
- * md-to-docx.js — Markdown to Word (.docx) converter
4
- *
5
- * Features:
6
- * - Auto-generated cover page (company logo, project info, document metadata)
7
- * - Table of contents (Heading 1-3)
8
- * - Full Markdown element conversion (headings, tables, code blocks, lists, images, blockquotes)
9
- *
10
- * Usage:
11
- * node md-to-docx.js --input <file> --output <file> --meta '<json>'
12
- * node md-to-docx.js --dir <dir> --output <dir> --meta '<json>' [--recursive]
13
- * [--no-cover] [--no-toc]
14
- */
15
-
16
- const fs = require("fs");
17
- const path = require("path");
18
- const {
19
- Document,
20
- Packer,
21
- Paragraph,
22
- TextRun,
23
- HeadingLevel,
24
- AlignmentType,
25
- TableOfContents,
26
- StyleLevel,
27
- Table,
28
- TableRow,
29
- TableCell,
30
- WidthType,
31
- BorderStyle,
32
- ImageRun,
33
- PageBreak,
34
- ShadingType,
35
- } = require("docx");
36
- const { marked } = require("marked");
37
-
38
- // ── Constants ──────────────────────────────────────────────────────────
39
-
40
- const DARK_BLUE = "1F4E79";
41
- const GRAY = "808080";
42
- const LIGHT_GRAY = "F2F2F2";
43
-
44
- const FONT_BODY = "DengXian";
45
- const FONT_HEADING = "Microsoft YaHei";
46
- const FONT_CODE = "Consolas";
47
-
48
- const HEADING_STYLES = {
49
- 1: { heading: HeadingLevel.HEADING_1, size: 44, font: FONT_HEADING },
50
- 2: { heading: HeadingLevel.HEADING_2, size: 32, font: FONT_HEADING },
51
- 3: { heading: HeadingLevel.HEADING_3, size: 28, font: FONT_HEADING },
52
- 4: { heading: HeadingLevel.HEADING_4, size: 24, font: FONT_HEADING },
53
- 5: { heading: HeadingLevel.HEADING_5, size: 22, font: FONT_HEADING },
54
- 6: { heading: HeadingLevel.HEADING_6, size: 20, font: FONT_HEADING },
55
- };
56
-
57
- // ── CLI Arguments ──────────────────────────────────────────────────────
58
-
59
- function parseArgs() {
60
- const args = process.argv.slice(2);
61
- const result = {
62
- input: null,
63
- dir: null,
64
- output: null,
65
- meta: "{}",
66
- noCover: false,
67
- noToc: false,
68
- recursive: false,
69
- };
70
- for (let i = 0; i < args.length; i++) {
71
- switch (args[i]) {
72
- case "--input":
73
- result.input = args[++i];
74
- break;
75
- case "--dir":
76
- result.dir = args[++i];
77
- break;
78
- case "--output":
79
- result.output = args[++i];
80
- break;
81
- case "--meta":
82
- result.meta = args[++i];
83
- break;
84
- case "--no-cover":
85
- result.noCover = true;
86
- break;
87
- case "--no-toc":
88
- result.noToc = true;
89
- break;
90
- case "--recursive":
91
- result.recursive = true;
92
- break;
93
- }
94
- }
95
- return result;
96
- }
97
-
98
- // ── Markdown Reader ────────────────────────────────────────────────────
99
-
100
- function readMarkdown(filePath) {
101
- const content = fs.readFileSync(filePath, "utf-8");
102
-
103
- // Try YAML frontmatter
104
- const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
105
- let frontmatter = {};
106
- let body = content;
107
-
108
- if (fmMatch) {
109
- for (const line of fmMatch[1].split("\n")) {
110
- const m = line.match(/^(\S+):\s*(.*)$/);
111
- if (m) frontmatter[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
112
- }
113
- body = fmMatch[2];
114
- } else {
115
- // Try blockquote metadata: > **版本**: v1.0
116
- const metaLines = [];
117
- const restLines = [];
118
- let inMeta = true;
119
- for (const line of content.split("\n")) {
120
- if (inMeta && line.startsWith(">")) {
121
- metaLines.push(line);
122
- } else {
123
- inMeta = false;
124
- restLines.push(line);
125
- }
126
- }
127
- body = restLines.join("\n");
128
- for (const line of metaLines) {
129
- const m = line.match(/\*\*(.+?)\*\*[::]\s*(.+)/);
130
- if (m) frontmatter[m[1].trim()] = m[2].trim();
131
- }
132
- }
133
-
134
- return { body, frontmatter };
135
- }
136
-
137
- // ── Inline Token Converter ─────────────────────────────────────────────
138
-
139
- function convertInline(tokens, overrides = {}) {
140
- if (!tokens || !Array.isArray(tokens)) return [];
141
- const runs = [];
142
- const baseFont = overrides.font || FONT_BODY;
143
- const baseSize = overrides.size || 24;
144
-
145
- for (const token of tokens) {
146
- switch (token.type) {
147
- case "text": {
148
- const children = token.tokens ? convertInline(token.tokens, overrides) : [];
149
- if (children.length > 0) {
150
- runs.push(...children);
151
- } else {
152
- runs.push(new TextRun({ text: token.text || "", font: baseFont, size: baseSize, ...overrides }));
153
- }
154
- break;
155
- }
156
- case "strong":
157
- runs.push(...convertInline(token.tokens, { ...overrides, bold: true }));
158
- break;
159
- case "em":
160
- runs.push(...convertInline(token.tokens, { ...overrides, italics: true }));
161
- break;
162
- case "del":
163
- runs.push(...convertInline(token.tokens, { ...overrides, strike: true }));
164
- break;
165
- case "codespan":
166
- runs.push(
167
- new TextRun({ text: token.text || "", font: FONT_CODE, size: Math.min(baseSize, 20), ...overrides })
168
- );
169
- break;
170
- case "link":
171
- runs.push(
172
- new TextRun({
173
- text: token.text || (token.tokens ? token.tokens.map(t => t.text || t.raw || "").join("") : ""),
174
- font: baseFont,
175
- size: baseSize,
176
- color: "0563C1",
177
- underline: { type: "single" },
178
- ...overrides,
179
- })
180
- );
181
- break;
182
- case "image":
183
- try {
184
- const imgPath = token.href;
185
- if (imgPath && fs.existsSync(imgPath)) {
186
- const buf = fs.readFileSync(imgPath);
187
- runs.push(
188
- new ImageRun({
189
- data: buf,
190
- transformation: { width: 200, height: 150 },
191
- altText: { title: token.text || "Image", description: token.text || "Image", name: "image" },
192
- })
193
- );
194
- } else {
195
- runs.push(new TextRun({ text: `[Image: ${token.text || token.href}]`, font: baseFont, size: baseSize, italics: true, color: GRAY }));
196
- }
197
- } catch {
198
- runs.push(new TextRun({ text: `[Image: ${token.text || token.href}]`, font: baseFont, size: baseSize, italics: true, color: GRAY }));
199
- }
200
- break;
201
- case "br":
202
- runs.push(new TextRun({ text: "", break: 1, font: baseFont, size: baseSize }));
203
- break;
204
- default:
205
- if (token.text || token.raw) {
206
- runs.push(new TextRun({ text: token.text || token.raw || "", font: baseFont, size: baseSize, ...overrides }));
207
- }
208
- }
209
- }
210
-
211
- return runs;
212
- }
213
-
214
- // ── Cover Page Builder ─────────────────────────────────────────────────
215
-
216
- function buildCoverPage(meta) {
217
- const blocks = [];
218
- const spacer = (n) => {
219
- for (let i = 0; i < n; i++) blocks.push(new Paragraph({ children: [new TextRun("")] }));
220
- };
221
-
222
- // 1. Top spacing
223
- spacer(3);
224
-
225
- // 2. Company logo
226
- if (meta.company_logo && fs.existsSync(meta.company_logo)) {
227
- try {
228
- const logoBuf = fs.readFileSync(meta.company_logo);
229
- blocks.push(
230
- new Paragraph({
231
- alignment: AlignmentType.CENTER,
232
- children: [
233
- new ImageRun({
234
- data: logoBuf,
235
- transformation: { width: 200, height: 200 },
236
- altText: { title: "Company Logo", description: "Company Logo", name: "logo" },
237
- }),
238
- ],
239
- })
240
- );
241
- spacer(2);
242
- } catch {
243
- // Skip logo on error
244
- }
245
- }
246
-
247
- // 3. Company name
248
- if (meta.company_name) {
249
- blocks.push(
250
- new Paragraph({
251
- alignment: AlignmentType.CENTER,
252
- children: [new TextRun({ text: meta.company_name, bold: true, font: FONT_HEADING, size: 32, color: DARK_BLUE })],
253
- })
254
- );
255
- }
256
-
257
- spacer(1);
258
-
259
- // 4. Project name
260
- if (meta.project_name) {
261
- blocks.push(
262
- new Paragraph({
263
- alignment: AlignmentType.CENTER,
264
- children: [new TextRun({ text: meta.project_name, bold: true, font: FONT_HEADING, size: 36, color: DARK_BLUE })],
265
- })
266
- );
267
- }
268
-
269
- spacer(2);
270
-
271
- // 5. Document title
272
- if (meta.title) {
273
- blocks.push(
274
- new Paragraph({
275
- alignment: AlignmentType.CENTER,
276
- children: [new TextRun({ text: meta.title, bold: true, font: FONT_HEADING, size: 56, color: DARK_BLUE })],
277
- })
278
- );
279
- }
280
-
281
- // 6. Subtitle
282
- if (meta.subtitle) {
283
- blocks.push(
284
- new Paragraph({
285
- alignment: AlignmentType.CENTER,
286
- spacing: { before: 200 },
287
- children: [new TextRun({ text: meta.subtitle, font: FONT_HEADING, size: 28, color: GRAY })],
288
- })
289
- );
290
- }
291
-
292
- spacer(3);
293
-
294
- // 7. Info table
295
- const fields = [
296
- ["文档编号", meta.document_number],
297
- ["版本号", meta.version],
298
- ["创建日期", meta.date],
299
- ["作者", meta.author],
300
- ["审核人", meta.reviewer],
301
- ["批准人", meta.approver],
302
- ["保密等级", meta.confidentiality],
303
- ].filter(([, v]) => v);
304
-
305
- if (fields.length > 0) {
306
- const borderStyle = { style: BorderStyle.SINGLE, size: 1, color: "BBBBBB" };
307
- const borders = { top: borderStyle, bottom: borderStyle, left: borderStyle, right: borderStyle };
308
-
309
- const rows = fields.map(
310
- ([label, value]) =>
311
- new TableRow({
312
- children: [
313
- new TableCell({
314
- borders,
315
- width: { size: 3000, type: WidthType.DXA },
316
- children: [
317
- new Paragraph({
318
- alignment: AlignmentType.CENTER,
319
- children: [new TextRun({ text: label, bold: true, font: FONT_HEADING, size: 22 })],
320
- }),
321
- ],
322
- }),
323
- new TableCell({
324
- borders,
325
- width: { size: 5000, type: WidthType.DXA },
326
- children: [
327
- new Paragraph({
328
- children: [new TextRun({ text: value, font: FONT_BODY, size: 22 })],
329
- }),
330
- ],
331
- }),
332
- ],
333
- })
334
- );
335
-
336
- blocks.push(
337
- new Table({
338
- rows,
339
- width: { size: 8000, type: WidthType.DXA },
340
- })
341
- );
342
- }
343
-
344
- // 8. Page break
345
- blocks.push(
346
- new Paragraph({
347
- children: [new PageBreak()],
348
- })
349
- );
350
-
351
- return blocks;
352
- }
353
-
354
- // ── TOC Builder ────────────────────────────────────────────────────────
355
-
356
- function buildTOC() {
357
- return [
358
- new Paragraph({
359
- heading: HeadingLevel.HEADING_1,
360
- children: [new TextRun({ text: "目录", font: FONT_HEADING, size: 44, bold: true, color: DARK_BLUE })],
361
- }),
362
- new TableOfContents("目录", {
363
- hyperlink: true,
364
- headingStyleRange: "1-3",
365
- stylesWithLevels: [
366
- new StyleLevel("Heading1", 1),
367
- new StyleLevel("Heading2", 2),
368
- new StyleLevel("Heading3", 3),
369
- ],
370
- }),
371
- new Paragraph({
372
- children: [new PageBreak()],
373
- }),
374
- ];
375
- }
376
-
377
- // ── Markdown → docx Converter ──────────────────────────────────────────
378
-
379
- function convertTokens(tokens) {
380
- const blocks = [];
381
-
382
- for (const token of tokens) {
383
- switch (token.type) {
384
- case "heading": {
385
- const style = HEADING_STYLES[token.depth] || HEADING_STYLES[6];
386
- const runs = token.tokens
387
- ? convertInline(token.tokens, { font: style.font, size: style.size, bold: true, color: DARK_BLUE })
388
- : [new TextRun({ text: token.text || "", font: style.font, size: style.size, bold: true, color: DARK_BLUE })];
389
- blocks.push(
390
- new Paragraph({
391
- heading: style.heading,
392
- spacing: { before: 300, after: 100 },
393
- children: runs,
394
- })
395
- );
396
- break;
397
- }
398
-
399
- case "paragraph": {
400
- const runs = convertInline(token.tokens);
401
- blocks.push(
402
- new Paragraph({
403
- spacing: { after: 120 },
404
- children: runs,
405
- })
406
- );
407
- break;
408
- }
409
-
410
- case "code": {
411
- const lines = (token.text || "").split("\n");
412
- for (const line of lines) {
413
- blocks.push(
414
- new Paragraph({
415
- spacing: { after: 0 },
416
- indent: { left: 400 },
417
- shading: { type: ShadingType.CLEAR, color: "auto", fill: LIGHT_GRAY },
418
- children: [new TextRun({ text: line || " ", font: FONT_CODE, size: 20 })],
419
- })
420
- );
421
- }
422
- // Add spacing after code block
423
- blocks.push(new Paragraph({ spacing: { after: 120 }, children: [new TextRun("")] }));
424
- break;
425
- }
426
-
427
- case "table": {
428
- const borderStyle = { style: BorderStyle.SINGLE, size: 1, color: "999999" };
429
- const borders = { top: borderStyle, bottom: borderStyle, left: borderStyle, right: borderStyle };
430
-
431
- // Header row
432
- const headerRow = new TableRow({
433
- tableHeader: true,
434
- children: (token.header || []).map(
435
- (h) =>
436
- new TableCell({
437
- borders,
438
- shading: { type: ShadingType.CLEAR, color: "auto", fill: "D9E2F3" },
439
- children: [
440
- new Paragraph({
441
- children: [
442
- new TextRun({
443
- text: h.text || "",
444
- bold: true,
445
- font: FONT_HEADING,
446
- size: 22,
447
- }),
448
- ],
449
- }),
450
- ],
451
- })
452
- ),
453
- });
454
-
455
- // Data rows
456
- const dataRows = (token.rows || []).map(
457
- (row) =>
458
- new TableRow({
459
- children: row.map(
460
- (cell) =>
461
- new TableCell({
462
- borders,
463
- children: [
464
- new Paragraph({
465
- children: cell.tokens
466
- ? convertInline(cell.tokens)
467
- : [new TextRun({ text: cell.text || "", font: FONT_BODY, size: 22 })],
468
- }),
469
- ],
470
- })
471
- ),
472
- })
473
- );
474
-
475
- blocks.push(new Table({ rows: [headerRow, ...dataRows] }));
476
- // Spacing after table
477
- blocks.push(new Paragraph({ spacing: { after: 120 }, children: [new TextRun("")] }));
478
- break;
479
- }
480
-
481
- case "list": {
482
- blocks.push(...convertList(token, 0));
483
- break;
484
- }
485
-
486
- case "blockquote": {
487
- const innerTokens = token.tokens || [];
488
- for (const inner of innerTokens) {
489
- if (inner.type === "paragraph") {
490
- const runs = convertInline(inner.tokens, { italics: true, color: "555555" });
491
- blocks.push(
492
- new Paragraph({
493
- indent: { left: 720 },
494
- spacing: { after: 120 },
495
- border: {
496
- left: { style: BorderStyle.SINGLE, size: 6, color: DARK_BLUE, space: 10 },
497
- },
498
- shading: { type: ShadingType.CLEAR, color: "auto", fill: LIGHT_GRAY },
499
- children: runs,
500
- })
501
- );
502
- } else {
503
- // Recursively handle other block types inside blockquote
504
- const nested = convertTokens([inner]);
505
- blocks.push(...nested);
506
- }
507
- }
508
- break;
509
- }
510
-
511
- case "hr":
512
- blocks.push(
513
- new Paragraph({
514
- spacing: { before: 200, after: 200 },
515
- border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" } },
516
- children: [new TextRun("")],
517
- })
518
- );
519
- break;
520
-
521
- case "html":
522
- // Skip HTML blocks or render as plain text
523
- blocks.push(
524
- new Paragraph({
525
- spacing: { after: 120 },
526
- children: [new TextRun({ text: token.text || "", font: FONT_BODY, size: 24, color: GRAY })],
527
- })
528
- );
529
- break;
530
-
531
- case "space":
532
- break;
533
-
534
- default:
535
- if (token.raw && token.text) {
536
- blocks.push(
537
- new Paragraph({
538
- spacing: { after: 120 },
539
- children: [new TextRun({ text: token.text || token.raw || "", font: FONT_BODY, size: 24 })],
540
- })
541
- );
542
- }
543
- }
544
- }
545
-
546
- return blocks;
547
- }
548
-
549
- // ── List Converter (handles nesting) ───────────────────────────────────
550
-
551
- function convertList(token, level) {
552
- const blocks = [];
553
- const isOrdered = token.ordered;
554
- const items = token.items || [];
555
-
556
- let numIndex = 1;
557
-
558
- for (const item of items) {
559
- const numbering = isOrdered
560
- ? { reference: "numbered-list", level }
561
- : { reference: "bullet-list", level };
562
-
563
- // Process item children (paragraphs, nested lists, code blocks)
564
- const itemChildren = item.tokens || [];
565
-
566
- for (const child of itemChildren) {
567
- if (child.type === "list") {
568
- // Nested list
569
- blocks.push(...convertList(child, level + 1));
570
- } else if (child.type === "text" && !child.tokens) {
571
- // Plain text item
572
- const prefix = item.task ? (item.checked ? "☑ " : "☐ ") : "";
573
- blocks.push(
574
- new Paragraph({
575
- numbering,
576
- spacing: { after: 60 },
577
- children: [new TextRun({ text: prefix + (child.text || child.raw || ""), font: FONT_BODY, size: 24 })],
578
- })
579
- );
580
- } else if (child.type === "text" && child.tokens) {
581
- // Text with inline formatting
582
- const runs = convertInline(child.tokens);
583
- if (item.task) {
584
- const prefix = item.checked ? "☑ " : "☐ ";
585
- runs.unshift(new TextRun({ text: prefix, font: FONT_BODY, size: 24 }));
586
- }
587
- blocks.push(
588
- new Paragraph({
589
- numbering,
590
- spacing: { after: 60 },
591
- children: runs,
592
- })
593
- );
594
- } else if (child.type === "paragraph") {
595
- const runs = convertInline(child.tokens);
596
- if (item.task) {
597
- const prefix = item.checked ? "☑ " : "☐ ";
598
- runs.unshift(new TextRun({ text: prefix, font: FONT_BODY, size: 24 }));
599
- }
600
- blocks.push(
601
- new Paragraph({
602
- numbering,
603
- spacing: { after: 60 },
604
- children: runs,
605
- })
606
- );
607
- } else if (child.type === "code") {
608
- // Code block inside list item
609
- const lines = (child.text || "").split("\n");
610
- for (const line of lines) {
611
- blocks.push(
612
- new Paragraph({
613
- spacing: { after: 0 },
614
- indent: { left: 400 + level * 720 },
615
- shading: { type: ShadingType.CLEAR, color: "auto", fill: LIGHT_GRAY },
616
- children: [new TextRun({ text: line || " ", font: FONT_CODE, size: 20 })],
617
- })
618
- );
619
- }
620
- }
621
- }
622
-
623
- // If no children processed, add a fallback
624
- if (itemChildren.length === 0) {
625
- const prefix = item.task ? (item.checked ? "☑ " : "☐ ") : "";
626
- blocks.push(
627
- new Paragraph({
628
- numbering,
629
- spacing: { after: 60 },
630
- children: [new TextRun({ text: prefix + (item.text || item.raw || ""), font: FONT_BODY, size: 24 })],
631
- })
632
- );
633
- }
634
-
635
- numIndex++;
636
- }
637
-
638
- return blocks;
639
- }
640
-
641
- // ── Document Assembler ─────────────────────────────────────────────────
642
-
643
- function assembleDocument(coverBlocks, tocBlocks, contentBlocks, meta) {
644
- const sections = [];
645
-
646
- // Cover page section (no header/footer)
647
- if (coverBlocks.length > 0) {
648
- sections.push({
649
- properties: {
650
- page: {
651
- size: { width: 11906, height: 16838 },
652
- margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
653
- },
654
- },
655
- children: coverBlocks,
656
- });
657
- }
658
-
659
- // TOC + Content in main section
660
- const mainChildren = [...tocBlocks, ...contentBlocks];
661
-
662
- sections.push({
663
- properties: {
664
- page: {
665
- size: { width: 11906, height: 16838 },
666
- margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
667
- },
668
- },
669
- children: mainChildren,
670
- });
671
-
672
- return new Document({
673
- features: { updateFields: true },
674
- styles: {
675
- default: {
676
- document: {
677
- run: { font: FONT_BODY, size: 24 },
678
- paragraph: { spacing: { line: 360 } },
679
- },
680
- heading1: {
681
- run: { font: FONT_HEADING, size: 44, bold: true, color: DARK_BLUE },
682
- paragraph: { spacing: { before: 400, after: 200 } },
683
- },
684
- heading2: {
685
- run: { font: FONT_HEADING, size: 32, bold: true, color: DARK_BLUE },
686
- paragraph: { spacing: { before: 300, after: 150 } },
687
- },
688
- heading3: {
689
- run: { font: FONT_HEADING, size: 28, bold: true, color: DARK_BLUE },
690
- paragraph: { spacing: { before: 240, after: 120 } },
691
- },
692
- heading4: {
693
- run: { font: FONT_HEADING, size: 24, bold: true, color: DARK_BLUE },
694
- paragraph: { spacing: { before: 200, after: 100 } },
695
- },
696
- },
697
- },
698
- numbering: {
699
- config: [
700
- {
701
- reference: "bullet-list",
702
- levels: [
703
- { level: 0, format: "bullet", text: "•", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 720, hanging: 360 } } } },
704
- { level: 1, format: "bullet", text: "◦", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 1440, hanging: 360 } } } },
705
- { level: 2, format: "bullet", text: "▪", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 2160, hanging: 360 } } } },
706
- { level: 3, format: "bullet", text: "•", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 2880, hanging: 360 } } } },
707
- ],
708
- },
709
- {
710
- reference: "numbered-list",
711
- levels: [
712
- { level: 0, format: "decimal", text: "%1.", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 720, hanging: 360 } } } },
713
- { level: 1, format: "lowerLetter", text: "%2)", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 1440, hanging: 360 } } } },
714
- { level: 2, format: "lowerRoman", text: "%3.", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 2160, hanging: 360 } } } },
715
- { level: 3, format: "decimal", text: "%4.", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 2880, hanging: 360 } } } },
716
- ],
717
- },
718
- ],
719
- },
720
- sections,
721
- });
722
- }
723
-
724
- // ── Directory Scanner ──────────────────────────────────────────────────
725
-
726
- function scanDirectory(dir, recursive) {
727
- const files = [];
728
- const entries = fs.readdirSync(dir, { withFileTypes: true });
729
-
730
- for (const entry of entries) {
731
- const fullPath = path.join(dir, entry.name);
732
-
733
- if (entry.isDirectory()) {
734
- // Skip hidden directories
735
- if (entry.name.startsWith(".")) continue;
736
- if (recursive) {
737
- files.push(...scanDirectory(fullPath, true));
738
- }
739
- } else if (entry.isFile() && entry.name.endsWith(".md")) {
740
- // Skip README.md
741
- if (entry.name.toLowerCase() === "readme.md") continue;
742
- files.push(fullPath);
743
- }
744
- }
745
-
746
- return files;
747
- }
748
-
749
- // ── Single File Processor ──────────────────────────────────────────────
750
-
751
- async function processFile(inputFile, outputFile, meta, options) {
752
- const { body, frontmatter } = readMarkdown(inputFile);
753
-
754
- // Merge frontmatter into meta (meta takes priority)
755
- const mergedMeta = { ...frontmatter, ...meta };
756
- if (!mergedMeta.title) {
757
- // Use first H1 or filename
758
- const h1Match = body.match(/^#\s+(.+)$/m);
759
- mergedMeta.title = h1Match ? h1Match[1] : path.basename(inputFile, ".md");
760
- }
761
-
762
- // Parse markdown
763
- const tokens = marked.lexer(body);
764
-
765
- // Build sections
766
- const coverBlocks = options.noCover ? [] : buildCoverPage(mergedMeta);
767
- const tocBlocks = options.noToc ? [] : buildTOC();
768
- const contentBlocks = convertTokens(tokens);
769
-
770
- // Assemble document
771
- const doc = assembleDocument(coverBlocks, tocBlocks, contentBlocks, mergedMeta);
772
-
773
- // Ensure output directory exists
774
- const outDir = path.dirname(outputFile);
775
- if (!fs.existsSync(outDir)) {
776
- fs.mkdirSync(outDir, { recursive: true });
777
- }
778
-
779
- // Write file
780
- const buffer = await Packer.toBuffer(doc);
781
- fs.writeFileSync(outputFile, buffer);
782
- }
783
-
784
- // ── Main ───────────────────────────────────────────────────────────────
785
-
786
- async function main() {
787
- const args = parseArgs();
788
- const meta = JSON.parse(args.meta || "{}");
789
- const options = { noCover: args.noCover, noToc: args.noToc };
790
- const results = [];
791
-
792
- try {
793
- if (args.dir) {
794
- // Directory mode
795
- const dirPath = path.resolve(args.dir);
796
- if (!fs.existsSync(dirPath)) {
797
- throw new Error(`目录不存在: ${dirPath}`);
798
- }
799
-
800
- const files = scanDirectory(dirPath, args.recursive);
801
- if (files.length === 0) {
802
- throw new Error(`目录中未找到 .md 文件: ${dirPath}`);
803
- }
804
-
805
- const outDir = args.output ? path.resolve(args.output) : dirPath;
806
-
807
- for (const file of files) {
808
- const baseName = path.basename(file, ".md");
809
- const outputFile = path.join(outDir, `${baseName}.docx`);
810
-
811
- try {
812
- await processFile(file, outputFile, { ...meta }, options);
813
- results.push({ input: file, output: outputFile, status: "ok" });
814
- } catch (err) {
815
- results.push({ input: file, output: outputFile, status: "error", error: err.message });
816
- }
817
- }
818
- } else if (args.input) {
819
- // Single file mode
820
- const inputFile = path.resolve(args.input);
821
- if (!fs.existsSync(inputFile)) {
822
- throw new Error(`文件不存在: ${inputFile}`);
823
- }
824
-
825
- const baseName = path.basename(inputFile, ".md");
826
- const outputFile = args.output
827
- ? path.resolve(args.output)
828
- : path.join(path.dirname(inputFile), `${baseName}.docx`);
829
-
830
- await processFile(inputFile, outputFile, meta, options);
831
- results.push({ input: inputFile, output: outputFile, status: "ok" });
832
- } else {
833
- throw new Error("必须指定 --input 或 --dir 参数");
834
- }
835
-
836
- const failed = results.filter((r) => r.status === "error");
837
- const status = failed.length === 0 ? "success" : failed.length === results.length ? "error" : "partial";
838
-
839
- console.log(
840
- JSON.stringify({
841
- status,
842
- files: results,
843
- cover: !args.noCover,
844
- toc: !args.noToc,
845
- total: results.length,
846
- success: results.length - failed.length,
847
- failed: failed.length,
848
- })
849
- );
850
- } catch (err) {
851
- console.error(
852
- JSON.stringify({
853
- status: "error",
854
- message: err.message,
855
- files: results,
856
- })
857
- );
858
- process.exit(1);
859
- }
860
- }
861
-
862
- main();
1
+ #!/usr/bin/env node
2
+ /**
3
+ * md-to-docx.js — Markdown to Word (.docx) converter
4
+ *
5
+ * Features:
6
+ * - Auto-generated cover page (company logo, project info, document metadata)
7
+ * - Table of contents (Heading 1-3)
8
+ * - Full Markdown element conversion (headings, tables, code blocks, lists, images, blockquotes)
9
+ *
10
+ * Usage:
11
+ * node md-to-docx.js --input <file> --output <file> --meta '<json>'
12
+ * node md-to-docx.js --dir <dir> --output <dir> --meta '<json>' [--recursive]
13
+ * [--no-cover] [--no-toc]
14
+ */
15
+
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+ const {
19
+ Document,
20
+ Packer,
21
+ Paragraph,
22
+ TextRun,
23
+ HeadingLevel,
24
+ AlignmentType,
25
+ TableOfContents,
26
+ StyleLevel,
27
+ Table,
28
+ TableRow,
29
+ TableCell,
30
+ WidthType,
31
+ BorderStyle,
32
+ ImageRun,
33
+ PageBreak,
34
+ ShadingType,
35
+ } = require("docx");
36
+ const { marked } = require("marked");
37
+
38
+ // ── Constants ──────────────────────────────────────────────────────────
39
+
40
+ const DARK_BLUE = "1F4E79";
41
+ const GRAY = "808080";
42
+ const LIGHT_GRAY = "F2F2F2";
43
+
44
+ const FONT_BODY = "DengXian";
45
+ const FONT_HEADING = "Microsoft YaHei";
46
+ const FONT_CODE = "Consolas";
47
+
48
+ const HEADING_STYLES = {
49
+ 1: { heading: HeadingLevel.HEADING_1, size: 44, font: FONT_HEADING },
50
+ 2: { heading: HeadingLevel.HEADING_2, size: 32, font: FONT_HEADING },
51
+ 3: { heading: HeadingLevel.HEADING_3, size: 28, font: FONT_HEADING },
52
+ 4: { heading: HeadingLevel.HEADING_4, size: 24, font: FONT_HEADING },
53
+ 5: { heading: HeadingLevel.HEADING_5, size: 22, font: FONT_HEADING },
54
+ 6: { heading: HeadingLevel.HEADING_6, size: 20, font: FONT_HEADING },
55
+ };
56
+
57
+ // ── CLI Arguments ──────────────────────────────────────────────────────
58
+
59
+ function parseArgs() {
60
+ const args = process.argv.slice(2);
61
+ const result = {
62
+ input: null,
63
+ dir: null,
64
+ output: null,
65
+ meta: "{}",
66
+ noCover: false,
67
+ noToc: false,
68
+ recursive: false,
69
+ };
70
+ for (let i = 0; i < args.length; i++) {
71
+ switch (args[i]) {
72
+ case "--input":
73
+ result.input = args[++i];
74
+ break;
75
+ case "--dir":
76
+ result.dir = args[++i];
77
+ break;
78
+ case "--output":
79
+ result.output = args[++i];
80
+ break;
81
+ case "--meta":
82
+ result.meta = args[++i];
83
+ break;
84
+ case "--no-cover":
85
+ result.noCover = true;
86
+ break;
87
+ case "--no-toc":
88
+ result.noToc = true;
89
+ break;
90
+ case "--recursive":
91
+ result.recursive = true;
92
+ break;
93
+ }
94
+ }
95
+ return result;
96
+ }
97
+
98
+ // ── Markdown Reader ────────────────────────────────────────────────────
99
+
100
+ function readMarkdown(filePath) {
101
+ const content = fs.readFileSync(filePath, "utf-8");
102
+
103
+ // Try YAML frontmatter
104
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
105
+ let frontmatter = {};
106
+ let body = content;
107
+
108
+ if (fmMatch) {
109
+ for (const line of fmMatch[1].split("\n")) {
110
+ const m = line.match(/^(\S+):\s*(.*)$/);
111
+ if (m) frontmatter[m[1]] = m[2].trim().replace(/^["']|["']$/g, "");
112
+ }
113
+ body = fmMatch[2];
114
+ } else {
115
+ // Try blockquote metadata: > **版本**: v1.0
116
+ const metaLines = [];
117
+ const restLines = [];
118
+ let inMeta = true;
119
+ for (const line of content.split("\n")) {
120
+ if (inMeta && line.startsWith(">")) {
121
+ metaLines.push(line);
122
+ } else {
123
+ inMeta = false;
124
+ restLines.push(line);
125
+ }
126
+ }
127
+ body = restLines.join("\n");
128
+ for (const line of metaLines) {
129
+ const m = line.match(/\*\*(.+?)\*\*[::]\s*(.+)/);
130
+ if (m) frontmatter[m[1].trim()] = m[2].trim();
131
+ }
132
+ }
133
+
134
+ return { body, frontmatter };
135
+ }
136
+
137
+ // ── Inline Token Converter ─────────────────────────────────────────────
138
+
139
+ function convertInline(tokens, overrides = {}) {
140
+ if (!tokens || !Array.isArray(tokens)) return [];
141
+ const runs = [];
142
+ const baseFont = overrides.font || FONT_BODY;
143
+ const baseSize = overrides.size || 24;
144
+
145
+ for (const token of tokens) {
146
+ switch (token.type) {
147
+ case "text": {
148
+ const children = token.tokens ? convertInline(token.tokens, overrides) : [];
149
+ if (children.length > 0) {
150
+ runs.push(...children);
151
+ } else {
152
+ runs.push(new TextRun({ text: token.text || "", font: baseFont, size: baseSize, ...overrides }));
153
+ }
154
+ break;
155
+ }
156
+ case "strong":
157
+ runs.push(...convertInline(token.tokens, { ...overrides, bold: true }));
158
+ break;
159
+ case "em":
160
+ runs.push(...convertInline(token.tokens, { ...overrides, italics: true }));
161
+ break;
162
+ case "del":
163
+ runs.push(...convertInline(token.tokens, { ...overrides, strike: true }));
164
+ break;
165
+ case "codespan":
166
+ runs.push(
167
+ new TextRun({ text: token.text || "", font: FONT_CODE, size: Math.min(baseSize, 20), ...overrides })
168
+ );
169
+ break;
170
+ case "link":
171
+ runs.push(
172
+ new TextRun({
173
+ text: token.text || (token.tokens ? token.tokens.map(t => t.text || t.raw || "").join("") : ""),
174
+ font: baseFont,
175
+ size: baseSize,
176
+ color: "0563C1",
177
+ underline: { type: "single" },
178
+ ...overrides,
179
+ })
180
+ );
181
+ break;
182
+ case "image":
183
+ try {
184
+ const imgPath = token.href;
185
+ if (imgPath && fs.existsSync(imgPath)) {
186
+ const buf = fs.readFileSync(imgPath);
187
+ runs.push(
188
+ new ImageRun({
189
+ data: buf,
190
+ transformation: { width: 200, height: 150 },
191
+ altText: { title: token.text || "Image", description: token.text || "Image", name: "image" },
192
+ })
193
+ );
194
+ } else {
195
+ runs.push(new TextRun({ text: `[Image: ${token.text || token.href}]`, font: baseFont, size: baseSize, italics: true, color: GRAY }));
196
+ }
197
+ } catch {
198
+ runs.push(new TextRun({ text: `[Image: ${token.text || token.href}]`, font: baseFont, size: baseSize, italics: true, color: GRAY }));
199
+ }
200
+ break;
201
+ case "br":
202
+ runs.push(new TextRun({ text: "", break: 1, font: baseFont, size: baseSize }));
203
+ break;
204
+ default:
205
+ if (token.text || token.raw) {
206
+ runs.push(new TextRun({ text: token.text || token.raw || "", font: baseFont, size: baseSize, ...overrides }));
207
+ }
208
+ }
209
+ }
210
+
211
+ return runs;
212
+ }
213
+
214
+ // ── Cover Page Builder ─────────────────────────────────────────────────
215
+
216
+ function buildCoverPage(meta) {
217
+ const blocks = [];
218
+ const spacer = (n) => {
219
+ for (let i = 0; i < n; i++) blocks.push(new Paragraph({ children: [new TextRun("")] }));
220
+ };
221
+
222
+ // 1. Top spacing
223
+ spacer(3);
224
+
225
+ // 2. Company logo
226
+ if (meta.company_logo && fs.existsSync(meta.company_logo)) {
227
+ try {
228
+ const logoBuf = fs.readFileSync(meta.company_logo);
229
+ blocks.push(
230
+ new Paragraph({
231
+ alignment: AlignmentType.CENTER,
232
+ children: [
233
+ new ImageRun({
234
+ data: logoBuf,
235
+ transformation: { width: 200, height: 200 },
236
+ altText: { title: "Company Logo", description: "Company Logo", name: "logo" },
237
+ }),
238
+ ],
239
+ })
240
+ );
241
+ spacer(2);
242
+ } catch {
243
+ // Skip logo on error
244
+ }
245
+ }
246
+
247
+ // 3. Company name
248
+ if (meta.company_name) {
249
+ blocks.push(
250
+ new Paragraph({
251
+ alignment: AlignmentType.CENTER,
252
+ children: [new TextRun({ text: meta.company_name, bold: true, font: FONT_HEADING, size: 32, color: DARK_BLUE })],
253
+ })
254
+ );
255
+ }
256
+
257
+ spacer(1);
258
+
259
+ // 4. Project name
260
+ if (meta.project_name) {
261
+ blocks.push(
262
+ new Paragraph({
263
+ alignment: AlignmentType.CENTER,
264
+ children: [new TextRun({ text: meta.project_name, bold: true, font: FONT_HEADING, size: 36, color: DARK_BLUE })],
265
+ })
266
+ );
267
+ }
268
+
269
+ spacer(2);
270
+
271
+ // 5. Document title
272
+ if (meta.title) {
273
+ blocks.push(
274
+ new Paragraph({
275
+ alignment: AlignmentType.CENTER,
276
+ children: [new TextRun({ text: meta.title, bold: true, font: FONT_HEADING, size: 56, color: DARK_BLUE })],
277
+ })
278
+ );
279
+ }
280
+
281
+ // 6. Subtitle
282
+ if (meta.subtitle) {
283
+ blocks.push(
284
+ new Paragraph({
285
+ alignment: AlignmentType.CENTER,
286
+ spacing: { before: 200 },
287
+ children: [new TextRun({ text: meta.subtitle, font: FONT_HEADING, size: 28, color: GRAY })],
288
+ })
289
+ );
290
+ }
291
+
292
+ spacer(3);
293
+
294
+ // 7. Info table
295
+ const fields = [
296
+ ["文档编号", meta.document_number],
297
+ ["版本号", meta.version],
298
+ ["创建日期", meta.date],
299
+ ["作者", meta.author],
300
+ ["审核人", meta.reviewer],
301
+ ["批准人", meta.approver],
302
+ ["保密等级", meta.confidentiality],
303
+ ].filter(([, v]) => v);
304
+
305
+ if (fields.length > 0) {
306
+ const borderStyle = { style: BorderStyle.SINGLE, size: 1, color: "BBBBBB" };
307
+ const borders = { top: borderStyle, bottom: borderStyle, left: borderStyle, right: borderStyle };
308
+
309
+ const rows = fields.map(
310
+ ([label, value]) =>
311
+ new TableRow({
312
+ children: [
313
+ new TableCell({
314
+ borders,
315
+ width: { size: 3000, type: WidthType.DXA },
316
+ children: [
317
+ new Paragraph({
318
+ alignment: AlignmentType.CENTER,
319
+ children: [new TextRun({ text: label, bold: true, font: FONT_HEADING, size: 22 })],
320
+ }),
321
+ ],
322
+ }),
323
+ new TableCell({
324
+ borders,
325
+ width: { size: 5000, type: WidthType.DXA },
326
+ children: [
327
+ new Paragraph({
328
+ children: [new TextRun({ text: value, font: FONT_BODY, size: 22 })],
329
+ }),
330
+ ],
331
+ }),
332
+ ],
333
+ })
334
+ );
335
+
336
+ blocks.push(
337
+ new Table({
338
+ rows,
339
+ width: { size: 8000, type: WidthType.DXA },
340
+ })
341
+ );
342
+ }
343
+
344
+ // 8. Page break
345
+ blocks.push(
346
+ new Paragraph({
347
+ children: [new PageBreak()],
348
+ })
349
+ );
350
+
351
+ return blocks;
352
+ }
353
+
354
+ // ── TOC Builder ────────────────────────────────────────────────────────
355
+
356
+ function buildTOC() {
357
+ return [
358
+ new Paragraph({
359
+ heading: HeadingLevel.HEADING_1,
360
+ children: [new TextRun({ text: "目录", font: FONT_HEADING, size: 44, bold: true, color: DARK_BLUE })],
361
+ }),
362
+ new TableOfContents("目录", {
363
+ hyperlink: true,
364
+ headingStyleRange: "1-3",
365
+ stylesWithLevels: [
366
+ new StyleLevel("Heading1", 1),
367
+ new StyleLevel("Heading2", 2),
368
+ new StyleLevel("Heading3", 3),
369
+ ],
370
+ }),
371
+ new Paragraph({
372
+ children: [new PageBreak()],
373
+ }),
374
+ ];
375
+ }
376
+
377
+ // ── Markdown → docx Converter ──────────────────────────────────────────
378
+
379
+ function convertTokens(tokens) {
380
+ const blocks = [];
381
+
382
+ for (const token of tokens) {
383
+ switch (token.type) {
384
+ case "heading": {
385
+ const style = HEADING_STYLES[token.depth] || HEADING_STYLES[6];
386
+ const runs = token.tokens
387
+ ? convertInline(token.tokens, { font: style.font, size: style.size, bold: true, color: DARK_BLUE })
388
+ : [new TextRun({ text: token.text || "", font: style.font, size: style.size, bold: true, color: DARK_BLUE })];
389
+ blocks.push(
390
+ new Paragraph({
391
+ heading: style.heading,
392
+ spacing: { before: 300, after: 100 },
393
+ children: runs,
394
+ })
395
+ );
396
+ break;
397
+ }
398
+
399
+ case "paragraph": {
400
+ const runs = convertInline(token.tokens);
401
+ blocks.push(
402
+ new Paragraph({
403
+ spacing: { after: 120 },
404
+ children: runs,
405
+ })
406
+ );
407
+ break;
408
+ }
409
+
410
+ case "code": {
411
+ const lines = (token.text || "").split("\n");
412
+ for (const line of lines) {
413
+ blocks.push(
414
+ new Paragraph({
415
+ spacing: { after: 0 },
416
+ indent: { left: 400 },
417
+ shading: { type: ShadingType.CLEAR, color: "auto", fill: LIGHT_GRAY },
418
+ children: [new TextRun({ text: line || " ", font: FONT_CODE, size: 20 })],
419
+ })
420
+ );
421
+ }
422
+ // Add spacing after code block
423
+ blocks.push(new Paragraph({ spacing: { after: 120 }, children: [new TextRun("")] }));
424
+ break;
425
+ }
426
+
427
+ case "table": {
428
+ const borderStyle = { style: BorderStyle.SINGLE, size: 1, color: "999999" };
429
+ const borders = { top: borderStyle, bottom: borderStyle, left: borderStyle, right: borderStyle };
430
+
431
+ // Header row
432
+ const headerRow = new TableRow({
433
+ tableHeader: true,
434
+ children: (token.header || []).map(
435
+ (h) =>
436
+ new TableCell({
437
+ borders,
438
+ shading: { type: ShadingType.CLEAR, color: "auto", fill: "D9E2F3" },
439
+ children: [
440
+ new Paragraph({
441
+ children: [
442
+ new TextRun({
443
+ text: h.text || "",
444
+ bold: true,
445
+ font: FONT_HEADING,
446
+ size: 22,
447
+ }),
448
+ ],
449
+ }),
450
+ ],
451
+ })
452
+ ),
453
+ });
454
+
455
+ // Data rows
456
+ const dataRows = (token.rows || []).map(
457
+ (row) =>
458
+ new TableRow({
459
+ children: row.map(
460
+ (cell) =>
461
+ new TableCell({
462
+ borders,
463
+ children: [
464
+ new Paragraph({
465
+ children: cell.tokens
466
+ ? convertInline(cell.tokens)
467
+ : [new TextRun({ text: cell.text || "", font: FONT_BODY, size: 22 })],
468
+ }),
469
+ ],
470
+ })
471
+ ),
472
+ })
473
+ );
474
+
475
+ blocks.push(new Table({ rows: [headerRow, ...dataRows] }));
476
+ // Spacing after table
477
+ blocks.push(new Paragraph({ spacing: { after: 120 }, children: [new TextRun("")] }));
478
+ break;
479
+ }
480
+
481
+ case "list": {
482
+ blocks.push(...convertList(token, 0));
483
+ break;
484
+ }
485
+
486
+ case "blockquote": {
487
+ const innerTokens = token.tokens || [];
488
+ for (const inner of innerTokens) {
489
+ if (inner.type === "paragraph") {
490
+ const runs = convertInline(inner.tokens, { italics: true, color: "555555" });
491
+ blocks.push(
492
+ new Paragraph({
493
+ indent: { left: 720 },
494
+ spacing: { after: 120 },
495
+ border: {
496
+ left: { style: BorderStyle.SINGLE, size: 6, color: DARK_BLUE, space: 10 },
497
+ },
498
+ shading: { type: ShadingType.CLEAR, color: "auto", fill: LIGHT_GRAY },
499
+ children: runs,
500
+ })
501
+ );
502
+ } else {
503
+ // Recursively handle other block types inside blockquote
504
+ const nested = convertTokens([inner]);
505
+ blocks.push(...nested);
506
+ }
507
+ }
508
+ break;
509
+ }
510
+
511
+ case "hr":
512
+ blocks.push(
513
+ new Paragraph({
514
+ spacing: { before: 200, after: 200 },
515
+ border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" } },
516
+ children: [new TextRun("")],
517
+ })
518
+ );
519
+ break;
520
+
521
+ case "html":
522
+ // Skip HTML blocks or render as plain text
523
+ blocks.push(
524
+ new Paragraph({
525
+ spacing: { after: 120 },
526
+ children: [new TextRun({ text: token.text || "", font: FONT_BODY, size: 24, color: GRAY })],
527
+ })
528
+ );
529
+ break;
530
+
531
+ case "space":
532
+ break;
533
+
534
+ default:
535
+ if (token.raw && token.text) {
536
+ blocks.push(
537
+ new Paragraph({
538
+ spacing: { after: 120 },
539
+ children: [new TextRun({ text: token.text || token.raw || "", font: FONT_BODY, size: 24 })],
540
+ })
541
+ );
542
+ }
543
+ }
544
+ }
545
+
546
+ return blocks;
547
+ }
548
+
549
+ // ── List Converter (handles nesting) ───────────────────────────────────
550
+
551
+ function convertList(token, level) {
552
+ const blocks = [];
553
+ const isOrdered = token.ordered;
554
+ const items = token.items || [];
555
+
556
+ let numIndex = 1;
557
+
558
+ for (const item of items) {
559
+ const numbering = isOrdered
560
+ ? { reference: "numbered-list", level }
561
+ : { reference: "bullet-list", level };
562
+
563
+ // Process item children (paragraphs, nested lists, code blocks)
564
+ const itemChildren = item.tokens || [];
565
+
566
+ for (const child of itemChildren) {
567
+ if (child.type === "list") {
568
+ // Nested list
569
+ blocks.push(...convertList(child, level + 1));
570
+ } else if (child.type === "text" && !child.tokens) {
571
+ // Plain text item
572
+ const prefix = item.task ? (item.checked ? "☑ " : "☐ ") : "";
573
+ blocks.push(
574
+ new Paragraph({
575
+ numbering,
576
+ spacing: { after: 60 },
577
+ children: [new TextRun({ text: prefix + (child.text || child.raw || ""), font: FONT_BODY, size: 24 })],
578
+ })
579
+ );
580
+ } else if (child.type === "text" && child.tokens) {
581
+ // Text with inline formatting
582
+ const runs = convertInline(child.tokens);
583
+ if (item.task) {
584
+ const prefix = item.checked ? "☑ " : "☐ ";
585
+ runs.unshift(new TextRun({ text: prefix, font: FONT_BODY, size: 24 }));
586
+ }
587
+ blocks.push(
588
+ new Paragraph({
589
+ numbering,
590
+ spacing: { after: 60 },
591
+ children: runs,
592
+ })
593
+ );
594
+ } else if (child.type === "paragraph") {
595
+ const runs = convertInline(child.tokens);
596
+ if (item.task) {
597
+ const prefix = item.checked ? "☑ " : "☐ ";
598
+ runs.unshift(new TextRun({ text: prefix, font: FONT_BODY, size: 24 }));
599
+ }
600
+ blocks.push(
601
+ new Paragraph({
602
+ numbering,
603
+ spacing: { after: 60 },
604
+ children: runs,
605
+ })
606
+ );
607
+ } else if (child.type === "code") {
608
+ // Code block inside list item
609
+ const lines = (child.text || "").split("\n");
610
+ for (const line of lines) {
611
+ blocks.push(
612
+ new Paragraph({
613
+ spacing: { after: 0 },
614
+ indent: { left: 400 + level * 720 },
615
+ shading: { type: ShadingType.CLEAR, color: "auto", fill: LIGHT_GRAY },
616
+ children: [new TextRun({ text: line || " ", font: FONT_CODE, size: 20 })],
617
+ })
618
+ );
619
+ }
620
+ }
621
+ }
622
+
623
+ // If no children processed, add a fallback
624
+ if (itemChildren.length === 0) {
625
+ const prefix = item.task ? (item.checked ? "☑ " : "☐ ") : "";
626
+ blocks.push(
627
+ new Paragraph({
628
+ numbering,
629
+ spacing: { after: 60 },
630
+ children: [new TextRun({ text: prefix + (item.text || item.raw || ""), font: FONT_BODY, size: 24 })],
631
+ })
632
+ );
633
+ }
634
+
635
+ numIndex++;
636
+ }
637
+
638
+ return blocks;
639
+ }
640
+
641
+ // ── Document Assembler ─────────────────────────────────────────────────
642
+
643
+ function assembleDocument(coverBlocks, tocBlocks, contentBlocks, meta) {
644
+ const sections = [];
645
+
646
+ // Cover page section (no header/footer)
647
+ if (coverBlocks.length > 0) {
648
+ sections.push({
649
+ properties: {
650
+ page: {
651
+ size: { width: 11906, height: 16838 },
652
+ margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
653
+ },
654
+ },
655
+ children: coverBlocks,
656
+ });
657
+ }
658
+
659
+ // TOC + Content in main section
660
+ const mainChildren = [...tocBlocks, ...contentBlocks];
661
+
662
+ sections.push({
663
+ properties: {
664
+ page: {
665
+ size: { width: 11906, height: 16838 },
666
+ margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
667
+ },
668
+ },
669
+ children: mainChildren,
670
+ });
671
+
672
+ return new Document({
673
+ features: { updateFields: true },
674
+ styles: {
675
+ default: {
676
+ document: {
677
+ run: { font: FONT_BODY, size: 24 },
678
+ paragraph: { spacing: { line: 360 } },
679
+ },
680
+ heading1: {
681
+ run: { font: FONT_HEADING, size: 44, bold: true, color: DARK_BLUE },
682
+ paragraph: { spacing: { before: 400, after: 200 } },
683
+ },
684
+ heading2: {
685
+ run: { font: FONT_HEADING, size: 32, bold: true, color: DARK_BLUE },
686
+ paragraph: { spacing: { before: 300, after: 150 } },
687
+ },
688
+ heading3: {
689
+ run: { font: FONT_HEADING, size: 28, bold: true, color: DARK_BLUE },
690
+ paragraph: { spacing: { before: 240, after: 120 } },
691
+ },
692
+ heading4: {
693
+ run: { font: FONT_HEADING, size: 24, bold: true, color: DARK_BLUE },
694
+ paragraph: { spacing: { before: 200, after: 100 } },
695
+ },
696
+ },
697
+ },
698
+ numbering: {
699
+ config: [
700
+ {
701
+ reference: "bullet-list",
702
+ levels: [
703
+ { level: 0, format: "bullet", text: "•", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 720, hanging: 360 } } } },
704
+ { level: 1, format: "bullet", text: "◦", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 1440, hanging: 360 } } } },
705
+ { level: 2, format: "bullet", text: "▪", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 2160, hanging: 360 } } } },
706
+ { level: 3, format: "bullet", text: "•", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 2880, hanging: 360 } } } },
707
+ ],
708
+ },
709
+ {
710
+ reference: "numbered-list",
711
+ levels: [
712
+ { level: 0, format: "decimal", text: "%1.", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 720, hanging: 360 } } } },
713
+ { level: 1, format: "lowerLetter", text: "%2)", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 1440, hanging: 360 } } } },
714
+ { level: 2, format: "lowerRoman", text: "%3.", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 2160, hanging: 360 } } } },
715
+ { level: 3, format: "decimal", text: "%4.", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 2880, hanging: 360 } } } },
716
+ ],
717
+ },
718
+ ],
719
+ },
720
+ sections,
721
+ });
722
+ }
723
+
724
+ // ── Directory Scanner ──────────────────────────────────────────────────
725
+
726
+ function scanDirectory(dir, recursive) {
727
+ const files = [];
728
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
729
+
730
+ for (const entry of entries) {
731
+ const fullPath = path.join(dir, entry.name);
732
+
733
+ if (entry.isDirectory()) {
734
+ // Skip hidden directories
735
+ if (entry.name.startsWith(".")) continue;
736
+ if (recursive) {
737
+ files.push(...scanDirectory(fullPath, true));
738
+ }
739
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
740
+ // Skip README.md
741
+ if (entry.name.toLowerCase() === "readme.md") continue;
742
+ files.push(fullPath);
743
+ }
744
+ }
745
+
746
+ return files;
747
+ }
748
+
749
+ // ── Single File Processor ──────────────────────────────────────────────
750
+
751
+ async function processFile(inputFile, outputFile, meta, options) {
752
+ const { body, frontmatter } = readMarkdown(inputFile);
753
+
754
+ // Merge frontmatter into meta (meta takes priority)
755
+ const mergedMeta = { ...frontmatter, ...meta };
756
+ if (!mergedMeta.title) {
757
+ // Use first H1 or filename
758
+ const h1Match = body.match(/^#\s+(.+)$/m);
759
+ mergedMeta.title = h1Match ? h1Match[1] : path.basename(inputFile, ".md");
760
+ }
761
+
762
+ // Parse markdown
763
+ const tokens = marked.lexer(body);
764
+
765
+ // Build sections
766
+ const coverBlocks = options.noCover ? [] : buildCoverPage(mergedMeta);
767
+ const tocBlocks = options.noToc ? [] : buildTOC();
768
+ const contentBlocks = convertTokens(tokens);
769
+
770
+ // Assemble document
771
+ const doc = assembleDocument(coverBlocks, tocBlocks, contentBlocks, mergedMeta);
772
+
773
+ // Ensure output directory exists
774
+ const outDir = path.dirname(outputFile);
775
+ if (!fs.existsSync(outDir)) {
776
+ fs.mkdirSync(outDir, { recursive: true });
777
+ }
778
+
779
+ // Write file
780
+ const buffer = await Packer.toBuffer(doc);
781
+ fs.writeFileSync(outputFile, buffer);
782
+ }
783
+
784
+ // ── Main ───────────────────────────────────────────────────────────────
785
+
786
+ async function main() {
787
+ const args = parseArgs();
788
+ const meta = JSON.parse(args.meta || "{}");
789
+ const options = { noCover: args.noCover, noToc: args.noToc };
790
+ const results = [];
791
+
792
+ try {
793
+ if (args.dir) {
794
+ // Directory mode
795
+ const dirPath = path.resolve(args.dir);
796
+ if (!fs.existsSync(dirPath)) {
797
+ throw new Error(`目录不存在: ${dirPath}`);
798
+ }
799
+
800
+ const files = scanDirectory(dirPath, args.recursive);
801
+ if (files.length === 0) {
802
+ throw new Error(`目录中未找到 .md 文件: ${dirPath}`);
803
+ }
804
+
805
+ const outDir = args.output ? path.resolve(args.output) : dirPath;
806
+
807
+ for (const file of files) {
808
+ const baseName = path.basename(file, ".md");
809
+ const outputFile = path.join(outDir, `${baseName}.docx`);
810
+
811
+ try {
812
+ await processFile(file, outputFile, { ...meta }, options);
813
+ results.push({ input: file, output: outputFile, status: "ok" });
814
+ } catch (err) {
815
+ results.push({ input: file, output: outputFile, status: "error", error: err.message });
816
+ }
817
+ }
818
+ } else if (args.input) {
819
+ // Single file mode
820
+ const inputFile = path.resolve(args.input);
821
+ if (!fs.existsSync(inputFile)) {
822
+ throw new Error(`文件不存在: ${inputFile}`);
823
+ }
824
+
825
+ const baseName = path.basename(inputFile, ".md");
826
+ const outputFile = args.output
827
+ ? path.resolve(args.output)
828
+ : path.join(path.dirname(inputFile), `${baseName}.docx`);
829
+
830
+ await processFile(inputFile, outputFile, meta, options);
831
+ results.push({ input: inputFile, output: outputFile, status: "ok" });
832
+ } else {
833
+ throw new Error("必须指定 --input 或 --dir 参数");
834
+ }
835
+
836
+ const failed = results.filter((r) => r.status === "error");
837
+ const status = failed.length === 0 ? "success" : failed.length === results.length ? "error" : "partial";
838
+
839
+ console.log(
840
+ JSON.stringify({
841
+ status,
842
+ files: results,
843
+ cover: !args.noCover,
844
+ toc: !args.noToc,
845
+ total: results.length,
846
+ success: results.length - failed.length,
847
+ failed: failed.length,
848
+ })
849
+ );
850
+ } catch (err) {
851
+ console.error(
852
+ JSON.stringify({
853
+ status: "error",
854
+ message: err.message,
855
+ files: results,
856
+ })
857
+ );
858
+ process.exit(1);
859
+ }
860
+ }
861
+
862
+ main();