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.
@@ -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
- > View mermaid-ed markdown in terminal
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 to terminal output
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
- --theme <name> syntax highlight theme (default, monokai, dracula,
30
- github-dark, solarized, nord) (default: "default")
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#v1.1.0
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
- // HSL color utilities for deriving muted variants from base hex colors
19
- function hexToHsl(hex) {
20
- const r = parseInt(hex.slice(1, 3), 16) / 255;
21
- const g = parseInt(hex.slice(3, 5), 16) / 255;
22
- const b = parseInt(hex.slice(5, 7), 16) / 255;
23
- const max = Math.max(r, g, b), min = Math.min(r, g, b);
24
- let h, s, l = (max + min) / 2;
25
- if (max === min) {
26
- h = s = 0;
27
- } else {
28
- const d = max - min;
29
- s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
30
- switch (max) {
31
- case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
32
- case g: h = ((b - r) / d + 2) / 6; break;
33
- case b: h = ((r - g) / d + 4) / 6; break;
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 [h * 360, s * 100, l * 100];
53
+ return _highlighter;
37
54
  }
38
55
 
39
- function hslToHex(h, s, l) {
40
- h /= 360; s /= 100; l /= 100;
41
- let r, g, b;
42
- if (s === 0) {
43
- r = g = b = l;
44
- } else {
45
- const hue2rgb = (p, q, t) => {
46
- if (t < 0) t += 1;
47
- if (t > 1) t -= 1;
48
- if (t < 1/6) return p + (q - p) * 6 * t;
49
- if (t < 1/2) return q;
50
- if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
51
- return p;
52
- };
53
- const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
54
- const p = 2 * l - q;
55
- r = hue2rgb(p, q, h + 1/3);
56
- g = hue2rgb(p, q, h);
57
- b = hue2rgb(p, q, h - 1/3);
58
- }
59
- const toHex = x => Math.round(x * 255).toString(16).padStart(2, '0');
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
- // Mute a hex color: reduce saturation and shift lightness toward middle
64
- function mute(hex, satMul = 0.55, lightShift = 0.85) {
65
- const [h, s, l] = hexToHsl(hex);
66
- const newS = s * satMul;
67
- const newL = l * lightShift;
68
- return hslToHex(h, Math.min(newS, 100), Math.max(Math.min(newL, 100), 0));
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
- // Build theme from base palette: markdown gets vivid colors, code blocks get muted variants
72
- function buildTheme(colors) {
73
- const m = (hex) => mute(hex); // muted variant for code block elements
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
- highlight: {
76
- keyword: chalk.hex(m(colors.keyword)),
77
- built_in: chalk.hex(m(colors.type)).italic,
78
- type: chalk.hex(m(colors.type)),
79
- literal: chalk.hex(m(colors.literal)),
80
- number: chalk.hex(m(colors.number)),
81
- regexp: chalk.hex(m(colors.string)),
82
- string: chalk.hex(m(colors.string)),
83
- subst: chalk.hex(m(colors.fg)),
84
- symbol: chalk.hex(m(colors.literal)),
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
- // Theme color palettes
119
- const THEMES = {
120
- default: { highlight: null, markdown: {} },
121
- monokai: buildTheme({
122
- keyword: '#F92672', func: '#A6E22E', string: '#E6DB74',
123
- type: '#66D9EF', number: '#AE81FF', literal: '#AE81FF',
124
- param: '#FD971F', comment: '#75715E', fg: '#F8F8F2',
125
- heading1: '#F92672', heading2:'#A6E22E',
126
- added: '#A6E22E', deleted: '#F92672',
127
- }),
128
- dracula: buildTheme({
129
- keyword: '#FF79C6', func: '#50FA7B', string: '#F1FA8C',
130
- type: '#8BE9FD', number: '#BD93F9', literal: '#BD93F9',
131
- param: '#FFB86C', comment: '#6272A4', fg: '#F8F8F2',
132
- heading1: '#BD93F9', heading2:'#FF79C6',
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
- const THEME_NAMES = Object.keys(THEMES);
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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
- return fs.readFileSync(absolutePath, 'utf-8');
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, options = {}) {
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 (options.ascii) {
184
- mermaidOptions.useAscii = true;
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 renderToString(markdown, options = {}) {
200
- const processedMarkdown = convertMermaidToAscii(markdown, options);
201
- let result = marked.parse(processedMarkdown);
202
-
203
- // Strip ANSI color codes if --no-color is specified
204
- if (options.color === false) {
205
- // Remove ANSI escape codes (color, style, etc.)
206
- result = result.replace(/\x1b\[[0-9;]*m/g, '');
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
- return result;
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 to terminal output')
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('--theme <name>', `syntax highlight theme (${THEME_NAMES.join(', ')})`, 'default')
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 option
282
- const themeName = options.theme || 'default';
283
- if (!(themeName in THEMES)) {
284
- console.error(`Unknown theme: ${themeName}\nAvailable themes: ${THEME_NAMES.join(', ')}`);
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
- // Check if color should be disabled (--no-color or NO_COLOR env var)
289
- const useColor = options.color !== false && !process.env.NO_COLOR;
290
-
291
- // Configure syntax highlighting options (passed to cli-highlight)
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
- const parts = [];
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
- parts.push(renderToString(stdinData, options));
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
- const markdown = readMarkdownFile(filePath);
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
- // Combine all output
370
- const fullText = parts.join('\n\n') + '\n';
371
-
372
- // Use pager if needed
373
- if (shouldUsePager(fullText, options)) {
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
- process.stdout.write(fullText);
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": "1.5.1",
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('1.5.1')
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
  })