@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 +4 -2
- package/package.json +1 -1
- package/src/cli.js +29 -13
- package/src/concat.js +16 -0
- package/src/git.js +207 -0
- package/src/tui.js +19 -4
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[](https://github.com/dunkinfrunkin/mdcat/actions/workflows/ci.yml)
|
|
7
7
|
[](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` (
|
|
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
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
|
-
|
|
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
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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) {
|