memd-cli 1.5.1 → 2.0.1
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/.claude/settings.local.json +6 -1
- package/README.md +19 -5
- package/main.js +337 -214
- package/package.json +6 -3
- package/test/memd.test.js +199 -1
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
"Bash(echo:*)",
|
|
5
5
|
"Bash(FORCE_COLOR=1 echo:*)",
|
|
6
6
|
"Bash(script:*)",
|
|
7
|
-
"Bash(FORCE_COLOR=1 node:*)"
|
|
7
|
+
"Bash(FORCE_COLOR=1 node:*)",
|
|
8
|
+
"Bash(node -e \"import\\('beautiful-mermaid/src/ascii/ansi.ts'\\).then\\(m=>console.log\\(Object.keys\\(m\\)\\)\\).catch\\(e=>console.error\\('ERROR:',e.message\\)\\)\" 2>&1 | head -5)",
|
|
9
|
+
"Bash(node main.js test/test-highlight.md --no-pager --theme catppuccin-mocha 2>&1 | head -3 | xxd | head -10)",
|
|
10
|
+
"Bash(FORCE_COLOR=1 node main.js test/test-highlight.md --no-pager --theme catppuccin-mocha 2>&1 | head -3 | xxd | head -5)",
|
|
11
|
+
"Bash(FORCE_COLOR=1 node main.js test/test-highlight.md --no-pager --theme zinc-dark 2>&1 | head -3 | xxd | head -5)",
|
|
12
|
+
"mcp__grep-github__searchGitHub"
|
|
8
13
|
]
|
|
9
14
|
}
|
|
10
15
|
}
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# memd
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Render markdown with mermaid diagrams
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -14,7 +14,7 @@ npm install -g memd-cli
|
|
|
14
14
|
```
|
|
15
15
|
Usage: memd [options] [files...]
|
|
16
16
|
|
|
17
|
-
Render markdown with mermaid diagrams
|
|
17
|
+
Render markdown with mermaid diagrams
|
|
18
18
|
|
|
19
19
|
Arguments:
|
|
20
20
|
files markdown file(s) to render
|
|
@@ -26,8 +26,12 @@ Options:
|
|
|
26
26
|
--no-color disable colored output
|
|
27
27
|
--width <number> terminal width override
|
|
28
28
|
--ascii use pure ASCII mode for diagrams (default: unicode)
|
|
29
|
-
--
|
|
30
|
-
|
|
29
|
+
--html output as standalone HTML (mermaid diagrams rendered as inline SVG)
|
|
30
|
+
--theme <name> color theme (default: "nord")
|
|
31
|
+
zinc-light, zinc-dark, tokyo-night, tokyo-night-storm,
|
|
32
|
+
tokyo-night-light, catppuccin-mocha, catppuccin-latte,
|
|
33
|
+
nord, nord-light, dracula, github-light, github-dark,
|
|
34
|
+
solarized-light, solarized-dark, one-dark
|
|
31
35
|
-h, --help display help for command
|
|
32
36
|
```
|
|
33
37
|
|
|
@@ -289,6 +293,16 @@ This is regular text between mermaid diagrams.
|
|
|
289
293
|
|
|
290
294
|
```
|
|
291
295
|
|
|
296
|
+
### HTML output
|
|
297
|
+
|
|
298
|
+
HTML is written to stdout. Use shell redirection to save to a file.
|
|
299
|
+
|
|
300
|
+
```
|
|
301
|
+
$ memd doc.md --html > out.html
|
|
302
|
+
$ memd doc.md --html --theme dracula > out.html
|
|
303
|
+
$ memd a.md b.md --html > combined.html
|
|
304
|
+
```
|
|
305
|
+
|
|
292
306
|
### Stdin input
|
|
293
307
|
|
|
294
308
|
```
|
|
@@ -318,7 +332,7 @@ node main.js test/test1.md
|
|
|
318
332
|
|
|
319
333
|
```bash
|
|
320
334
|
# tag
|
|
321
|
-
npm install -g git+https://github.com/ktrysmt/memd.git#
|
|
335
|
+
npm install -g git+https://github.com/ktrysmt/memd.git#v2.0.0
|
|
322
336
|
# branch
|
|
323
337
|
npm install -g git+https://github.com/ktrysmt/memd.git#master
|
|
324
338
|
npm install -g git+https://github.com/ktrysmt/memd.git#feature
|
package/main.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// @ts-nocheck
|
|
3
|
-
import { marked } from 'marked';
|
|
3
|
+
import { marked, Marked } from 'marked';
|
|
4
4
|
import { markedTerminal } from 'marked-terminal';
|
|
5
|
-
import { renderMermaidASCII } from 'beautiful-mermaid';
|
|
6
|
-
import { highlight, supportsLanguage, plain } from 'cli-highlight';
|
|
5
|
+
import { renderMermaidASCII, renderMermaidSVG, THEMES as MERMAID_THEMES } from 'beautiful-mermaid';
|
|
7
6
|
import chalk from 'chalk';
|
|
8
7
|
import { program } from 'commander';
|
|
9
8
|
import { spawn } from 'child_process';
|
|
@@ -11,151 +10,198 @@ import * as fs from 'fs';
|
|
|
11
10
|
import * as path from 'path';
|
|
12
11
|
import { fileURLToPath } from 'url';
|
|
13
12
|
|
|
13
|
+
import { createHighlighterCoreSync } from 'shiki/core';
|
|
14
|
+
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
|
|
15
|
+
import { bundledThemes } from 'shiki/themes';
|
|
16
|
+
import { bundledLanguages } from 'shiki/langs';
|
|
17
|
+
|
|
14
18
|
const __filename = fileURLToPath(import.meta.url);
|
|
15
19
|
const __dirname = path.dirname(__filename);
|
|
16
20
|
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf-8'));
|
|
17
21
|
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
22
|
+
// Shiki theme IDs used by memd (keys into bundledThemes)
|
|
23
|
+
const SHIKI_THEME_IDS = [
|
|
24
|
+
'nord', 'dracula', 'one-dark-pro',
|
|
25
|
+
'github-dark', 'github-light',
|
|
26
|
+
'solarized-dark', 'solarized-light',
|
|
27
|
+
'catppuccin-mocha', 'catppuccin-latte',
|
|
28
|
+
'tokyo-night', 'one-light',
|
|
29
|
+
'everforest-light', 'min-dark', 'min-light',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// Language IDs to preload (top 19 for >95% real-world coverage)
|
|
33
|
+
const SHIKI_LANG_IDS = [
|
|
34
|
+
'javascript', 'typescript', 'python', 'shellscript',
|
|
35
|
+
'go', 'rust', 'java', 'c', 'cpp',
|
|
36
|
+
'ruby', 'php', 'html', 'css', 'json',
|
|
37
|
+
'yaml', 'toml', 'sql', 'markdown', 'diff',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
let _highlighter;
|
|
41
|
+
async function getHighlighter() {
|
|
42
|
+
if (!_highlighter) {
|
|
43
|
+
const [themes, langs] = await Promise.all([
|
|
44
|
+
Promise.all(SHIKI_THEME_IDS.map(id => bundledThemes[id]().then(m => m.default))),
|
|
45
|
+
Promise.all(SHIKI_LANG_IDS.map(id => bundledLanguages[id]().then(m => m.default))),
|
|
46
|
+
]);
|
|
47
|
+
_highlighter = createHighlighterCoreSync({
|
|
48
|
+
themes,
|
|
49
|
+
langs,
|
|
50
|
+
engine: createJavaScriptRegexEngine(),
|
|
51
|
+
});
|
|
35
52
|
}
|
|
36
|
-
return
|
|
53
|
+
return _highlighter;
|
|
37
54
|
}
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
// Maps memd theme name -> { shikiTheme: string (Shiki theme ID), mermaidTheme: string }
|
|
57
|
+
const THEME_MAP = {
|
|
58
|
+
'nord': { shikiTheme: 'nord', mermaidTheme: 'nord' },
|
|
59
|
+
'dracula': { shikiTheme: 'dracula', mermaidTheme: 'dracula' },
|
|
60
|
+
'one-dark': { shikiTheme: 'one-dark-pro', mermaidTheme: 'one-dark' },
|
|
61
|
+
'github-dark': { shikiTheme: 'github-dark', mermaidTheme: 'github-dark' },
|
|
62
|
+
'github-light': { shikiTheme: 'github-light', mermaidTheme: 'github-light' },
|
|
63
|
+
'solarized-dark': { shikiTheme: 'solarized-dark', mermaidTheme: 'solarized-dark' },
|
|
64
|
+
'solarized-light': { shikiTheme: 'solarized-light', mermaidTheme: 'solarized-light' },
|
|
65
|
+
'catppuccin-mocha': { shikiTheme: 'catppuccin-mocha', mermaidTheme: 'catppuccin-mocha' },
|
|
66
|
+
'catppuccin-latte': { shikiTheme: 'catppuccin-latte', mermaidTheme: 'catppuccin-latte' },
|
|
67
|
+
'tokyo-night': { shikiTheme: 'tokyo-night', mermaidTheme: 'tokyo-night' },
|
|
68
|
+
'tokyo-night-storm': { shikiTheme: 'tokyo-night', mermaidTheme: 'tokyo-night-storm' },
|
|
69
|
+
'tokyo-night-light': { shikiTheme: 'one-light', mermaidTheme: 'tokyo-night-light' },
|
|
70
|
+
'nord-light': { shikiTheme: 'everforest-light', mermaidTheme: 'nord-light' },
|
|
71
|
+
'zinc-dark': { shikiTheme: 'min-dark', mermaidTheme: 'zinc-dark' },
|
|
72
|
+
'zinc-light': { shikiTheme: 'min-light', mermaidTheme: 'zinc-light' },
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Single source of truth for available theme names (used in --help and validation)
|
|
76
|
+
const THEME_NAMES = Object.keys(THEME_MAP);
|
|
77
|
+
|
|
78
|
+
// Color mixing: blend hex1 into hex2 at pct% (sRGB linear interpolation)
|
|
79
|
+
// Equivalent to CSS color-mix(in srgb, hex1 pct%, hex2)
|
|
80
|
+
function mixHex(hex1, hex2, pct) {
|
|
81
|
+
const p = pct / 100;
|
|
82
|
+
const parse = (h, o) => parseInt(h.slice(o, o + 2), 16);
|
|
83
|
+
const mix = (c1, c2) => Math.round(c1 * p + c2 * (1 - p));
|
|
84
|
+
const toHex = x => x.toString(16).padStart(2, '0');
|
|
85
|
+
const r = mix(parse(hex1, 1), parse(hex2, 1));
|
|
86
|
+
const g = mix(parse(hex1, 3), parse(hex2, 3));
|
|
87
|
+
const b = mix(parse(hex1, 5), parse(hex2, 5));
|
|
60
88
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
61
89
|
}
|
|
62
90
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
91
|
+
// MIX ratios from beautiful-mermaid theme.ts:64-87
|
|
92
|
+
const MIX = { line: 50, arrow: 85, textSec: 60, nodeStroke: 20 };
|
|
93
|
+
|
|
94
|
+
// Gentle desaturation by blending toward neutral gray (#808080) in sRGB space.
|
|
95
|
+
// amount=0.15 means 15% toward gray. Replaces the old mute() (HSL-based) with a simpler,
|
|
96
|
+
// more predictable sRGB blend that avoids hue shifts from HSL rounding.
|
|
97
|
+
function softenHex(hex, amount = 0.15) {
|
|
98
|
+
const parse = (h, o) => parseInt(h.slice(o, o + 2), 16);
|
|
99
|
+
const mix = (c, gray) => Math.round(c * (1 - amount) + gray * amount);
|
|
100
|
+
const toHex = x => x.toString(16).padStart(2, '0');
|
|
101
|
+
const r = mix(parse(hex, 1), 128);
|
|
102
|
+
const g = mix(parse(hex, 3), 128);
|
|
103
|
+
const b = mix(parse(hex, 5), 128);
|
|
104
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function tokensToAnsi(lines) {
|
|
108
|
+
if (chalk.level === 0) {
|
|
109
|
+
// --no-color: return plain text without ANSI codes
|
|
110
|
+
return lines.map(line => line.map(t => t.content).join('')).join('\n');
|
|
111
|
+
}
|
|
112
|
+
return lines.map(line =>
|
|
113
|
+
line.map(token => {
|
|
114
|
+
if (!token.color) return token.content;
|
|
115
|
+
const r = parseInt(token.color.slice(1, 3), 16);
|
|
116
|
+
const g = parseInt(token.color.slice(3, 5), 16);
|
|
117
|
+
const b = parseInt(token.color.slice(5, 7), 16);
|
|
118
|
+
return `\x1b[38;2;${r};${g};${b}m${token.content}\x1b[39m`;
|
|
119
|
+
}).join('')
|
|
120
|
+
).join('\n');
|
|
69
121
|
}
|
|
70
122
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
123
|
+
function highlightWithShikiSync(hl, code, lang, shikiThemeName) {
|
|
124
|
+
const loadedLangs = hl.getLoadedLanguages();
|
|
125
|
+
const effectiveLang = loadedLangs.includes(lang) ? lang : 'text';
|
|
126
|
+
|
|
127
|
+
if (effectiveLang === 'text') {
|
|
128
|
+
return code;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const tokens = hl.codeToTokensBase(code, {
|
|
132
|
+
lang: effectiveLang,
|
|
133
|
+
theme: shikiThemeName,
|
|
134
|
+
});
|
|
135
|
+
return tokensToAnsi(tokens);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function extractMarkdownStyleSync(hl, shikiThemeName) {
|
|
139
|
+
const theme = hl.getTheme(shikiThemeName);
|
|
140
|
+
|
|
141
|
+
const findColor = (...scopes) => {
|
|
142
|
+
for (const scope of scopes) {
|
|
143
|
+
const setting = theme.settings?.find(s => {
|
|
144
|
+
if (!s.scope) return false;
|
|
145
|
+
const scopeList = Array.isArray(s.scope) ? s.scope : [s.scope];
|
|
146
|
+
return scopeList.some(sc => sc === scope || sc.startsWith(scope + '.'));
|
|
147
|
+
});
|
|
148
|
+
if (setting?.settings?.foreground) return setting.settings.foreground;
|
|
149
|
+
}
|
|
150
|
+
return theme.fg;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const accent = softenHex(findColor('entity.name.function', 'support.function', 'keyword'));
|
|
154
|
+
const string = softenHex(findColor('string', 'string.quoted'));
|
|
155
|
+
const comment = softenHex(findColor('comment', 'punctuation.definition.comment'));
|
|
156
|
+
const type = softenHex(findColor('entity.name.type', 'support.type', 'storage.type'));
|
|
157
|
+
const param = softenHex(findColor('variable.parameter', 'variable.other', 'entity.name.tag'));
|
|
158
|
+
const fg = theme.fg;
|
|
159
|
+
|
|
74
160
|
return {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
class: chalk.hex(m(colors.type)),
|
|
86
|
-
function: chalk.hex(m(colors.func)),
|
|
87
|
-
title: chalk.hex(m(colors.func)),
|
|
88
|
-
params: chalk.hex(m(colors.param)).italic,
|
|
89
|
-
comment: chalk.hex(m(colors.comment)).italic,
|
|
90
|
-
doctag: chalk.hex(m(colors.comment)).bold,
|
|
91
|
-
meta: chalk.hex(m(colors.comment)),
|
|
92
|
-
'meta-keyword': chalk.hex(m(colors.keyword)),
|
|
93
|
-
'meta-string': chalk.hex(m(colors.string)),
|
|
94
|
-
tag: chalk.hex(m(colors.keyword)),
|
|
95
|
-
name: chalk.hex(m(colors.keyword)),
|
|
96
|
-
attr: chalk.hex(m(colors.func)),
|
|
97
|
-
attribute: chalk.hex(m(colors.func)),
|
|
98
|
-
variable: chalk.hex(m(colors.fg)),
|
|
99
|
-
addition: chalk.hex(m(colors.added)),
|
|
100
|
-
deletion: chalk.hex(m(colors.deleted)),
|
|
101
|
-
default: plain,
|
|
102
|
-
},
|
|
103
|
-
markdown: {
|
|
104
|
-
firstHeading: chalk.hex(colors.heading1).underline.bold,
|
|
105
|
-
heading: chalk.hex(colors.heading2).bold,
|
|
106
|
-
code: chalk.hex(colors.string),
|
|
107
|
-
codespan: chalk.hex(colors.string),
|
|
108
|
-
blockquote: chalk.hex(colors.comment).italic,
|
|
109
|
-
strong: chalk.hex(colors.fg).bold,
|
|
110
|
-
em: chalk.hex(colors.param).italic,
|
|
111
|
-
del: chalk.hex(colors.comment).strikethrough,
|
|
112
|
-
link: chalk.hex(colors.type),
|
|
113
|
-
href: chalk.hex(colors.type).underline,
|
|
114
|
-
},
|
|
161
|
+
firstHeading: chalk.hex(accent).underline.bold,
|
|
162
|
+
heading: chalk.hex(accent).bold,
|
|
163
|
+
code: chalk.hex(string),
|
|
164
|
+
codespan: chalk.hex(string),
|
|
165
|
+
blockquote: chalk.hex(comment).italic,
|
|
166
|
+
strong: chalk.hex(fg).bold,
|
|
167
|
+
em: chalk.hex(param).italic,
|
|
168
|
+
del: chalk.hex(comment).strikethrough,
|
|
169
|
+
link: chalk.hex(type),
|
|
170
|
+
href: chalk.hex(type).underline,
|
|
115
171
|
};
|
|
116
172
|
}
|
|
117
173
|
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
added: '#50FA7B', deleted: '#FF5555',
|
|
134
|
-
}),
|
|
135
|
-
'github-dark': buildTheme({
|
|
136
|
-
keyword: '#FF7B72', func: '#D2A8FF', string: '#A5D6FF',
|
|
137
|
-
type: '#FFA657', number: '#79C0FF', literal: '#79C0FF',
|
|
138
|
-
param: '#C9D1D9', comment: '#8B949E', fg: '#C9D1D9',
|
|
139
|
-
heading1: '#FFFFFF', heading2:'#79C0FF',
|
|
140
|
-
added: '#7EE787', deleted: '#FF7B72',
|
|
141
|
-
}),
|
|
142
|
-
solarized: buildTheme({
|
|
143
|
-
keyword: '#859900', func: '#268BD2', string: '#2AA198',
|
|
144
|
-
type: '#B58900', number: '#D33682', literal: '#2AA198',
|
|
145
|
-
param: '#93A1A1', comment: '#586E75', fg: '#93A1A1',
|
|
146
|
-
heading1: '#CB4B16', heading2:'#268BD2',
|
|
147
|
-
added: '#859900', deleted: '#DC322F',
|
|
148
|
-
}),
|
|
149
|
-
nord: buildTheme({
|
|
150
|
-
keyword: '#81A1C1', func: '#88C0D0', string: '#A3BE8C',
|
|
151
|
-
type: '#8FBCBB', number: '#B48EAD', literal: '#81A1C1',
|
|
152
|
-
param: '#D8DEE9', comment: '#616E88', fg: '#D8DEE9',
|
|
153
|
-
heading1: '#88C0D0', heading2:'#81A1C1',
|
|
154
|
-
added: '#A3BE8C', deleted: '#BF616A',
|
|
155
|
-
}),
|
|
156
|
-
};
|
|
174
|
+
// DiagramColors -> AsciiTheme conversion (equivalent to beautiful-mermaid's internal diagramColorsToAsciiTheme)
|
|
175
|
+
function diagramColorsToAsciiTheme(colors) {
|
|
176
|
+
const line = colors.line ?? mixHex(colors.fg, colors.bg, MIX.line);
|
|
177
|
+
const border = colors.border ?? mixHex(colors.fg, colors.bg, MIX.nodeStroke);
|
|
178
|
+
return {
|
|
179
|
+
fg: colors.fg,
|
|
180
|
+
bg: colors.bg,
|
|
181
|
+
line,
|
|
182
|
+
border,
|
|
183
|
+
arrow: colors.accent ?? mixHex(colors.fg, colors.bg, MIX.arrow),
|
|
184
|
+
accent: colors.accent,
|
|
185
|
+
corner: line,
|
|
186
|
+
junction: border,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
157
189
|
|
|
158
|
-
|
|
190
|
+
// Resolve optional DiagramColors fields for HTML template CSS
|
|
191
|
+
function resolveThemeColors(colors) {
|
|
192
|
+
return {
|
|
193
|
+
bg: colors.bg,
|
|
194
|
+
fg: colors.fg,
|
|
195
|
+
line: colors.line ?? mixHex(colors.fg, colors.bg, MIX.line),
|
|
196
|
+
accent: colors.accent ?? mixHex(colors.fg, colors.bg, MIX.arrow),
|
|
197
|
+
muted: colors.muted ?? mixHex(colors.fg, colors.bg, MIX.textSec),
|
|
198
|
+
border: colors.border ?? mixHex(colors.fg, colors.bg, MIX.nodeStroke),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function escapeHtml(str) {
|
|
203
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
204
|
+
}
|
|
159
205
|
|
|
160
206
|
function readMarkdownFile(filePath) {
|
|
161
207
|
const absolutePath = path.resolve(filePath);
|
|
@@ -171,20 +217,25 @@ function readMarkdownFile(filePath) {
|
|
|
171
217
|
throw new Error('File not found: ' + absolutePath);
|
|
172
218
|
}
|
|
173
219
|
|
|
174
|
-
|
|
220
|
+
// Resolve symlinks and re-check to prevent symlink-based traversal
|
|
221
|
+
const realPath = fs.realpathSync(absolutePath);
|
|
222
|
+
const realRelative = path.relative(currentDirResolved, realPath);
|
|
223
|
+
if (realRelative.startsWith('..') || path.isAbsolute(realRelative)) {
|
|
224
|
+
throw new Error('Invalid path: access outside current directory is not allowed');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return fs.readFileSync(realPath, 'utf-8');
|
|
175
228
|
}
|
|
176
229
|
|
|
177
|
-
function convertMermaidToAscii(markdown,
|
|
230
|
+
function convertMermaidToAscii(markdown, { useAscii = false, colorMode, theme } = {}) {
|
|
178
231
|
const mermaidRegex = /```mermaid\s+([\s\S]+?)```/g;
|
|
179
232
|
|
|
180
233
|
return markdown.replace(mermaidRegex, function (_, code) {
|
|
181
234
|
try {
|
|
182
235
|
const mermaidOptions = {};
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
// Set color mode based on --no-color flag
|
|
187
|
-
mermaidOptions.colorMode = options.color === false ? 'none' : 'none';
|
|
236
|
+
if (useAscii) mermaidOptions.useAscii = true;
|
|
237
|
+
if (colorMode !== undefined) mermaidOptions.colorMode = colorMode;
|
|
238
|
+
if (theme) mermaidOptions.theme = theme;
|
|
188
239
|
const asciiArt = renderMermaidASCII(code.trim(), mermaidOptions);
|
|
189
240
|
return '```text\n' + asciiArt + '\n```';
|
|
190
241
|
} catch (error) {
|
|
@@ -196,17 +247,56 @@ function convertMermaidToAscii(markdown, options = {}) {
|
|
|
196
247
|
});
|
|
197
248
|
}
|
|
198
249
|
|
|
199
|
-
function
|
|
200
|
-
const
|
|
201
|
-
let
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
250
|
+
function convertMermaidToSVG(markdown, diagramTheme) {
|
|
251
|
+
const mermaidRegex = /```mermaid\s+([\s\S]+?)```/g;
|
|
252
|
+
let svgIndex = 0;
|
|
253
|
+
return markdown.replace(mermaidRegex, (_, code) => {
|
|
254
|
+
try {
|
|
255
|
+
const prefix = `m${svgIndex++}`;
|
|
256
|
+
let svg = renderMermaidSVG(code.trim(), diagramTheme);
|
|
257
|
+
svg = svg.replace(/@import url\([^)]+\);\s*/g, '');
|
|
258
|
+
// Prefix all id="..." and url(#...) to avoid cross-SVG collisions
|
|
259
|
+
// Note: regex uses ` id=` (with leading space) to avoid matching `data-id`
|
|
260
|
+
svg = svg.replace(/ id="([^"]+)"/g, ` id="${prefix}-$1"`);
|
|
261
|
+
svg = svg.replace(/url\(#([^)]+)\)/g, `url(#${prefix}-$1)`);
|
|
262
|
+
return svg;
|
|
263
|
+
} catch (e) {
|
|
264
|
+
return `<pre class="mermaid-error">${escapeHtml(e.message)}\n\n${escapeHtml(code.trim())}</pre>`;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
208
268
|
|
|
209
|
-
|
|
269
|
+
function renderToHTML(markdown, diagramColors) {
|
|
270
|
+
const processed = convertMermaidToSVG(markdown, diagramColors);
|
|
271
|
+
const htmlMarked = new Marked();
|
|
272
|
+
const body = htmlMarked.parse(processed);
|
|
273
|
+
const t = resolveThemeColors(diagramColors);
|
|
274
|
+
|
|
275
|
+
return `<!DOCTYPE html>
|
|
276
|
+
<html lang="en">
|
|
277
|
+
<head>
|
|
278
|
+
<meta charset="utf-8">
|
|
279
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
280
|
+
<style>
|
|
281
|
+
body { background: ${t.bg}; color: ${t.fg}; font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
|
|
282
|
+
a { color: ${t.accent}; }
|
|
283
|
+
hr { border-color: ${t.line}; }
|
|
284
|
+
blockquote { border-left: 3px solid ${t.line}; color: ${t.muted}; padding-left: 1rem; }
|
|
285
|
+
svg { max-width: 100%; height: auto; }
|
|
286
|
+
pre { background: color-mix(in srgb, ${t.fg} 8%, ${t.bg}); padding: 1rem; border-radius: 6px; overflow-x: auto; }
|
|
287
|
+
code { font-size: 0.9em; color: ${t.accent}; }
|
|
288
|
+
pre code { color: inherit; }
|
|
289
|
+
table { border-collapse: collapse; }
|
|
290
|
+
th, td { border: 1px solid ${t.line}; padding: 0.4rem 0.8rem; }
|
|
291
|
+
th { background: color-mix(in srgb, ${t.fg} 5%, ${t.bg}); }
|
|
292
|
+
.mermaid-error { background: color-mix(in srgb, ${t.accent} 10%, ${t.bg}); border: 1px solid color-mix(in srgb, ${t.accent} 40%, ${t.bg}); color: ${t.fg}; padding: 1rem; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; }
|
|
293
|
+
</style>
|
|
294
|
+
</head>
|
|
295
|
+
<body>
|
|
296
|
+
${body.trimEnd()}
|
|
297
|
+
</body>
|
|
298
|
+
</html>
|
|
299
|
+
`;
|
|
210
300
|
}
|
|
211
301
|
|
|
212
302
|
function readStdin() {
|
|
@@ -257,6 +347,11 @@ function spawnPager(text, options) {
|
|
|
257
347
|
}
|
|
258
348
|
});
|
|
259
349
|
|
|
350
|
+
// Ignore EPIPE on stdin - expected when user quits pager before all content is consumed
|
|
351
|
+
pager.stdin.on('error', (err) => {
|
|
352
|
+
if (err.code !== 'EPIPE') throw err;
|
|
353
|
+
});
|
|
354
|
+
|
|
260
355
|
pager.stdin.write(text);
|
|
261
356
|
pager.stdin.end();
|
|
262
357
|
|
|
@@ -269,71 +364,31 @@ async function main() {
|
|
|
269
364
|
program
|
|
270
365
|
.name('memd')
|
|
271
366
|
.version(packageJson.version, '-v, --version', 'output the version number')
|
|
272
|
-
.description('Render markdown with mermaid diagrams
|
|
367
|
+
.description('Render markdown with mermaid diagrams')
|
|
273
368
|
.argument('[files...]', 'markdown file(s) to render')
|
|
274
369
|
.option('--no-pager', 'disable pager (less)')
|
|
275
370
|
.option('--no-mouse', 'disable mouse scroll in pager')
|
|
276
371
|
.option('--no-color', 'disable colored output')
|
|
277
372
|
.option('--width <number>', 'terminal width override', Number)
|
|
278
373
|
.option('--ascii', 'use pure ASCII mode for diagrams (default: unicode)')
|
|
279
|
-
.option('--
|
|
374
|
+
.option('--html', 'output as standalone HTML (mermaid diagrams rendered as inline SVG)')
|
|
375
|
+
.option('--theme <name>', `color theme\n${THEME_NAMES.join(', ')}`, 'nord')
|
|
280
376
|
.action(async (files, options) => {
|
|
281
|
-
// Validate theme
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
console.error(`Unknown theme: ${
|
|
377
|
+
// 1. Validate theme via THEME_MAP (unified for both paths)
|
|
378
|
+
if (!(options.theme in THEME_MAP)) {
|
|
379
|
+
const names = Object.keys(THEME_MAP).join(', ');
|
|
380
|
+
console.error(`Unknown theme: ${options.theme}\nAvailable themes: ${names}`);
|
|
285
381
|
process.exit(1);
|
|
286
382
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const shouldHighlight = useColor;
|
|
293
|
-
|
|
294
|
-
// Configure marked with terminal renderer
|
|
295
|
-
const selectedTheme = THEMES[themeName];
|
|
296
|
-
const highlightOptions = shouldHighlight
|
|
297
|
-
? {
|
|
298
|
-
ignoreIllegals: true,
|
|
299
|
-
...(selectedTheme.highlight ? { theme: selectedTheme.highlight } : {}),
|
|
300
|
-
}
|
|
301
|
-
: undefined;
|
|
302
|
-
|
|
303
|
-
marked.use(markedTerminal({
|
|
304
|
-
reflowText: true,
|
|
305
|
-
width: options.width ?? process.stdout.columns ?? 80,
|
|
306
|
-
...selectedTheme.markdown,
|
|
307
|
-
}, highlightOptions));
|
|
308
|
-
|
|
309
|
-
// Override link renderer to avoid OSC 8 escape sequences (fixes tmux display issues)
|
|
310
|
-
marked.use({
|
|
311
|
-
renderer: {
|
|
312
|
-
link(token) {
|
|
313
|
-
// URLとテキストを両方表示する場合
|
|
314
|
-
return token.text + (token.text !== token.href ? ` (${token.href})` : '');
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
// If highlighting is disabled, override the code renderer to bypass cli-highlight
|
|
320
|
-
if (!shouldHighlight) {
|
|
321
|
-
marked.use({
|
|
322
|
-
renderer: {
|
|
323
|
-
code(token) {
|
|
324
|
-
// Extract code text from token
|
|
325
|
-
const codeText = typeof token === 'string' ? token : (token.text || token);
|
|
326
|
-
// Return plain code without highlighting
|
|
327
|
-
// Note: We still need to format it as marked-terminal would
|
|
328
|
-
const lines = String(codeText).split('\n');
|
|
329
|
-
const indented = lines.map(line => ' ' + line).join('\n');
|
|
330
|
-
return indented + '\n';
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
});
|
|
383
|
+
const themeEntry = THEME_MAP[options.theme];
|
|
384
|
+
const diagramColors = MERMAID_THEMES[themeEntry.mermaidTheme];
|
|
385
|
+
if (!diagramColors) {
|
|
386
|
+
console.error(`Internal error: mermaid theme '${themeEntry.mermaidTheme}' not found in beautiful-mermaid`);
|
|
387
|
+
process.exit(1);
|
|
334
388
|
}
|
|
335
389
|
|
|
336
|
-
|
|
390
|
+
// 2. Read input (common): files or stdin -> markdownParts[]
|
|
391
|
+
const markdownParts = [];
|
|
337
392
|
|
|
338
393
|
if (files.length === 0) {
|
|
339
394
|
// Read from stdin
|
|
@@ -343,37 +398,105 @@ async function main() {
|
|
|
343
398
|
|
|
344
399
|
const stdinData = await readStdin();
|
|
345
400
|
if (stdinData.trim()) {
|
|
346
|
-
|
|
401
|
+
markdownParts.push(stdinData);
|
|
347
402
|
} else {
|
|
348
403
|
program.help();
|
|
349
404
|
}
|
|
350
405
|
} else {
|
|
351
|
-
// Read from files
|
|
352
406
|
let exitCode = 0;
|
|
353
407
|
for (const filePath of files) {
|
|
354
408
|
try {
|
|
355
|
-
|
|
356
|
-
parts.push(renderToString(markdown, options));
|
|
409
|
+
markdownParts.push(readMarkdownFile(filePath));
|
|
357
410
|
} catch (error) {
|
|
358
411
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
359
412
|
console.error(`Error reading file ${filePath}: ${errorMessage}`);
|
|
360
413
|
exitCode = 1;
|
|
361
414
|
}
|
|
362
415
|
}
|
|
363
|
-
|
|
364
416
|
if (exitCode !== 0) {
|
|
365
417
|
process.exit(exitCode);
|
|
366
418
|
}
|
|
367
419
|
}
|
|
368
420
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
spawnPager(fullText, options);
|
|
421
|
+
if (options.html) {
|
|
422
|
+
// 3a. HTML path
|
|
423
|
+
const combined = markdownParts.join('\n\n');
|
|
424
|
+
const html = renderToHTML(combined, diagramColors);
|
|
425
|
+
process.stdout.write(html);
|
|
375
426
|
} else {
|
|
376
|
-
|
|
427
|
+
// 3b. Terminal path
|
|
428
|
+
|
|
429
|
+
// --no-color: disable all ANSI output at the root via chalk.level = 0
|
|
430
|
+
const useColor = options.color !== false && !process.env.NO_COLOR;
|
|
431
|
+
if (!useColor) {
|
|
432
|
+
chalk.level = 0;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const shikiThemeName = themeEntry.shikiTheme;
|
|
436
|
+
|
|
437
|
+
// Initialize Shiki highlighter (async load, then used synchronously)
|
|
438
|
+
const hl = await getHighlighter();
|
|
439
|
+
|
|
440
|
+
// Extract markdown element styling from Shiki theme (with softenHex desaturation)
|
|
441
|
+
const markdownStyle = extractMarkdownStyleSync(hl, shikiThemeName);
|
|
442
|
+
|
|
443
|
+
// Pass null for highlightOptions to marked-terminal.
|
|
444
|
+
// The code renderer override below intercepts all code blocks before
|
|
445
|
+
// marked-terminal's internal highlight function is reached.
|
|
446
|
+
marked.use(markedTerminal({
|
|
447
|
+
reflowText: true,
|
|
448
|
+
width: options.width ?? process.stdout.columns ?? 80,
|
|
449
|
+
...markdownStyle,
|
|
450
|
+
}, null));
|
|
451
|
+
|
|
452
|
+
// Override link renderer to avoid OSC 8 escape sequences (fixes tmux display issues)
|
|
453
|
+
marked.use({
|
|
454
|
+
renderer: {
|
|
455
|
+
link(token) {
|
|
456
|
+
return token.text + (token.text !== token.href ? ` (${token.href})` : '');
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// Override code renderer to use Shiki instead of cli-highlight.
|
|
462
|
+
// This marked.use() call MUST come after markedTerminal() so that it takes
|
|
463
|
+
// precedence over marked-terminal's internal code renderer (marked v17+
|
|
464
|
+
// applies later overrides first).
|
|
465
|
+
marked.use({
|
|
466
|
+
renderer: {
|
|
467
|
+
code(token) {
|
|
468
|
+
const lang = token.lang || '';
|
|
469
|
+
const code = typeof token === 'string' ? token : (token.text || token);
|
|
470
|
+
const highlighted = highlightWithShikiSync(hl, String(code), lang, shikiThemeName);
|
|
471
|
+
const lines = highlighted.split('\n');
|
|
472
|
+
const indented = lines.map(line => ' ' + line).join('\n');
|
|
473
|
+
return indented + '\n';
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
// Convert DiagramColors to AsciiTheme for terminal rendering
|
|
479
|
+
const asciiTheme = diagramColorsToAsciiTheme(diagramColors);
|
|
480
|
+
const colorMode = useColor ? undefined : 'none';
|
|
481
|
+
|
|
482
|
+
const parts = [];
|
|
483
|
+
for (const markdown of markdownParts) {
|
|
484
|
+
const processed = convertMermaidToAscii(markdown, {
|
|
485
|
+
useAscii: options.ascii,
|
|
486
|
+
colorMode,
|
|
487
|
+
theme: asciiTheme,
|
|
488
|
+
});
|
|
489
|
+
parts.push(marked.parse(processed));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const fullText = parts.join('\n\n') + '\n';
|
|
493
|
+
|
|
494
|
+
// Use pager if needed
|
|
495
|
+
if (shouldUsePager(fullText, options)) {
|
|
496
|
+
spawnPager(fullText, options);
|
|
497
|
+
} else {
|
|
498
|
+
process.stdout.write(fullText);
|
|
499
|
+
}
|
|
377
500
|
}
|
|
378
501
|
});
|
|
379
502
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memd-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "main.js",
|
|
6
6
|
"bin": {
|
|
@@ -12,15 +12,18 @@
|
|
|
12
12
|
"dependencies": {
|
|
13
13
|
"beautiful-mermaid": "^1.1.2",
|
|
14
14
|
"chalk": "^5.6.2",
|
|
15
|
-
"cli-highlight": "^2.1.11",
|
|
16
15
|
"commander": "^14.0.3",
|
|
17
16
|
"marked": "^17.0.3",
|
|
18
|
-
"marked-terminal": "^7.3.0"
|
|
17
|
+
"marked-terminal": "^7.3.0",
|
|
18
|
+
"shiki": "^4.0.2"
|
|
19
19
|
},
|
|
20
20
|
"repository": {
|
|
21
21
|
"type": "git",
|
|
22
22
|
"url": "https://github.com/ktrysmt/memd.git"
|
|
23
23
|
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
24
27
|
"license": "MIT",
|
|
25
28
|
"packageManager": "pnpm@10.15.1",
|
|
26
29
|
"devDependencies": {
|
package/test/memd.test.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import { launchTerminal } from 'tuistory'
|
|
3
|
+
import { execSync } from 'child_process'
|
|
3
4
|
import path from 'path'
|
|
4
5
|
import { fileURLToPath } from 'url'
|
|
5
6
|
|
|
@@ -19,10 +20,14 @@ async function run(args, { waitFor = null } = {}) {
|
|
|
19
20
|
return output.trim()
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
function runSync(args) {
|
|
24
|
+
return execSync(`node ${MAIN} ${args}`, { encoding: 'utf-8', timeout: 15000 })
|
|
25
|
+
}
|
|
26
|
+
|
|
22
27
|
describe('memd CLI', () => {
|
|
23
28
|
it('--version', async () => {
|
|
24
29
|
const output = await run(['-v'])
|
|
25
|
-
expect(output).toContain('
|
|
30
|
+
expect(output).toContain('2.0.0')
|
|
26
31
|
})
|
|
27
32
|
|
|
28
33
|
it('--help', async () => {
|
|
@@ -31,6 +36,8 @@ describe('memd CLI', () => {
|
|
|
31
36
|
expect(output).toContain('--no-pager')
|
|
32
37
|
expect(output).toContain('--no-color')
|
|
33
38
|
expect(output).toContain('--ascii')
|
|
39
|
+
expect(output).toContain('--html')
|
|
40
|
+
expect(output).toContain('"nord"')
|
|
34
41
|
})
|
|
35
42
|
|
|
36
43
|
it('renders test1.md (basic markdown + mermaid)', async () => {
|
|
@@ -146,4 +153,195 @@ describe('memd CLI', () => {
|
|
|
146
153
|
})).trim()
|
|
147
154
|
expect(output).toContain('stdin test')
|
|
148
155
|
})
|
|
156
|
+
|
|
157
|
+
// --html output tests
|
|
158
|
+
describe('--html output', () => {
|
|
159
|
+
it('--html + file -> stdout', () => {
|
|
160
|
+
const output = runSync('--html test/test1.md')
|
|
161
|
+
expect(output).toContain('<!DOCTYPE html>')
|
|
162
|
+
expect(output).toContain('<svg')
|
|
163
|
+
// Contains theme CSS colors (nord default: bg=#2e3440, fg=#d8dee9)
|
|
164
|
+
expect(output).toContain('#2e3440')
|
|
165
|
+
expect(output).toContain('#d8dee9')
|
|
166
|
+
// Ends with newline (POSIX)
|
|
167
|
+
expect(output.endsWith('\n')).toBe(true)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('--html + stdin -> stdout', () => {
|
|
171
|
+
const output = execSync(`echo '# hello' | node ${MAIN} --html`, { encoding: 'utf-8', timeout: 15000 })
|
|
172
|
+
expect(output).toContain('<!DOCTYPE html>')
|
|
173
|
+
expect(output).toContain('hello')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('--html + Mermaid error shows mermaid-error block', () => {
|
|
177
|
+
const input = '# Test\n\n```mermaid\ngantt\n title Test\n dateFormat YYYY-MM-DD\n section S\n Task :a1, 2024-01-01, 30d\n```'
|
|
178
|
+
const output = execSync(`node ${MAIN} --html`, { input, encoding: 'utf-8', timeout: 15000 })
|
|
179
|
+
expect(output).toContain('mermaid-error')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('--html + multiple Mermaid blocks have unique marker IDs', () => {
|
|
183
|
+
const output = runSync('--html test/test3.md')
|
|
184
|
+
expect(output).toContain('<svg')
|
|
185
|
+
// Check for prefixed marker IDs (m0-, m1-, etc.)
|
|
186
|
+
expect(output).toMatch(/id="m0-/)
|
|
187
|
+
expect(output).toMatch(/id="m1-/)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('--html + multiple files -> combined single HTML', () => {
|
|
191
|
+
const output = runSync('--html test/test1.md test/test2.md')
|
|
192
|
+
expect(output).toContain('<!DOCTYPE html>')
|
|
193
|
+
// Content from both files
|
|
194
|
+
expect(output).toContain('Hello')
|
|
195
|
+
expect(output).toContain('More text after the diagram.')
|
|
196
|
+
// Only one HTML document
|
|
197
|
+
const doctypeCount = (output.match(/<!DOCTYPE html>/g) || []).length
|
|
198
|
+
expect(doctypeCount).toBe(1)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// Theme tests (HTML path)
|
|
203
|
+
describe('theme (HTML path)', () => {
|
|
204
|
+
it('--html --theme dracula uses dracula colors', () => {
|
|
205
|
+
const output = runSync('--html --theme dracula test/test1.md')
|
|
206
|
+
expect(output).toContain('#282a36') // bg
|
|
207
|
+
expect(output).toContain('#f8f8f2') // fg
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('--html --theme tokyo-night uses tokyo-night colors', () => {
|
|
211
|
+
const output = runSync('--html --theme tokyo-night test/test1.md')
|
|
212
|
+
expect(output).toContain('#1a1b26') // bg
|
|
213
|
+
expect(output).toContain('#a9b1d6') // fg
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('--html --theme nonexistent exits with error', () => {
|
|
217
|
+
expect(() => {
|
|
218
|
+
execSync(`node ${MAIN} --html --theme nonexistent test/test1.md`, { encoding: 'utf-8', timeout: 15000, stdio: 'pipe' })
|
|
219
|
+
}).toThrow()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('--html --no-color outputs full color HTML (silently ignored)', () => {
|
|
223
|
+
const output = runSync('--html --no-color test/test1.md')
|
|
224
|
+
expect(output).toContain('<!DOCTYPE html>')
|
|
225
|
+
expect(output).toContain('#2e3440')
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// Theme tests (terminal path)
|
|
230
|
+
describe('theme (terminal path)', () => {
|
|
231
|
+
it('--theme dracula renders terminal output', async () => {
|
|
232
|
+
const output = await run(
|
|
233
|
+
['--no-pager', '--no-color', '--theme', 'dracula', 'test/test1.md'],
|
|
234
|
+
{ waitFor: t => t.includes('More text.') },
|
|
235
|
+
)
|
|
236
|
+
expect(output).toContain('Hello')
|
|
237
|
+
expect(output).toContain('More text.')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('--theme tokyo-night (no highlight) renders terminal output', async () => {
|
|
241
|
+
const output = await run(
|
|
242
|
+
['--no-pager', '--no-color', '--theme', 'tokyo-night', 'test/test1.md'],
|
|
243
|
+
{ waitFor: t => t.includes('More text.') },
|
|
244
|
+
)
|
|
245
|
+
expect(output).toContain('Hello')
|
|
246
|
+
expect(output).toContain('More text.')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('--theme one-dark renders terminal output', async () => {
|
|
250
|
+
const output = await run(
|
|
251
|
+
['--no-pager', '--no-color', '--theme', 'one-dark', 'test/test1.md'],
|
|
252
|
+
{ waitFor: t => t.includes('More text.') },
|
|
253
|
+
)
|
|
254
|
+
expect(output).toContain('Hello')
|
|
255
|
+
expect(output).toContain('More text.')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('--theme nonexistent exits with error', async () => {
|
|
259
|
+
const session = await launchTerminal({
|
|
260
|
+
command: 'node',
|
|
261
|
+
args: [MAIN, '--no-pager', '--no-color', '--theme', 'nonexistent', 'test/test1.md'],
|
|
262
|
+
cols: 80,
|
|
263
|
+
rows: 10,
|
|
264
|
+
waitForData: false,
|
|
265
|
+
})
|
|
266
|
+
const output = (await session.text({
|
|
267
|
+
waitFor: t => t.includes('Unknown theme'),
|
|
268
|
+
timeout: 8000,
|
|
269
|
+
})).trim()
|
|
270
|
+
expect(output).toContain('Unknown theme')
|
|
271
|
+
expect(output).toContain('Available themes')
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('default theme is nord', async () => {
|
|
275
|
+
const output = await run(
|
|
276
|
+
['--no-pager', '--no-color', 'test/test1.md'],
|
|
277
|
+
{ waitFor: t => t.includes('More text.') },
|
|
278
|
+
)
|
|
279
|
+
expect(output).toContain('Hello')
|
|
280
|
+
expect(output).toContain('More text.')
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// chalk.level = 0 + Shiki: verify no ANSI codes for all themes
|
|
285
|
+
describe('--no-color strips ANSI from all themes', () => {
|
|
286
|
+
const themes = [
|
|
287
|
+
'nord', 'dracula', 'one-dark', 'github-dark', 'github-light',
|
|
288
|
+
'solarized-dark', 'solarized-light', 'catppuccin-mocha', 'catppuccin-latte',
|
|
289
|
+
'tokyo-night', 'tokyo-night-storm', 'tokyo-night-light',
|
|
290
|
+
'nord-light', 'zinc-dark', 'zinc-light',
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
for (const theme of themes) {
|
|
294
|
+
it(`--theme ${theme} --no-color has no ANSI codes`, async () => {
|
|
295
|
+
const output = await run(
|
|
296
|
+
['--no-pager', '--no-color', '--theme', theme, 'test/test-highlight.md'],
|
|
297
|
+
{ waitFor: t => t.includes('TypeScript') },
|
|
298
|
+
)
|
|
299
|
+
// eslint-disable-next-line no-control-regex
|
|
300
|
+
expect(output).not.toMatch(/\x1b\[[\d;]*m/)
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('syntax highlighting produces truecolor ANSI for known languages', () => {
|
|
306
|
+
const output = execSync(
|
|
307
|
+
`FORCE_COLOR=3 node ${MAIN} --no-pager --theme nord test/test-highlight.md`,
|
|
308
|
+
{ encoding: 'utf-8', timeout: 15000 },
|
|
309
|
+
)
|
|
310
|
+
// Truecolor ANSI escape: ESC[38;2;R;G;Bm
|
|
311
|
+
// eslint-disable-next-line no-control-regex
|
|
312
|
+
expect(output).toMatch(/\x1b\[38;2;\d+;\d+;\d+m/)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('unknown language falls back to plain text without errors', async () => {
|
|
316
|
+
const session = await launchTerminal({
|
|
317
|
+
command: 'sh',
|
|
318
|
+
args: ['-c', `echo '# Test\n\n\`\`\`unknownlang\nsome code\n\`\`\`' | node ${MAIN} --no-pager --no-color`],
|
|
319
|
+
cols: 80,
|
|
320
|
+
rows: 10,
|
|
321
|
+
waitForData: false,
|
|
322
|
+
})
|
|
323
|
+
const output = (await session.text({
|
|
324
|
+
waitFor: t => t.includes('some code'),
|
|
325
|
+
timeout: 8000,
|
|
326
|
+
})).trim()
|
|
327
|
+
expect(output).toContain('some code')
|
|
328
|
+
expect(output).not.toContain('Error')
|
|
329
|
+
expect(output).not.toContain('Could not find the language')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('cli-highlight is not invoked (no highlight.js errors in output)', async () => {
|
|
333
|
+
const session = await launchTerminal({
|
|
334
|
+
command: 'sh',
|
|
335
|
+
args: ['-c', `echo '# Test\n\n\`\`\`rust\nfn main() {}\n\`\`\`' | node ${MAIN} --no-pager`],
|
|
336
|
+
cols: 80,
|
|
337
|
+
rows: 10,
|
|
338
|
+
waitForData: false,
|
|
339
|
+
})
|
|
340
|
+
const output = (await session.text({
|
|
341
|
+
waitFor: t => t.includes('fn main'),
|
|
342
|
+
timeout: 8000,
|
|
343
|
+
})).trim()
|
|
344
|
+
expect(output).toContain('fn main')
|
|
345
|
+
expect(output).not.toContain('Could not find the language')
|
|
346
|
+
})
|
|
149
347
|
})
|