memd-cli 1.5.1 → 2.0.0

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/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');
121
+ }
122
+
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
+
160
+ return {
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,
171
+ };
69
172
  }
70
173
 
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
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);
74
178
  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
- },
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,
115
187
  };
116
188
  }
117
189
 
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
- };
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
+ }
157
201
 
158
- const THEME_NAMES = Object.keys(THEMES);
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() {
@@ -269,71 +359,31 @@ async function main() {
269
359
  program
270
360
  .name('memd')
271
361
  .version(packageJson.version, '-v, --version', 'output the version number')
272
- .description('Render markdown with mermaid diagrams to terminal output')
362
+ .description('Render markdown with mermaid diagrams')
273
363
  .argument('[files...]', 'markdown file(s) to render')
274
364
  .option('--no-pager', 'disable pager (less)')
275
365
  .option('--no-mouse', 'disable mouse scroll in pager')
276
366
  .option('--no-color', 'disable colored output')
277
367
  .option('--width <number>', 'terminal width override', Number)
278
368
  .option('--ascii', 'use pure ASCII mode for diagrams (default: unicode)')
279
- .option('--theme <name>', `syntax highlight theme (${THEME_NAMES.join(', ')})`, 'default')
369
+ .option('--html', 'output as standalone HTML (mermaid diagrams rendered as inline SVG)')
370
+ .option('--theme <name>', `color theme\n${THEME_NAMES.join(', ')}`, 'nord')
280
371
  .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(', ')}`);
372
+ // 1. Validate theme via THEME_MAP (unified for both paths)
373
+ if (!(options.theme in THEME_MAP)) {
374
+ const names = Object.keys(THEME_MAP).join(', ');
375
+ console.error(`Unknown theme: ${options.theme}\nAvailable themes: ${names}`);
285
376
  process.exit(1);
286
377
  }
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
- });
378
+ const themeEntry = THEME_MAP[options.theme];
379
+ const diagramColors = MERMAID_THEMES[themeEntry.mermaidTheme];
380
+ if (!diagramColors) {
381
+ console.error(`Internal error: mermaid theme '${themeEntry.mermaidTheme}' not found in beautiful-mermaid`);
382
+ process.exit(1);
334
383
  }
335
384
 
336
- const parts = [];
385
+ // 2. Read input (common): files or stdin -> markdownParts[]
386
+ const markdownParts = [];
337
387
 
338
388
  if (files.length === 0) {
339
389
  // Read from stdin
@@ -343,37 +393,105 @@ async function main() {
343
393
 
344
394
  const stdinData = await readStdin();
345
395
  if (stdinData.trim()) {
346
- parts.push(renderToString(stdinData, options));
396
+ markdownParts.push(stdinData);
347
397
  } else {
348
398
  program.help();
349
399
  }
350
400
  } else {
351
- // Read from files
352
401
  let exitCode = 0;
353
402
  for (const filePath of files) {
354
403
  try {
355
- const markdown = readMarkdownFile(filePath);
356
- parts.push(renderToString(markdown, options));
404
+ markdownParts.push(readMarkdownFile(filePath));
357
405
  } catch (error) {
358
406
  const errorMessage = error instanceof Error ? error.message : String(error);
359
407
  console.error(`Error reading file ${filePath}: ${errorMessage}`);
360
408
  exitCode = 1;
361
409
  }
362
410
  }
363
-
364
411
  if (exitCode !== 0) {
365
412
  process.exit(exitCode);
366
413
  }
367
414
  }
368
415
 
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);
416
+ if (options.html) {
417
+ // 3a. HTML path
418
+ const combined = markdownParts.join('\n\n');
419
+ const html = renderToHTML(combined, diagramColors);
420
+ process.stdout.write(html);
375
421
  } else {
376
- process.stdout.write(fullText);
422
+ // 3b. Terminal path
423
+
424
+ // --no-color: disable all ANSI output at the root via chalk.level = 0
425
+ const useColor = options.color !== false && !process.env.NO_COLOR;
426
+ if (!useColor) {
427
+ chalk.level = 0;
428
+ }
429
+
430
+ const shikiThemeName = themeEntry.shikiTheme;
431
+
432
+ // Initialize Shiki highlighter (async load, then used synchronously)
433
+ const hl = await getHighlighter();
434
+
435
+ // Extract markdown element styling from Shiki theme (with softenHex desaturation)
436
+ const markdownStyle = extractMarkdownStyleSync(hl, shikiThemeName);
437
+
438
+ // Pass null for highlightOptions to marked-terminal.
439
+ // The code renderer override below intercepts all code blocks before
440
+ // marked-terminal's internal highlight function is reached.
441
+ marked.use(markedTerminal({
442
+ reflowText: true,
443
+ width: options.width ?? process.stdout.columns ?? 80,
444
+ ...markdownStyle,
445
+ }, null));
446
+
447
+ // Override link renderer to avoid OSC 8 escape sequences (fixes tmux display issues)
448
+ marked.use({
449
+ renderer: {
450
+ link(token) {
451
+ return token.text + (token.text !== token.href ? ` (${token.href})` : '');
452
+ }
453
+ }
454
+ });
455
+
456
+ // Override code renderer to use Shiki instead of cli-highlight.
457
+ // This marked.use() call MUST come after markedTerminal() so that it takes
458
+ // precedence over marked-terminal's internal code renderer (marked v17+
459
+ // applies later overrides first).
460
+ marked.use({
461
+ renderer: {
462
+ code(token) {
463
+ const lang = token.lang || '';
464
+ const code = typeof token === 'string' ? token : (token.text || token);
465
+ const highlighted = highlightWithShikiSync(hl, String(code), lang, shikiThemeName);
466
+ const lines = highlighted.split('\n');
467
+ const indented = lines.map(line => ' ' + line).join('\n');
468
+ return indented + '\n';
469
+ }
470
+ }
471
+ });
472
+
473
+ // Convert DiagramColors to AsciiTheme for terminal rendering
474
+ const asciiTheme = diagramColorsToAsciiTheme(diagramColors);
475
+ const colorMode = useColor ? undefined : 'none';
476
+
477
+ const parts = [];
478
+ for (const markdown of markdownParts) {
479
+ const processed = convertMermaidToAscii(markdown, {
480
+ useAscii: options.ascii,
481
+ colorMode,
482
+ theme: asciiTheme,
483
+ });
484
+ parts.push(marked.parse(processed));
485
+ }
486
+
487
+ const fullText = parts.join('\n\n') + '\n';
488
+
489
+ // Use pager if needed
490
+ if (shouldUsePager(fullText, options)) {
491
+ spawnPager(fullText, options);
492
+ } else {
493
+ process.stdout.write(fullText);
494
+ }
377
495
  }
378
496
  });
379
497