adspecs 0.1.18 → 0.1.20

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 (88) hide show
  1. package/.adspecs/extensions/git/commands/adspecs.git.initialize.md +4 -4
  2. package/.adspecs/extensions/git/extension.yml +1 -1
  3. package/.adspecs/extensions/git/git-config.yml +17 -2
  4. package/.adspecs/extensions.yml +32 -0
  5. package/.adspecs/feature.json +16 -16
  6. package/.adspecs/feature.yml +28 -28
  7. package/.adspecs/paths.json +17 -0
  8. package/.adspecs/templates/02-/344/272/247/345/223/201/351/234/200/346/261/202/350/257/264/346/230/216/344/271/246/346/250/241/346/235/277.md +4 -0
  9. package/.adspecs/workflows/adspecs/workflow.yml +5 -4
  10. package/.claude-plugin/marketplace.json +23 -23
  11. package/.claude-plugin/plugin.json +18 -18
  12. package/.qoder-plugin/plugin.json +31 -31
  13. package/CLAUDE.md +1 -5
  14. package/README.md +395 -395
  15. package/bin/adspecs.js +129 -129
  16. package/hooks/commit-queue.js +245 -245
  17. package/hooks/hooks.json +63 -63
  18. package/hooks/session-start.js +44 -44
  19. package/hooks/wiki-queue.js +127 -127
  20. package/package.json +61 -61
  21. package/references/ant6-front-standard/index.md +99 -99
  22. package/references/antd-front-demo/.env.develop +12 -0
  23. package/references/antd-front-demo/.env.production +5 -0
  24. package/references/antd-front-demo/package-lock.json +2 -2
  25. package/references/antd-front-demo/package.json +1 -1
  26. package/references/antd-front-demo/public/mockServiceWorker.js +361 -361
  27. package/references/ecp-end-standard/index.md +63 -63
  28. 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
  29. 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
  30. 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
  31. package/references/python-end-standard/04-Redis/344/275/277/347/224/250/350/247/204/350/214/203.md +231 -231
  32. package/scripts/postinstall.js +107 -107
  33. package/scripts/sync-version.js +105 -105
  34. package/skills/.claude/.wiki-update-queue +26 -26
  35. package/skills/adspecs-constitution/SKILL.md +157 -0
  36. package/skills/adspecs-export-word/SKILL.md +498 -498
  37. package/skills/adspecs-export-word/references/md-to-docx.js +862 -862
  38. package/skills/adspecs-export-word/references/package-lock.json +220 -220
  39. package/skills/adspecs-export-word/references/package.json +10 -10
  40. package/skills/adspecs-front-prototype/SKILL.md +405 -0
  41. package/skills/adspecs-git-commit/SKILL.md +23 -8
  42. package/skills/adspecs-git-initialize/SKILL.md +54 -0
  43. package/skills/adspecs-plan/SKILL.md +59 -69
  44. package/skills/adspecs-prd/SKILL.md +13 -5
  45. package/skills/adspecs-prd-to-demo/SKILL.md +532 -0
  46. package/skills/adspecs-tasks/SKILL.md +175 -204
  47. package/skills/adspecs-update-status/SKILL.md +382 -382
  48. package/skills/adspecs-utest/SKILL.md +107 -116
  49. package/skills/grill-me/SKILL.md +7 -0
  50. package/skills/grill-me/agents/openai.yaml +5 -0
  51. package/skills/playwright-cli/SKILL.md +420 -0
  52. package/skills/playwright-cli/references/element-attributes.md +23 -0
  53. package/skills/playwright-cli/references/playwright-tests.md +39 -0
  54. package/skills/playwright-cli/references/request-mocking.md +87 -0
  55. package/skills/playwright-cli/references/running-code.md +241 -0
  56. package/skills/playwright-cli/references/session-management.md +225 -0
  57. package/skills/playwright-cli/references/storage-state.md +275 -0
  58. package/skills/playwright-cli/references/test-generation.md +433 -0
  59. package/skills/playwright-cli/references/tracing.md +139 -0
  60. package/skills/playwright-cli/references/video-recording.md +143 -0
  61. package/skills/playwright-trace/SKILL.md +171 -0
  62. package/skills/project-init/references/front-demo/.claude/settings.local.json +9 -0
  63. package/skills/wiki-update/SKILL.md +232 -232
  64. package/src/commands/doctor.js +197 -197
  65. package/src/commands/init.js +83 -83
  66. package/src/commands/plugin.js +165 -165
  67. package/src/commands/update.js +87 -87
  68. package/src/lib/area-scanner.js +129 -129
  69. package/src/lib/copier.js +104 -104
  70. package/src/lib/dir-utils.js +133 -133
  71. package/src/lib/json-merge.js +114 -114
  72. package/src/lib/paths-defaults.js +37 -37
  73. package/src/lib/prompts.js +347 -345
  74. package/src/lib/readme-gen.js +143 -143
  75. package/src/lib/report.js +327 -327
  76. package/src/lib/scaffolder.js +518 -518
  77. package/src/lib/short-name.js +36 -36
  78. package/src/utils.js +80 -80
  79. package/references/antd-front-demo/.env +0 -15
  80. package/references/antd-front-demo/src/pages/aps/coil/components/CoilDrawer.tsx +0 -141
  81. package/references/antd-front-demo/src/pages/aps/coil/index.tsx +0 -209
  82. package/references/antd-front-demo/src/pages/aps/optimization/index.tsx +0 -192
  83. package/references/antd-front-demo/src/pages/aps/order/components/OrderDrawer.tsx +0 -251
  84. package/references/antd-front-demo/src/pages/aps/order/index.tsx +0 -273
  85. package/references/antd-front-demo/src/pages/aps/param/index.tsx +0 -153
  86. package/references/antd-front-demo/src/pages/aps/plan/components/CuttingPlanVisualization.tsx +0 -821
  87. package/references/antd-front-demo/src/pages/aps/plan/index.tsx +0 -254
  88. package/references/antd-front-demo/src/pages/aps/task/index.tsx +0 -190
@@ -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();