@dunkinfrunkin/mdcat 0.1.15 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![CI](https://github.com/dunkinfrunkin/mdcat/actions/workflows/ci.yml/badge.svg)](https://github.com/dunkinfrunkin/mdcat/actions/workflows/ci.yml)
7
7
  [![site](https://img.shields.io/badge/site-mdcat.frankchan.dev-61afef)](https://mdcat.frankchan.dev)
8
8
 
9
- **Terminal pager for Markdown.** Full colour, syntax highlighting, incremental search, mouse support — zero config.
9
+ **Terminal pager for Markdown.** Full colour, syntax highlighting, git diff gutter, incremental search, mouse support — zero config.
10
10
 
11
11
  ```sh
12
12
  npm install -g @dunkinfrunkin/mdcat
@@ -33,6 +33,7 @@ npx @dunkinfrunkin/mdcat README.md
33
33
 
34
34
  ```sh
35
35
  mdcat README.md # open a file
36
+ mdcat file1.md file2.md file3.md # view multiple files
36
37
  mdcat --web README.md # render and open in browser
37
38
  mdcat -p README.md # plain text output (no TUI, no ANSI)
38
39
  mdcat -n README.md # show line numbers
@@ -97,6 +98,7 @@ MDCAT_THEME=light mdcat file.md # env var override
97
98
  | Images | `[alt text]` badge in dim |
98
99
  | Horizontal rules | Full-width dim `─` line |
99
100
  | HTML entities | Decoded (`&`, `<`, …) |
101
+ | Git diff gutter | Green `+` added, yellow `~` modified, red `-` deleted |
100
102
 
101
103
  ## Contributing
102
104
 
@@ -109,7 +111,7 @@ npm install
109
111
  npm test
110
112
  ```
111
113
 
112
- All PRs must pass `npm test` (90 tests).
114
+ All PRs must pass `npm test` (107 tests).
113
115
 
114
116
  See [CONTRIBUTING.md](CONTRIBUTING.md) for more details.
115
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dunkinfrunkin/mdcat",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "View markdown files beautifully in your terminal",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -8,6 +8,8 @@ import { renderTokens, setTheme } from "./render.js";
8
8
  import { launch } from "./tui.js";
9
9
  import { toDocx } from "./docx.js";
10
10
  import { detectTheme, themeFromArgs, stripThemeArgs } from "./theme.js";
11
+ import { concatFiles } from "./concat.js";
12
+ import { getDiffMap, mapDiffToRendered } from "./git.js";
11
13
 
12
14
  marked.use({ gfm: true });
13
15
 
@@ -27,7 +29,7 @@ if (args[0] === "--help" || args[0] === "-h") {
27
29
  console.log(`\n${CAT} ${bold("mdcat")} ${dim(`v${pkg.version}`)}`);
28
30
  console.log(`${dim(" markdown pager for your terminal")}\n`);
29
31
  console.log(`${bold("Usage:")}`);
30
- console.log(` mdcat ${dim("<file.md>")}`);
32
+ console.log(` mdcat ${dim("<file.md> [file2.md ...]")}`);
31
33
  console.log(` mdcat ${dim("--web <file.md>")} ${dim("# open in browser")}`);
32
34
  console.log(` mdcat ${dim("--doc <file.md>")} ${dim("# export to .docx")}`);
33
35
  console.log(` mdcat ${dim("-p, --plain")} ${dim("# plain output (no TUI, no ANSI)")}`);
@@ -149,12 +151,20 @@ function openInBrowser(title, content) {
149
151
  catch { console.error(`mdcat: could not open browser`); process.exit(1); }
150
152
  }
151
153
 
152
- function runTUI(title, content) {
154
+ function runTUI(title, content, filePath) {
153
155
  const termCols = process.stdout.columns || 80;
154
156
  const cols = Math.min(termCols, MAX_COLS);
155
157
  const tokens = marked.lexer(content);
156
158
  const lines = renderTokens(tokens, cols, termCols > MAX_COLS ? termCols : undefined);
157
- launch(title, lines, activeTheme, { lineNumbers: showLineNumbers });
159
+
160
+ let diffMap = new Map();
161
+ if (filePath) {
162
+ const sourceLineCount = content.split("\n").length;
163
+ const sourceDiffMap = getDiffMap(filePath, sourceLineCount);
164
+ diffMap = mapDiffToRendered(sourceDiffMap, sourceLineCount, lines.length);
165
+ }
166
+
167
+ launch(title, lines, activeTheme, { lineNumbers: showLineNumbers, diffMap });
158
168
  }
159
169
 
160
170
  // --web / --doc flags
@@ -183,20 +193,26 @@ if (!process.stdin.isTTY && fileArgs.length === 0) {
183
193
  else runTUI("stdin", input);
184
194
  });
185
195
  } else if (fileArgs.length === 0) {
186
- console.error("Usage: mdcat <file.md>");
196
+ console.error("Usage: mdcat <file.md> [file2.md ...]");
187
197
  process.exit(1);
188
198
  } else {
189
- const filePath = resolve(fileArgs[0]);
190
- let content;
191
- try {
192
- content = readFileSync(filePath, "utf8");
193
- } catch (err) {
194
- console.error(`mdcat: ${fileArgs[0]}: ${err.code === "ENOENT" ? "No such file" : err.message}`);
195
- process.exit(1);
199
+ const parts = [];
200
+ for (const arg of fileArgs) {
201
+ const filePath = resolve(arg);
202
+ let text;
203
+ try {
204
+ text = readFileSync(filePath, "utf8");
205
+ } catch (err) {
206
+ console.error(`mdcat: ${arg}: ${err.code === "ENOENT" ? "No such file" : err.message}`);
207
+ process.exit(1);
208
+ }
209
+ parts.push({ name: basename(filePath), content: text });
196
210
  }
197
- const title = basename(filePath);
211
+
212
+ const { title, content } = concatFiles(parts);
213
+
198
214
  if (docMode) exportDocx(title, content);
199
215
  else if (webMode) openInBrowser(title, content);
200
216
  else if (plainMode) runPlain(content);
201
- else runTUI(title, content);
217
+ else runTUI(title, content, filePath);
202
218
  }
package/src/concat.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Concatenate multiple file entries into a single markdown document.
3
+ * Each entry is { name, content }. When there is more than one file,
4
+ * a `## filename` heading and `---` separator are inserted between files.
5
+ * Returns { title, content }.
6
+ */
7
+ export function concatFiles(parts) {
8
+ if (parts.length === 0) return { title: "", content: "" };
9
+ if (parts.length === 1) return { title: parts[0].name, content: parts[0].content };
10
+ return {
11
+ title: `${parts.length} files`,
12
+ content: parts
13
+ .map(p => `## ${p.name}\n\n${p.content}`)
14
+ .join("\n\n---\n\n"),
15
+ };
16
+ }
package/src/git.js ADDED
@@ -0,0 +1,207 @@
1
+ import { execFileSync } from "child_process";
2
+ import { dirname } from "path";
3
+
4
+ /**
5
+ * Parse unified diff output into a Map of source line numbers to change types.
6
+ *
7
+ * Change types:
8
+ * "added" — line exists only in the working copy
9
+ * "modified" — line was changed (deletion + addition at the same position)
10
+ * "deleted" — a deletion occurred at or just before this line
11
+ *
12
+ * @param {string} diffOutput - raw unified diff text (from `git diff`)
13
+ * @returns {Map<number, string>} lineNumber (1-based) → "added" | "modified" | "deleted"
14
+ */
15
+ export function parseDiff(diffOutput) {
16
+ const map = new Map();
17
+ if (!diffOutput) return map;
18
+
19
+ const lines = diffOutput.split("\n");
20
+ let newLine = 0; // current line number in the new (working) file
21
+
22
+ for (let i = 0; i < lines.length; i++) {
23
+ const line = lines[i];
24
+
25
+ // Parse hunk header: @@ -oldstart,oldcount +newstart,newcount @@
26
+ const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
27
+ if (hunkMatch) {
28
+ const oldStart = parseInt(hunkMatch[1], 10);
29
+ const oldCount = hunkMatch[2] !== undefined ? parseInt(hunkMatch[2], 10) : 1;
30
+ const newStart = parseInt(hunkMatch[3], 10);
31
+ const newCount = hunkMatch[4] !== undefined ? parseInt(hunkMatch[4], 10) : 1;
32
+
33
+ newLine = newStart;
34
+
35
+ // Walk through hunk body lines
36
+ let j = i + 1;
37
+ let curNew = newStart;
38
+ let curOld = oldStart;
39
+
40
+ while (j < lines.length && !lines[j].startsWith("@@") && !lines[j].startsWith("diff ")) {
41
+ const hLine = lines[j];
42
+
43
+ if (hLine.startsWith("---") || hLine.startsWith("+++")) {
44
+ j++;
45
+ continue;
46
+ }
47
+
48
+ if (hLine.startsWith("-")) {
49
+ // Deletion — look ahead to see if the next line is an addition (modification)
50
+ let k = j + 1;
51
+ // Peek ahead: if the next non-delete line is a +, this is a modification
52
+ if (k < lines.length && lines[k].startsWith("+")) {
53
+ // This is a modification: the + line replaces the - line
54
+ map.set(curNew, "modified");
55
+ curOld++;
56
+ curNew++;
57
+ j = k + 1; // skip both - and + lines
58
+ continue;
59
+ }
60
+ // Pure deletion — mark the current new-file line
61
+ // If curNew hasn't gone past the file, mark it as a deletion point
62
+ if (!map.has(curNew)) {
63
+ map.set(curNew, "deleted");
64
+ }
65
+ curOld++;
66
+ j++;
67
+ continue;
68
+ }
69
+
70
+ if (hLine.startsWith("+")) {
71
+ // Pure addition
72
+ map.set(curNew, "added");
73
+ curNew++;
74
+ j++;
75
+ continue;
76
+ }
77
+
78
+ // Context line (starts with space or is empty after diff markers)
79
+ curNew++;
80
+ curOld++;
81
+ j++;
82
+ }
83
+
84
+ // Advance outer loop past the hunk body we just consumed
85
+ i = j - 1;
86
+ continue;
87
+ }
88
+ }
89
+
90
+ return map;
91
+ }
92
+
93
+ /**
94
+ * Check whether a file is tracked by git.
95
+ *
96
+ * @param {string} filePath - absolute path to the file
97
+ * @returns {boolean}
98
+ */
99
+ function isTracked(filePath) {
100
+ try {
101
+ execFileSync("git", ["ls-files", "--error-unmatch", filePath], {
102
+ cwd: dirname(filePath),
103
+ stdio: ["pipe", "pipe", "pipe"],
104
+ });
105
+ return true;
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Check whether a path is inside a git repository.
113
+ *
114
+ * @param {string} filePath - absolute path to the file
115
+ * @returns {boolean}
116
+ */
117
+ function isGitRepo(filePath) {
118
+ try {
119
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
120
+ cwd: dirname(filePath),
121
+ stdio: ["pipe", "pipe", "pipe"],
122
+ });
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Get a diff map for a file, mapping source line numbers to change types.
131
+ *
132
+ * Handles:
133
+ * - Not a git repo: returns empty map
134
+ * - Untracked file: returns map with every line marked "added"
135
+ * - Tracked file with changes: parses `git diff HEAD -- <file>`
136
+ * - Tracked file with no changes: returns empty map
137
+ *
138
+ * @param {string} filePath - absolute path to the file
139
+ * @param {number} totalLines - total number of lines in the file
140
+ * @returns {Map<number, string>} lineNumber (1-based) → "added" | "modified" | "deleted"
141
+ */
142
+ export function getDiffMap(filePath, totalLines) {
143
+ if (!isGitRepo(filePath)) {
144
+ return new Map();
145
+ }
146
+
147
+ if (!isTracked(filePath)) {
148
+ // Untracked file — everything is "added"
149
+ const map = new Map();
150
+ for (let i = 1; i <= totalLines; i++) {
151
+ map.set(i, "added");
152
+ }
153
+ return map;
154
+ }
155
+
156
+ try {
157
+ const diff = execFileSync("git", ["diff", "HEAD", "--", filePath], {
158
+ cwd: dirname(filePath),
159
+ encoding: "utf8",
160
+ stdio: ["pipe", "pipe", "pipe"],
161
+ });
162
+ return parseDiff(diff);
163
+ } catch {
164
+ return new Map();
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Map source-file diff markers to rendered output lines using proportional mapping.
170
+ *
171
+ * Since markdown rendering changes the line count (headings become boxes, code blocks
172
+ * get borders, etc.), we use a proportional mapping from source lines to rendered lines.
173
+ *
174
+ * @param {Map<number, string>} sourceDiffMap - from getDiffMap (1-based source lines)
175
+ * @param {number} sourceLineCount - total lines in the source file
176
+ * @param {number} renderedLineCount - total lines in the rendered output
177
+ * @returns {Map<number, string>} renderedLineIndex (0-based) → "added" | "modified" | "deleted"
178
+ */
179
+ export function mapDiffToRendered(sourceDiffMap, sourceLineCount, renderedLineCount) {
180
+ const rendered = new Map();
181
+ if (sourceDiffMap.size === 0 || sourceLineCount === 0 || renderedLineCount === 0) {
182
+ return rendered;
183
+ }
184
+
185
+ for (const [srcLine, type] of sourceDiffMap) {
186
+ // Proportional mapping: source line 1..N → rendered line 0..(M-1)
187
+ const ratio = (srcLine - 1) / Math.max(sourceLineCount - 1, 1);
188
+ const renderedLine = Math.round(ratio * (renderedLineCount - 1));
189
+ const clamped = Math.max(0, Math.min(renderedLine, renderedLineCount - 1));
190
+
191
+ // If a line already has a stronger marker, keep it
192
+ // Priority: modified > added > deleted
193
+ const existing = rendered.get(clamped);
194
+ if (!existing || priority(type) > priority(existing)) {
195
+ rendered.set(clamped, type);
196
+ }
197
+ }
198
+
199
+ return rendered;
200
+ }
201
+
202
+ function priority(type) {
203
+ if (type === "modified") return 3;
204
+ if (type === "added") return 2;
205
+ if (type === "deleted") return 1;
206
+ return 0;
207
+ }
package/src/tui.js CHANGED
@@ -143,6 +143,9 @@ export function launch(title, lines, theme, opts = {}) {
143
143
  const totalDigits = String(lines.length).length;
144
144
  const gutterW = totalDigits + 2; // " N " width
145
145
 
146
+ // Git diff map (rendered line index → "added" | "modified" | "deleted")
147
+ const diffMap = opts.diffMap ?? new Map();
148
+
146
149
  // Viewport state
147
150
  let offset = 0;
148
151
 
@@ -230,6 +233,15 @@ export function launch(title, lines, theme, opts = {}) {
230
233
  return `${C.chromeBg}${left}${C.dimFg}${" ".repeat(gap)}${cat}${RESET}`;
231
234
  }
232
235
 
236
+ function diffMarkerFor(absLine) {
237
+ const type = diffMap.get(absLine);
238
+ if (!type) return "";
239
+ if (type === "added") return `${C.greenFg}+${RESET}`;
240
+ if (type === "modified") return `${C.matchFg}~${RESET}`;
241
+ if (type === "deleted") return `${C.redFg}-${RESET}`;
242
+ return "";
243
+ }
244
+
233
245
  function gutterFor(absLine) {
234
246
  let prefix = "";
235
247
  if (lineNumbers) {
@@ -237,10 +249,13 @@ export function launch(title, lines, theme, opts = {}) {
237
249
  prefix = `${C.dimFg}${num}${RESET} `;
238
250
  }
239
251
 
240
- if (mode === "normal" || !searchQuery) return prefix || " ";
241
- if (matchLines[matchIdx] === absLine) return `${prefix}${C.matchFg}▶${RESET} `;
242
- if (matchSet.has(absLine)) return `${prefix}${C.otherMatchFg}›${RESET} `;
243
- return prefix || " ";
252
+ // Diff gutter marker (single char column before content)
253
+ const diff = diffMap.size > 0 ? (diffMarkerFor(absLine) || " ") : "";
254
+
255
+ if (mode === "normal" || !searchQuery) return `${prefix}${diff}${diff ? " " : prefix ? "" : " "}`;
256
+ if (matchLines[matchIdx] === absLine) return `${prefix}${diff ? diff + " " : ""}${C.matchFg}▶${RESET} `;
257
+ if (matchSet.has(absLine)) return `${prefix}${diff ? diff + " " : ""}${C.otherMatchFg}›${RESET} `;
258
+ return `${prefix}${diff}${diff ? " " : prefix ? "" : " "}`;
244
259
  }
245
260
 
246
261
  function statusBar(w) {