@dunkinfrunkin/mdcat 0.1.8 → 0.1.9

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 (3) hide show
  1. package/package.json +2 -1
  2. package/src/cli.js +22 -4
  3. package/src/docx.js +515 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dunkinfrunkin/mdcat",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "View markdown files beautifully in your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  "dependencies": {
17
17
  "chalk": "^5.6.2",
18
18
  "cli-highlight": "^2.1.11",
19
+ "docx": "^9.6.0",
19
20
  "marked": "^14.0.0",
20
21
  "wrap-ansi": "^10.0.0"
21
22
  },
package/src/cli.js CHANGED
@@ -6,6 +6,7 @@ import { execFileSync } from "child_process";
6
6
  import { marked } from "marked";
7
7
  import { renderTokens } from "./render.js";
8
8
  import { launch } from "./tui.js";
9
+ import { toDocx } from "./docx.js";
9
10
 
10
11
  marked.use({ gfm: true });
11
12
 
@@ -27,6 +28,7 @@ if (args[0] === "--help" || args[0] === "-h") {
27
28
  console.log(`${bold("Usage:")}`);
28
29
  console.log(` mdcat ${dim("<file.md>")}`);
29
30
  console.log(` mdcat ${dim("--web <file.md>")} ${dim("# open in browser")}`);
31
+ console.log(` mdcat ${dim("--doc <file.md>")} ${dim("# export to .docx")}`);
30
32
  console.log(` cat file.md ${dim("|")} mdcat\n`);
31
33
  console.log(`${bold("Keys:")}`);
32
34
  console.log(` ${blue("/")} search ${blue("n/N")} next/prev match`);
@@ -102,16 +104,30 @@ function runTUI(title, content) {
102
104
  launch(title, lines);
103
105
  }
104
106
 
105
- // --web flag
107
+ // --web / --doc flags
106
108
  const webMode = args[0] === "--web" || args[0] === "-w";
107
- const fileArgs = webMode ? args.slice(1) : args;
109
+ const docMode = args[0] === "--doc" || args[0] === "-d";
110
+ const fileArgs = (webMode || docMode) ? args.slice(1) : args;
111
+
112
+ async function exportDocx(title, content) {
113
+ const tokens = marked.lexer(content);
114
+ const buf = await toDocx(tokens, title);
115
+ const outName = title.replace(/\.md$/i, "") + ".docx";
116
+ const outPath = resolve(outName);
117
+ writeFileSync(outPath, buf);
118
+ console.log(`${CAT} ${bold("mdcat")} ${dim("→")} ${blue(outPath)}`);
119
+ }
108
120
 
109
121
  // Piped input
110
122
  if (!process.stdin.isTTY && fileArgs.length === 0) {
111
123
  let input = "";
112
124
  process.stdin.setEncoding("utf8");
113
125
  process.stdin.on("data", (chunk) => (input += chunk));
114
- process.stdin.on("end", () => webMode ? openInBrowser("stdin", input) : runTUI("stdin", input));
126
+ process.stdin.on("end", () => {
127
+ if (docMode) exportDocx("stdin", input);
128
+ else if (webMode) openInBrowser("stdin", input);
129
+ else runTUI("stdin", input);
130
+ });
115
131
  } else if (fileArgs.length === 0) {
116
132
  console.error("Usage: mdcat <file.md>");
117
133
  process.exit(1);
@@ -125,5 +141,7 @@ if (!process.stdin.isTTY && fileArgs.length === 0) {
125
141
  process.exit(1);
126
142
  }
127
143
  const title = basename(filePath);
128
- webMode ? openInBrowser(title, content) : runTUI(title, content);
144
+ if (docMode) exportDocx(title, content);
145
+ else if (webMode) openInBrowser(title, content);
146
+ else runTUI(title, content);
129
147
  }
package/src/docx.js ADDED
@@ -0,0 +1,515 @@
1
+ import {
2
+ Document, Packer, Paragraph, TextRun, HeadingLevel,
3
+ Table, TableRow, TableCell, WidthType, BorderStyle,
4
+ AlignmentType, ExternalHyperlink, TabStopPosition, TabStopType,
5
+ ShadingType, TableLayoutType, Footer, Header,
6
+ } from "docx";
7
+
8
+ // ─── Colors (Word-native blues/grays) ──────────────────────────────────────
9
+
10
+ const COLOR = {
11
+ heading1: "1F3864", // dark navy
12
+ heading2: "2E74B5", // medium blue
13
+ heading3: "2E74B5",
14
+ heading4: "404040",
15
+ link: "2E74B5",
16
+ code: "D63384", // magenta for inline code
17
+ codeBg: "F6F8FA",
18
+ tableHeader:"2E74B5",
19
+ tableHeaderBg: "D9E2F3",
20
+ tableAltBg: "F2F2F2",
21
+ tableBorder:"BFBFBF",
22
+ blockquoteBorder: "2E74B5",
23
+ blockquoteText: "595959",
24
+ taskGreen: "2D8A4E",
25
+ taskGray: "999999",
26
+ body: "333333",
27
+ muted: "808080",
28
+ };
29
+
30
+ // ─── Inline token → TextRun[] / ExternalHyperlink[] ─────────────────────────
31
+
32
+ function inlineRuns(tokens, opts = {}) {
33
+ if (!tokens?.length) return [];
34
+ const runs = [];
35
+
36
+ for (const tok of tokens) {
37
+ switch (tok.type) {
38
+ case "text":
39
+ if (tok.tokens) {
40
+ runs.push(...inlineRuns(tok.tokens, opts));
41
+ } else {
42
+ runs.push(new TextRun({ text: htmlDecode(tok.text ?? ""), color: COLOR.body, ...opts }));
43
+ }
44
+ break;
45
+ case "strong":
46
+ runs.push(...inlineRuns(tok.tokens, { ...opts, bold: true }));
47
+ break;
48
+ case "em":
49
+ runs.push(...inlineRuns(tok.tokens, { ...opts, italics: true }));
50
+ break;
51
+ case "del":
52
+ runs.push(...inlineRuns(tok.tokens, { ...opts, strike: true, color: COLOR.muted }));
53
+ break;
54
+ case "codespan":
55
+ runs.push(new TextRun({
56
+ text: tok.text ?? "",
57
+ font: "Consolas",
58
+ size: 20,
59
+ color: COLOR.code,
60
+ shading: { type: ShadingType.CLEAR, fill: COLOR.codeBg },
61
+ ...opts,
62
+ }));
63
+ break;
64
+ case "link": {
65
+ // Recursively get inline runs for the link label to preserve bold/italic/etc
66
+ const labelRuns = tok.tokens?.length
67
+ ? inlineRuns(tok.tokens, { ...opts, color: COLOR.link, underline: { type: "single" } })
68
+ : [new TextRun({ text: tok.href ?? "", color: COLOR.link, underline: { type: "single" }, ...opts })];
69
+ runs.push(new ExternalHyperlink({
70
+ children: labelRuns,
71
+ link: tok.href ?? "",
72
+ }));
73
+ break;
74
+ }
75
+ case "image":
76
+ runs.push(new TextRun({
77
+ text: `[${tok.text || tok.alt || "image"}]`,
78
+ italics: true,
79
+ color: COLOR.muted,
80
+ ...opts,
81
+ }));
82
+ break;
83
+ case "br":
84
+ runs.push(new TextRun({ break: 1 }));
85
+ break;
86
+ case "escape":
87
+ runs.push(new TextRun({ text: tok.text ?? "", color: COLOR.body, ...opts }));
88
+ break;
89
+ default:
90
+ if (tok.raw) runs.push(new TextRun({ text: tok.raw, color: COLOR.body, ...opts }));
91
+ break;
92
+ }
93
+ }
94
+
95
+ return runs;
96
+ }
97
+
98
+ // ─── HTML entity decoder ────────────────────────────────────────────────────
99
+
100
+ function htmlDecode(s) {
101
+ return s
102
+ .replace(/&amp;/g, "&")
103
+ .replace(/&lt;/g, "<")
104
+ .replace(/&gt;/g, ">")
105
+ .replace(/&quot;/g, '"')
106
+ .replace(/&#39;/g, "'")
107
+ .replace(/&nbsp;/g, " ");
108
+ }
109
+
110
+ // ─── Block token → Paragraph[] ──────────────────────────────────────────────
111
+
112
+ function blockToParagraphs(tok) {
113
+ const out = [];
114
+
115
+ switch (tok.type) {
116
+ case "heading": {
117
+ const headingMap = {
118
+ 1: HeadingLevel.HEADING_1,
119
+ 2: HeadingLevel.HEADING_2,
120
+ 3: HeadingLevel.HEADING_3,
121
+ 4: HeadingLevel.HEADING_4,
122
+ 5: HeadingLevel.HEADING_5,
123
+ 6: HeadingLevel.HEADING_6,
124
+ };
125
+ const colorMap = {
126
+ 1: COLOR.heading1,
127
+ 2: COLOR.heading2,
128
+ 3: COLOR.heading3,
129
+ 4: COLOR.heading4,
130
+ 5: COLOR.heading4,
131
+ 6: COLOR.muted,
132
+ };
133
+ const runs = inlineRuns(tok.tokens).map(r => {
134
+ // Override color for heading runs
135
+ if (r instanceof TextRun) {
136
+ return new TextRun({
137
+ ...extractRunProps(r),
138
+ color: colorMap[tok.depth] ?? COLOR.body,
139
+ });
140
+ }
141
+ return r;
142
+ });
143
+
144
+ out.push(new Paragraph({
145
+ heading: headingMap[tok.depth] ?? HeadingLevel.HEADING_1,
146
+ children: runs,
147
+ spacing: { before: 240, after: 120 },
148
+ ...(tok.depth <= 2 ? {
149
+ border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: "D9D9D9", space: 4 } },
150
+ } : {}),
151
+ }));
152
+ break;
153
+ }
154
+
155
+ case "paragraph":
156
+ out.push(new Paragraph({
157
+ children: inlineRuns(tok.tokens),
158
+ spacing: { after: 160, line: 300 },
159
+ }));
160
+ break;
161
+
162
+ case "code": {
163
+ const lang = (tok.lang ?? "").split(/\s/)[0].trim();
164
+ const lines = (tok.text ?? "").split("\n");
165
+
166
+ // Language label
167
+ if (lang) {
168
+ out.push(new Paragraph({
169
+ children: [new TextRun({
170
+ text: lang.toUpperCase(),
171
+ font: "Consolas",
172
+ size: 16,
173
+ color: COLOR.muted,
174
+ bold: true,
175
+ })],
176
+ spacing: { before: 200, after: 40 },
177
+ }));
178
+ } else {
179
+ out.push(new Paragraph({ spacing: { before: 120 } }));
180
+ }
181
+
182
+ // Code lines in a shaded block
183
+ for (let i = 0; i < lines.length; i++) {
184
+ out.push(new Paragraph({
185
+ children: [new TextRun({
186
+ text: lines[i] || " ",
187
+ font: "Consolas",
188
+ size: 19,
189
+ color: COLOR.body,
190
+ })],
191
+ shading: { type: ShadingType.CLEAR, fill: COLOR.codeBg },
192
+ spacing: { after: 0, line: 260 },
193
+ indent: { left: 240, right: 240 },
194
+ ...(i === 0 ? { border: { top: { style: BorderStyle.SINGLE, size: 1, color: "E1E4E8", space: 4 } } } : {}),
195
+ ...(i === lines.length - 1 ? { border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: "E1E4E8", space: 4 } } } : {}),
196
+ }));
197
+ }
198
+ out.push(new Paragraph({ spacing: { after: 160 } }));
199
+ break;
200
+ }
201
+
202
+ case "blockquote": {
203
+ for (const t of tok.tokens ?? []) {
204
+ if (t.tokens) {
205
+ const children = inlineRuns(t.tokens).map(r => {
206
+ if (r instanceof TextRun) {
207
+ return new TextRun({ ...extractRunProps(r), color: COLOR.blockquoteText, italics: true });
208
+ }
209
+ return r;
210
+ });
211
+ out.push(new Paragraph({
212
+ children,
213
+ indent: { left: 480 },
214
+ border: { left: { style: BorderStyle.SINGLE, size: 12, color: COLOR.blockquoteBorder, space: 12 } },
215
+ spacing: { after: 80, line: 280 },
216
+ }));
217
+ } else if (t.type === "paragraph" && t.tokens) {
218
+ const children = inlineRuns(t.tokens).map(r => {
219
+ if (r instanceof TextRun) {
220
+ return new TextRun({ ...extractRunProps(r), color: COLOR.blockquoteText, italics: true });
221
+ }
222
+ return r;
223
+ });
224
+ out.push(new Paragraph({
225
+ children,
226
+ indent: { left: 480 },
227
+ border: { left: { style: BorderStyle.SINGLE, size: 12, color: COLOR.blockquoteBorder, space: 12 } },
228
+ spacing: { after: 80, line: 280 },
229
+ }));
230
+ }
231
+ }
232
+ out.push(new Paragraph({ spacing: { after: 80 } }));
233
+ break;
234
+ }
235
+
236
+ case "list": {
237
+ const items = tok.items ?? [];
238
+ items.forEach((item, i) => {
239
+ const children = [];
240
+
241
+ if (item.task) {
242
+ const checked = item.checked;
243
+ children.push(new TextRun({
244
+ text: checked ? "✓ " : "○ ",
245
+ font: "Segoe UI Symbol",
246
+ color: checked ? COLOR.taskGreen : COLOR.taskGray,
247
+ bold: checked,
248
+ }));
249
+ }
250
+
251
+ for (const t of item.tokens) {
252
+ if (t.type === "text") {
253
+ const taskDone = item.task && item.checked;
254
+ children.push(...inlineRuns(
255
+ t.tokens ?? [{ type: "text", text: t.text ?? "" }],
256
+ taskDone ? { strike: true, color: COLOR.muted } : {},
257
+ ));
258
+ } else if (t.type === "paragraph") {
259
+ const taskDone = item.task && item.checked;
260
+ children.push(...inlineRuns(
261
+ t.tokens,
262
+ taskDone ? { strike: true, color: COLOR.muted } : {},
263
+ ));
264
+ } else if (t.type === "list") {
265
+ // handled below
266
+ }
267
+ }
268
+
269
+ const bullet = tok.ordered
270
+ ? new TextRun({ text: `${(tok.start ?? 1) + i}. `, color: COLOR.heading2, bold: true })
271
+ : new TextRun({ text: item.task ? "" : "• ", color: COLOR.heading2 });
272
+
273
+ out.push(new Paragraph({
274
+ children: [bullet, ...children],
275
+ indent: { left: 360 },
276
+ spacing: { after: 60, line: 276 },
277
+ }));
278
+
279
+ // Nested lists
280
+ for (const t of item.tokens) {
281
+ if (t.type === "list") {
282
+ const nested = blockToParagraphs(t);
283
+ for (const p of nested) {
284
+ out.push(new Paragraph({
285
+ ...extractParaProps(p),
286
+ indent: { left: 720 },
287
+ spacing: { after: 60, line: 276 },
288
+ }));
289
+ }
290
+ }
291
+ }
292
+ });
293
+ out.push(new Paragraph({ spacing: { after: 80 } }));
294
+ break;
295
+ }
296
+
297
+ case "table": {
298
+ const headers = tok.header ?? [];
299
+ const rows = tok.rows ?? [];
300
+ const aligns = tok.align ?? [];
301
+
302
+ const alignMap = {
303
+ left: AlignmentType.LEFT,
304
+ right: AlignmentType.RIGHT,
305
+ center: AlignmentType.CENTER,
306
+ };
307
+
308
+ const cellBorder = {
309
+ top: { style: BorderStyle.SINGLE, size: 1, color: COLOR.tableBorder },
310
+ bottom: { style: BorderStyle.SINGLE, size: 1, color: COLOR.tableBorder },
311
+ left: { style: BorderStyle.SINGLE, size: 1, color: COLOR.tableBorder },
312
+ right: { style: BorderStyle.SINGLE, size: 1, color: COLOR.tableBorder },
313
+ };
314
+
315
+ // Compute column widths from content
316
+ const colWidths = headers.map((h, i) => {
317
+ const hLen = (h.tokens ?? []).map(t => t.text ?? t.raw ?? "").join("").length;
318
+ const maxCell = rows.reduce((m, r) => {
319
+ const cLen = (r[i]?.tokens ?? []).map(t => t.text ?? t.raw ?? "").join("").length;
320
+ return Math.max(m, cLen);
321
+ }, 0);
322
+ return Math.max(hLen, maxCell, 3);
323
+ });
324
+ const totalW = colWidths.reduce((s, w) => s + w, 0) || 1;
325
+ const colPcts = colWidths.map(w => Math.max(8, Math.round((w / totalW) * 100)));
326
+
327
+ // Header row
328
+ const headerCells = headers.map((h, i) => new TableCell({
329
+ children: [new Paragraph({
330
+ children: inlineRuns(h.tokens ?? []).map(r => {
331
+ if (r instanceof TextRun) {
332
+ return new TextRun({ ...extractRunProps(r), bold: true, color: "FFFFFF" });
333
+ }
334
+ return r;
335
+ }),
336
+ alignment: alignMap[aligns[i]] ?? AlignmentType.LEFT,
337
+ spacing: { before: 40, after: 40 },
338
+ })],
339
+ width: { size: colPcts[i], type: WidthType.PERCENTAGE },
340
+ shading: { type: ShadingType.CLEAR, fill: COLOR.tableHeader },
341
+ borders: cellBorder,
342
+ margins: { top: 40, bottom: 40, left: 80, right: 80 },
343
+ }));
344
+
345
+ const tableRows = [new TableRow({ children: headerCells, tableHeader: true })];
346
+
347
+ // Data rows with alternating shading
348
+ rows.forEach((row, rowIdx) => {
349
+ const cells = row.map((cell, i) => new TableCell({
350
+ children: [new Paragraph({
351
+ children: inlineRuns(cell.tokens ?? []),
352
+ alignment: alignMap[aligns[i]] ?? AlignmentType.LEFT,
353
+ spacing: { before: 20, after: 20 },
354
+ })],
355
+ width: { size: colPcts[i], type: WidthType.PERCENTAGE },
356
+ shading: rowIdx % 2 === 1
357
+ ? { type: ShadingType.CLEAR, fill: COLOR.tableAltBg }
358
+ : undefined,
359
+ borders: cellBorder,
360
+ margins: { top: 20, bottom: 20, left: 80, right: 80 },
361
+ }));
362
+ tableRows.push(new TableRow({ children: cells }));
363
+ });
364
+
365
+ out.push(new Table({
366
+ rows: tableRows,
367
+ width: { size: 100, type: WidthType.PERCENTAGE },
368
+ layout: TableLayoutType.FIXED,
369
+ columnWidths: colPcts.map(p => Math.round(p * 90)),
370
+ }));
371
+ out.push(new Paragraph({ spacing: { after: 200 } }));
372
+ break;
373
+ }
374
+
375
+ case "hr":
376
+ out.push(new Paragraph({
377
+ border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: "D9D9D9" } },
378
+ spacing: { before: 240, after: 240 },
379
+ }));
380
+ break;
381
+
382
+ case "space":
383
+ case "html":
384
+ case "def":
385
+ break;
386
+
387
+ default:
388
+ break;
389
+ }
390
+
391
+ return out;
392
+ }
393
+
394
+ // ─── Helpers to extract properties from existing objects ─────────────────────
395
+
396
+ function extractRunProps(run) {
397
+ // TextRun stores props internally; we reconstruct from the XML root
398
+ const props = {};
399
+ const root = run.root;
400
+ if (!root) return props;
401
+
402
+ // Walk the internal representation to pull out text and formatting
403
+ // This is a simplified extraction — we pass through common props
404
+ for (const child of root) {
405
+ if (typeof child === "string") continue;
406
+ if (child?.rootKey === "w:t") {
407
+ props.text = child.root?.[1] ?? "";
408
+ }
409
+ if (child?.rootKey === "w:rPr") {
410
+ for (const pr of child.root ?? []) {
411
+ if (pr?.rootKey === "w:b") props.bold = true;
412
+ if (pr?.rootKey === "w:i") props.italics = true;
413
+ if (pr?.rootKey === "w:strike") props.strike = true;
414
+ if (pr?.rootKey === "w:color") {
415
+ const val = pr.root?.find(a => a?.rootKey === "w:val" || a?._attr?.["w:val"]);
416
+ if (val?._attr?.["w:val"]) props.color = val._attr["w:val"];
417
+ }
418
+ if (pr?.rootKey === "w:rFonts") {
419
+ const val = pr.root?.find(a => a?._attr?.["w:ascii"]);
420
+ if (val?._attr?.["w:ascii"]) props.font = val._attr["w:ascii"];
421
+ }
422
+ if (pr?.rootKey === "w:sz") {
423
+ const val = pr.root?.find(a => a?._attr?.["w:val"]);
424
+ if (val?._attr?.["w:val"]) props.size = parseInt(val._attr["w:val"]);
425
+ }
426
+ }
427
+ }
428
+ }
429
+ return props;
430
+ }
431
+
432
+ function extractParaProps(para) {
433
+ // Return a minimal representation for re-creating paragraphs
434
+ return {
435
+ children: para.root?.[1]?.root?.filter(r => r instanceof TextRun || r instanceof ExternalHyperlink) ?? [],
436
+ };
437
+ }
438
+
439
+ // ─── Public API ─────────────────────────────────────────────────────────────
440
+
441
+ /**
442
+ * Convert marked tokens to a .docx buffer.
443
+ */
444
+ export async function toDocx(tokens, title = "Document") {
445
+ const children = tokens.flatMap(tok => blockToParagraphs(tok));
446
+
447
+ const doc = new Document({
448
+ title,
449
+ styles: {
450
+ default: {
451
+ document: {
452
+ run: { size: 24, font: "Calibri", color: COLOR.body },
453
+ paragraph: { spacing: { after: 160, line: 300 } },
454
+ },
455
+ heading1: {
456
+ run: { size: 36, font: "Calibri", bold: true, color: COLOR.heading1 },
457
+ paragraph: { spacing: { before: 360, after: 120 } },
458
+ },
459
+ heading2: {
460
+ run: { size: 30, font: "Calibri", bold: true, color: COLOR.heading2 },
461
+ paragraph: { spacing: { before: 280, after: 100 } },
462
+ },
463
+ heading3: {
464
+ run: { size: 26, font: "Calibri", bold: true, color: COLOR.heading3 },
465
+ paragraph: { spacing: { before: 240, after: 80 } },
466
+ },
467
+ heading4: {
468
+ run: { size: 24, font: "Calibri", bold: true, color: COLOR.heading4 },
469
+ paragraph: { spacing: { before: 200, after: 80 } },
470
+ },
471
+ heading5: {
472
+ run: { size: 22, font: "Calibri", italics: true, color: COLOR.heading4 },
473
+ paragraph: { spacing: { before: 160, after: 60 } },
474
+ },
475
+ heading6: {
476
+ run: { size: 22, font: "Calibri", color: COLOR.muted },
477
+ paragraph: { spacing: { before: 160, after: 60 } },
478
+ },
479
+ hyperlink: {
480
+ run: { color: COLOR.link, underline: { type: "single" } },
481
+ },
482
+ },
483
+ },
484
+ sections: [{
485
+ properties: {
486
+ page: {
487
+ margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
488
+ },
489
+ },
490
+ footers: {
491
+ default: new Footer({
492
+ children: [new Paragraph({
493
+ alignment: AlignmentType.CENTER,
494
+ children: [
495
+ new TextRun({ text: "Created with ", color: COLOR.muted, size: 16, font: "Calibri" }),
496
+ new ExternalHyperlink({
497
+ children: [new TextRun({
498
+ text: "mdcat",
499
+ color: COLOR.link,
500
+ underline: { type: "single" },
501
+ size: 16,
502
+ font: "Calibri",
503
+ })],
504
+ link: "https://mdcat.frankchan.dev",
505
+ }),
506
+ ],
507
+ })],
508
+ }),
509
+ },
510
+ children,
511
+ }],
512
+ });
513
+
514
+ return await Packer.toBuffer(doc);
515
+ }