memd-cli 1.5.0 → 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.
@@ -0,0 +1,710 @@
1
+ memd v2 Syntax Highlighting Overhaul: cli-highlight -> Shiki
2
+ =============================================================
3
+
4
+ ## Decisions
5
+
6
+ | # | Topic | Decision |
7
+ |---|-------|----------|
8
+ | 1 | Missing themes strategy | Pattern C: Use nearest Shiki built-in theme for syntax highlighting; keep beautiful-mermaid built-in for diagrams |
9
+ | 2 | Theme architecture | Pattern F: beautiful-mermaid built-in themes for diagrams, Shiki themes for syntax highlighting + markdown styling only |
10
+ | 3 | Language preload | Pattern G: Preload top 19 languages on first use (lazy init, not at import time) |
11
+ | 4 | Node.js minimum | Node.js >= 20 (required by Shiki v4 JS RegExp engine) |
12
+ | 5 | cli-highlight dep | Pattern L: Remove from package.json direct dependencies (remains as transitive dep of marked-terminal) |
13
+ | 6 | cli-highlight bypass | Code renderer override via `marked.use()` intercepts all code blocks before marked-terminal's internal highlight; marked-terminal's `highlightOptions` passed as `null` (falls back to `{}` internally, but never reached) |
14
+
15
+ ## Background
16
+
17
+ ### Current Problems
18
+
19
+ 1. **Color source fragmentation**: 5 themes have hand-crafted 13-color palettes in memd (`TERMINAL_THEMES`), 10 themes auto-derive from beautiful-mermaid's `DiagramColors` (3 colors), resulting in inconsistent quality
20
+ 2. **cli-highlight limitations**: Based on highlight.js v10, limited language support, no built-in truecolor themes, "Could not find the language" warnings leak to console
21
+ 3. **Redundant color utilities**: `mute()`, `hexToHsl()`, `hslToHex()`, `buildTheme()`, `buildMarkdownFromDiagramColors()` all exist to compensate for the lack of a proper theme system
22
+ 4. **beautiful-mermaid and terminal text colors are decoupled**: Diagram colors come from `MERMAID_THEMES`, terminal text colors come from memd's hand-crafted palettes or auto-derivation -- no single source of truth
23
+
24
+ ### Solution
25
+
26
+ Replace cli-highlight with **Shiki** (v4.0.2) for syntax highlighting. Shiki uses VS Code / TextMate themes -- the same origin as beautiful-mermaid.
27
+
28
+ - **Syntax highlighting + markdown styling**: Shiki theme objects (50+ token colors per theme)
29
+ - **Diagram colors**: beautiful-mermaid built-in `MERMAID_THEMES` (hand-tuned DiagramColors, higher quality than auto-derivation)
30
+
31
+ ### HTML Path Impact: None
32
+
33
+ The HTML output path (`--html`) is **not affected** by this migration:
34
+
35
+ - Uses an independent `new Marked()` instance (main.js:305), never configured with `markedTerminal`
36
+ - Does not use `cli-highlight` or any syntax highlighter (CSS-only styling for code blocks)
37
+ - Only consumes `diagramColors` for CSS variables and SVG diagram rendering
38
+ - `renderToHTML()` (main.js:303-334) requires zero changes
39
+
40
+ ## Architecture (After)
41
+
42
+ ### Color Flow
43
+
44
+ ```mermaid
45
+ flowchart TD
46
+ A["--theme name<br>(e.g. nord)"] --> B["THEME_MAP[name]"]
47
+ B --> C["Shiki theme object<br>(VS Code / TextMate format)<br>50+ token colors"]
48
+ B --> D["MERMAID_THEMES[mermaidTheme]<br>DiagramColors (built-in)<br>bg, fg, accent, muted, line"]
49
+
50
+ C --> E["Shiki highlighter<br>codeToTokensBase() -> ANSI<br>truecolor syntax highlight"]
51
+ C --> F["extractMarkdownStyle()<br>+ softenHex() desaturation<br>heading, link, code, blockquote..."]
52
+
53
+ D --> G["renderMermaidSVG /<br>renderMermaidASCII<br>diagram colors"]
54
+
55
+ E --> H["marked code renderer<br>(override)"]
56
+ F --> I["markedTerminal options<br>markdown element styling"]
57
+
58
+ H --> J["Terminal output"]
59
+ I --> J
60
+ G --> J
61
+ ```
62
+
63
+ ### What Gets Removed
64
+
65
+ | Item | Location | Reason |
66
+ |------|----------|--------|
67
+ | `hexToHsl()` | main.js:19-37 | Replaced by `softenHex()` (sRGB blend, no HSL needed) |
68
+ | `hslToHex()` | main.js:39-61 | Same as above |
69
+ | `mute()` | main.js:64-69 | Same as above |
70
+ | `buildTheme()` | main.js:88-133 | Replaced by `extractMarkdownStyle()` from Shiki themes |
71
+ | `TERMINAL_THEMES` (5 palettes) | main.js:136-172 | Replaced by Shiki themes |
72
+ | `buildMarkdownFromDiagramColors()` | main.js:175-193 | Replaced by `extractMarkdownStyle()` |
73
+ | `resolveTerminalTheme()` | main.js:195-197 | Replaced by `THEME_MAP` lookup |
74
+ | `resolveDiagramTheme()` | main.js:230-234 | Replaced by unified `THEME_MAP` validation |
75
+ | `console.error` suppression hack | main.js:503-515 | highlight.js is no longer called |
76
+ | `cli-highlight` import | main.js:6 | Replaced by Shiki |
77
+ | `cli-highlight` in package.json | package.json:15 | Removed from direct dependencies |
78
+
79
+ ### What Gets Kept (unchanged)
80
+
81
+ | Item | Location | Reason |
82
+ |------|----------|--------|
83
+ | `mixHex()` | main.js:73-82 | Still needed for diagram/HTML path |
84
+ | `MIX` | main.js:85 | Still needed for diagram/HTML path |
85
+ | `diagramColorsToAsciiTheme()` | main.js:202-215 | Still needed for ASCII diagram rendering |
86
+ | `resolveThemeColors()` | main.js:218-227 | Still needed for HTML path CSS |
87
+
88
+ ### What Gets Added
89
+
90
+ | Item | Purpose |
91
+ |------|---------|
92
+ | `shiki` dependency | Syntax highlighting engine (v4.0.2); automatically includes `@shikijs/themes`, `@shikijs/langs`, `@shikijs/engine-javascript` as sub-packages |
93
+ | Shiki imports (core, engine, themes, langs) | Individual default imports for tree-shaking |
94
+ | `THEME_MAP` | Maps memd theme name -> `{ shikiTheme, mermaidTheme }` |
95
+ | `THEME_NAMES` | `Object.keys(THEME_MAP)` (replaces `Object.keys(MERMAID_THEMES)`) |
96
+ | `getHighlighter()` (lazy singleton) | Returns Shiki highlighter instance, created on first call (skips init for --version/--help) |
97
+ | `highlightWithShiki()` | Tokenizes code with Shiki and converts to ANSI |
98
+ | `tokensToAnsi()` | Converts Shiki `ThemedToken[][]` to truecolor ANSI string |
99
+ | `softenHex()` | sRGB blend toward gray for gentle desaturation of markdown styles |
100
+ | `extractMarkdownStyle()` | Extracts markdown element styling from Shiki theme token scopes, with `softenHex()` applied |
101
+ | `engines` field in package.json | `"node": ">=20"` |
102
+
103
+ ## Theme Mapping
104
+
105
+ ### Final Theme Table: memd --theme -> Shiki theme -> beautiful-mermaid theme
106
+
107
+ | memd --theme | Shiki built-in theme | beautiful-mermaid theme | Notes |
108
+ |---|---|---|---|
109
+ | nord | `nord` | `nord` | Direct match |
110
+ | dracula | `dracula` | `dracula` | Direct match |
111
+ | one-dark | `one-dark-pro` | `one-dark` | Name differs in Shiki |
112
+ | github-dark | `github-dark` | `github-dark` | Direct match |
113
+ | github-light | `github-light` | `github-light` | Direct match |
114
+ | solarized-dark | `solarized-dark` | `solarized-dark` | Direct match |
115
+ | solarized-light | `solarized-light` | `solarized-light` | Direct match |
116
+ | catppuccin-mocha | `catppuccin-mocha` | `catppuccin-mocha` | Direct match |
117
+ | catppuccin-latte | `catppuccin-latte` | `catppuccin-latte` | Direct match |
118
+ | tokyo-night | `tokyo-night` | `tokyo-night` | Direct match |
119
+ | tokyo-night-storm | `tokyo-night` | `tokyo-night-storm` | Shiki has no storm variant; use base `tokyo-night` (difference is bg only) |
120
+ | tokyo-night-light | `one-light` | `tokyo-night-light` | Shiki has no light variant; `one-light` is closest |
121
+ | nord-light | `everforest-light` | `nord-light` | Shiki has no nord-light; `everforest-light` has similar soft palette |
122
+ | zinc-dark | `min-dark` | `zinc-dark` | Neutral/minimal dark |
123
+ | zinc-light | `min-light` | `zinc-light` | Neutral/minimal light |
124
+
125
+ All 15 memd themes are preserved. Diagram colors always use beautiful-mermaid built-in themes (high quality, hand-tuned). Syntax highlighting uses the nearest Shiki built-in theme.
126
+
127
+ ## Detailed Implementation
128
+
129
+ ### Step 1: Install Shiki and update package.json
130
+
131
+ ```bash
132
+ pnpm add shiki
133
+ pnpm remove cli-highlight
134
+ ```
135
+
136
+ Add `engines` field to package.json:
137
+ ```json
138
+ {
139
+ "engines": {
140
+ "node": ">=20"
141
+ }
142
+ }
143
+ ```
144
+
145
+ ### Step 2: Replace imports in main.js
146
+
147
+ **Remove** (line 6):
148
+ ```javascript
149
+ import { highlight, supportsLanguage, plain } from 'cli-highlight';
150
+ ```
151
+
152
+ **Add** (after existing imports):
153
+ ```javascript
154
+ import { createHighlighterCoreSync } from 'shiki/core';
155
+ import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
156
+
157
+ // Shiki themes (individual default imports for tree-shaking)
158
+ import themeNord from '@shikijs/themes/nord';
159
+ import themeDracula from '@shikijs/themes/dracula';
160
+ import themeOneDarkPro from '@shikijs/themes/one-dark-pro';
161
+ import themeGithubDark from '@shikijs/themes/github-dark';
162
+ import themeGithubLight from '@shikijs/themes/github-light';
163
+ import themeSolarizedDark from '@shikijs/themes/solarized-dark';
164
+ import themeSolarizedLight from '@shikijs/themes/solarized-light';
165
+ import themeCatppuccinMocha from '@shikijs/themes/catppuccin-mocha';
166
+ import themeCatppuccinLatte from '@shikijs/themes/catppuccin-latte';
167
+ import themeTokyoNight from '@shikijs/themes/tokyo-night';
168
+ import themeOneLight from '@shikijs/themes/one-light';
169
+ import themeEverforestLight from '@shikijs/themes/everforest-light';
170
+ import themeMinDark from '@shikijs/themes/min-dark';
171
+ import themeMinLight from '@shikijs/themes/min-light';
172
+
173
+ // Shiki languages (top 19 for >95% real-world coverage)
174
+ // Note: @shikijs/langs/bash exports { name: 'shellscript', aliases: ['bash', 'sh', 'shell', 'zsh'] }.
175
+ // getLoadedLanguages() includes all aliases, so 'bash', 'js', 'ts', 'py' etc. all resolve automatically.
176
+ import langJavascript from '@shikijs/langs/javascript';
177
+ import langTypescript from '@shikijs/langs/typescript';
178
+ import langPython from '@shikijs/langs/python';
179
+ import langBash from '@shikijs/langs/bash';
180
+ import langGo from '@shikijs/langs/go';
181
+ import langRust from '@shikijs/langs/rust';
182
+ import langJava from '@shikijs/langs/java';
183
+ import langC from '@shikijs/langs/c';
184
+ import langCpp from '@shikijs/langs/cpp';
185
+ import langRuby from '@shikijs/langs/ruby';
186
+ import langPhp from '@shikijs/langs/php';
187
+ import langHtml from '@shikijs/langs/html';
188
+ import langCss from '@shikijs/langs/css';
189
+ import langJson from '@shikijs/langs/json';
190
+ import langYaml from '@shikijs/langs/yaml';
191
+ import langToml from '@shikijs/langs/toml';
192
+ import langSql from '@shikijs/langs/sql';
193
+ import langMarkdown from '@shikijs/langs/markdown';
194
+ import langDiff from '@shikijs/langs/diff';
195
+ ```
196
+
197
+ ### Step 3: Create Shiki highlighter singleton (lazy)
198
+
199
+ Place this after imports, at module level. The highlighter is created lazily on first
200
+ call to `getHighlighter()`, so `--version` and `--help` skip the initialization cost.
201
+
202
+ ```javascript
203
+ // All Shiki themes used by memd
204
+ const SHIKI_THEMES = [
205
+ themeNord, themeDracula, themeOneDarkPro,
206
+ themeGithubDark, themeGithubLight,
207
+ themeSolarizedDark, themeSolarizedLight,
208
+ themeCatppuccinMocha, themeCatppuccinLatte,
209
+ themeTokyoNight, themeOneLight,
210
+ themeEverforestLight, themeMinDark, themeMinLight,
211
+ ];
212
+
213
+ // All languages pre-loaded (19 languages)
214
+ const SHIKI_LANGS = [
215
+ langJavascript, langTypescript, langPython, langBash,
216
+ langGo, langRust, langJava, langC, langCpp,
217
+ langRuby, langPhp, langHtml, langCss, langJson,
218
+ langYaml, langToml, langSql, langMarkdown, langDiff,
219
+ ];
220
+
221
+ let _highlighter;
222
+ function getHighlighter() {
223
+ if (!_highlighter) {
224
+ _highlighter = createHighlighterCoreSync({
225
+ themes: SHIKI_THEMES,
226
+ langs: SHIKI_LANGS,
227
+ engine: createJavaScriptRegexEngine(),
228
+ });
229
+ }
230
+ return _highlighter;
231
+ }
232
+ ```
233
+
234
+ ### Step 4: Implement THEME_MAP and THEME_NAMES
235
+
236
+ ```javascript
237
+ // Maps memd theme name -> { shikiTheme: string (Shiki theme ID), mermaidTheme: string }
238
+ // shikiTheme: used for syntax highlighting + markdown style extraction
239
+ // mermaidTheme: key into MERMAID_THEMES for diagram colors
240
+ const THEME_MAP = {
241
+ 'nord': { shikiTheme: 'nord', mermaidTheme: 'nord' },
242
+ 'dracula': { shikiTheme: 'dracula', mermaidTheme: 'dracula' },
243
+ 'one-dark': { shikiTheme: 'one-dark-pro', mermaidTheme: 'one-dark' },
244
+ 'github-dark': { shikiTheme: 'github-dark', mermaidTheme: 'github-dark' },
245
+ 'github-light': { shikiTheme: 'github-light', mermaidTheme: 'github-light' },
246
+ 'solarized-dark': { shikiTheme: 'solarized-dark', mermaidTheme: 'solarized-dark' },
247
+ 'solarized-light': { shikiTheme: 'solarized-light', mermaidTheme: 'solarized-light' },
248
+ 'catppuccin-mocha': { shikiTheme: 'catppuccin-mocha', mermaidTheme: 'catppuccin-mocha' },
249
+ 'catppuccin-latte': { shikiTheme: 'catppuccin-latte', mermaidTheme: 'catppuccin-latte' },
250
+ 'tokyo-night': { shikiTheme: 'tokyo-night', mermaidTheme: 'tokyo-night' },
251
+ 'tokyo-night-storm': { shikiTheme: 'tokyo-night', mermaidTheme: 'tokyo-night-storm' },
252
+ 'tokyo-night-light': { shikiTheme: 'one-light', mermaidTheme: 'tokyo-night-light' },
253
+ 'nord-light': { shikiTheme: 'everforest-light', mermaidTheme: 'nord-light' },
254
+ 'zinc-dark': { shikiTheme: 'min-dark', mermaidTheme: 'zinc-dark' },
255
+ 'zinc-light': { shikiTheme: 'min-light', mermaidTheme: 'zinc-light' },
256
+ };
257
+
258
+ // Single source of truth for available theme names (used in --help and validation)
259
+ const THEME_NAMES = Object.keys(THEME_MAP);
260
+ ```
261
+
262
+ ### Step 5: Implement softenHex() and tokensToAnsi()
263
+
264
+ ```javascript
265
+ // Gentle desaturation by blending toward neutral gray (#808080) in sRGB space.
266
+ // Reuses the same linear interpolation approach as mixHex() -- no HSL conversion needed.
267
+ // amount=0.15 means 15% toward gray. Replaces the old mute() (HSL-based) with a simpler,
268
+ // more predictable sRGB blend that avoids hue shifts from HSL rounding.
269
+ function softenHex(hex, amount = 0.15) {
270
+ const parse = (h, o) => parseInt(h.slice(o, o + 2), 16);
271
+ const mix = (c, gray) => Math.round(c * (1 - amount) + gray * amount);
272
+ const toHex = x => x.toString(16).padStart(2, '0');
273
+ const r = mix(parse(hex, 1), 128);
274
+ const g = mix(parse(hex, 3), 128);
275
+ const b = mix(parse(hex, 5), 128);
276
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
277
+ }
278
+
279
+ function tokensToAnsi(lines) {
280
+ if (chalk.level === 0) {
281
+ // --no-color: return plain text without ANSI codes
282
+ return lines.map(line => line.map(t => t.content).join('')).join('\n');
283
+ }
284
+ return lines.map(line =>
285
+ line.map(token => {
286
+ if (!token.color) return token.content;
287
+ const r = parseInt(token.color.slice(1, 3), 16);
288
+ const g = parseInt(token.color.slice(3, 5), 16);
289
+ const b = parseInt(token.color.slice(5, 7), 16);
290
+ return `\x1b[38;2;${r};${g};${b}m${token.content}\x1b[39m`;
291
+ }).join('')
292
+ ).join('\n');
293
+ }
294
+ ```
295
+
296
+ ### Step 6: Implement highlightWithShiki()
297
+
298
+ ```javascript
299
+ function highlightWithShiki(code, lang, shikiThemeName) {
300
+ // getLoadedLanguages() includes all aliases (e.g. 'js', 'ts', 'bash', 'py', 'yml'),
301
+ // so no manual LANG_ALIASES map is needed.
302
+ const hl = getHighlighter();
303
+ const loadedLangs = hl.getLoadedLanguages();
304
+ const effectiveLang = loadedLangs.includes(lang) ? lang : 'text';
305
+
306
+ if (effectiveLang === 'text') {
307
+ // Plain text: no tokenization needed
308
+ return code;
309
+ }
310
+
311
+ const tokens = hl.codeToTokensBase(code, {
312
+ lang: effectiveLang,
313
+ theme: shikiThemeName,
314
+ });
315
+ return tokensToAnsi(tokens);
316
+ }
317
+ ```
318
+
319
+ Note: `codeToTokensBase` returns `ThemedToken[][]` (array of lines, each line is array of tokens with `{ content, color }`).
320
+
321
+ ### Step 7: Implement extractMarkdownStyle()
322
+
323
+ Extracts colors from Shiki theme's `settings` array (Shiki v4 normalizes TextMate `tokenColors` into `settings`) for marked-terminal options. Colors are softened via `softenHex()` to avoid overly saturated output in terminal.
324
+
325
+ ```javascript
326
+ function extractMarkdownStyle(shikiThemeName) {
327
+ const theme = getHighlighter().getTheme(shikiThemeName);
328
+
329
+ const findColor = (...scopes) => {
330
+ for (const scope of scopes) {
331
+ const setting = theme.settings?.find(s => {
332
+ if (!s.scope) return false;
333
+ const scopeList = Array.isArray(s.scope) ? s.scope : [s.scope];
334
+ return scopeList.some(sc => sc === scope || sc.startsWith(scope + '.'));
335
+ });
336
+ if (setting?.settings?.foreground) return setting.settings.foreground;
337
+ }
338
+ return theme.fg;
339
+ };
340
+
341
+ const accent = softenHex(findColor('entity.name.function', 'support.function', 'keyword'));
342
+ const string = softenHex(findColor('string', 'string.quoted'));
343
+ const comment = softenHex(findColor('comment', 'punctuation.definition.comment'));
344
+ const type = softenHex(findColor('entity.name.type', 'support.type', 'storage.type'));
345
+ const param = softenHex(findColor('variable.parameter', 'variable.other', 'entity.name.tag'));
346
+ const fg = theme.fg;
347
+
348
+ return {
349
+ firstHeading: chalk.hex(accent).underline.bold,
350
+ heading: chalk.hex(accent).bold,
351
+ code: chalk.hex(string),
352
+ codespan: chalk.hex(string),
353
+ blockquote: chalk.hex(comment).italic,
354
+ strong: chalk.hex(fg).bold,
355
+ em: chalk.hex(param).italic,
356
+ del: chalk.hex(comment).strikethrough,
357
+ link: chalk.hex(type),
358
+ href: chalk.hex(type).underline,
359
+ };
360
+ }
361
+ ```
362
+
363
+ ### Step 8: Rewire main.js terminal path (action function)
364
+
365
+ Replace the terminal path code inside the `action` callback (main.js lines 451-525).
366
+
367
+ **Before** (current code, lines 460-515):
368
+ ```javascript
369
+ const shouldHighlight = useColor;
370
+ const selectedTheme = resolveTerminalTheme(options.theme, diagramColors);
371
+ const highlightOptions = shouldHighlight
372
+ ? {
373
+ ignoreIllegals: true,
374
+ ...(selectedTheme.highlight ? { theme: selectedTheme.highlight } : {}),
375
+ }
376
+ : undefined;
377
+
378
+ marked.use(markedTerminal({
379
+ reflowText: true,
380
+ width: options.width ?? process.stdout.columns ?? 80,
381
+ ...selectedTheme.markdown,
382
+ }, highlightOptions));
383
+
384
+ // ... link renderer override ...
385
+
386
+ if (!shouldHighlight) {
387
+ marked.use({ renderer: { code(token) { ... } } });
388
+ }
389
+
390
+ // ... console.error suppression hack ...
391
+ const origConsoleError = console.error;
392
+ for (const markdown of markdownParts) {
393
+ const processed = convertMermaidToAscii(markdown, { ... });
394
+ console.error = () => {};
395
+ parts.push(marked.parse(processed));
396
+ console.error = origConsoleError;
397
+ }
398
+ ```
399
+
400
+ **After** (new code):
401
+ ```javascript
402
+ // themeEntry is already declared in Step 10 (action scope, before if/else)
403
+ const shikiThemeName = themeEntry.shikiTheme;
404
+
405
+ // Extract markdown element styling from Shiki theme (with softenHex desaturation)
406
+ const markdownStyle = extractMarkdownStyle(shikiThemeName);
407
+
408
+ // Pass null for highlightOptions to marked-terminal.
409
+ // Note: marked-terminal internally converts null to {} and would still call
410
+ // cli-highlight, but the code renderer override below intercepts all code
411
+ // blocks before marked-terminal's internal highlight function is reached.
412
+ marked.use(markedTerminal({
413
+ reflowText: true,
414
+ width: options.width ?? process.stdout.columns ?? 80,
415
+ ...markdownStyle,
416
+ }, null));
417
+
418
+ // Override link renderer (unchanged)
419
+ marked.use({
420
+ renderer: {
421
+ link(token) {
422
+ return token.text + (token.text !== token.href ? ` (${token.href})` : '');
423
+ }
424
+ }
425
+ });
426
+
427
+ // Override code renderer to use Shiki instead of cli-highlight.
428
+ // This marked.use() call MUST come after markedTerminal() so that it takes
429
+ // precedence over marked-terminal's internal code renderer (marked v17+
430
+ // applies later overrides first).
431
+ marked.use({
432
+ renderer: {
433
+ code(token) {
434
+ const lang = token.lang || '';
435
+ const code = typeof token === 'string' ? token : (token.text || token);
436
+ const highlighted = highlightWithShiki(String(code), lang, shikiThemeName);
437
+ const lines = highlighted.split('\n');
438
+ const indented = lines.map(line => ' ' + line).join('\n');
439
+ return indented + '\n';
440
+ }
441
+ }
442
+ });
443
+
444
+ // Convert DiagramColors to AsciiTheme for terminal rendering
445
+ const asciiTheme = diagramColorsToAsciiTheme(diagramColors);
446
+ const colorMode = useColor ? undefined : 'none';
447
+
448
+ const parts = [];
449
+ // No more console.error suppression needed (highlight.js is gone)
450
+ for (const markdown of markdownParts) {
451
+ const processed = convertMermaidToAscii(markdown, {
452
+ useAscii: options.ascii,
453
+ colorMode,
454
+ theme: asciiTheme,
455
+ });
456
+ parts.push(marked.parse(processed));
457
+ }
458
+ ```
459
+
460
+ Key changes:
461
+ - `resolveTerminalTheme()` replaced by `THEME_MAP` lookup + `extractMarkdownStyle()`
462
+ - `highlightOptions` passed as `null` to `markedTerminal()` (cli-highlight never reached due to code renderer override)
463
+ - Code renderer always overridden (uses Shiki for color, respects `chalk.level === 0` via `tokensToAnsi()`)
464
+ - `console.error` suppression hack removed entirely
465
+
466
+ ### Step 9: Remove dead code from main.js
467
+
468
+ Delete these functions and constants (in order of appearance):
469
+
470
+ | Lines | Item |
471
+ |-------|------|
472
+ | 19-37 | `hexToHsl()` |
473
+ | 39-61 | `hslToHex()` |
474
+ | 64-69 | `mute()` |
475
+ | 88-133 | `buildTheme()` |
476
+ | 136-172 | `TERMINAL_THEMES` |
477
+ | 175-193 | `buildMarkdownFromDiagramColors()` |
478
+ | 195-197 | `resolveTerminalTheme()` |
479
+ | 230-234 | `resolveDiagramTheme()` |
480
+
481
+ Also remove the `cli-highlight` import line (line 6).
482
+
483
+ ### Step 10: Unify theme validation via THEME_MAP
484
+
485
+ Replace the current `resolveDiagramTheme()` call in the action callback with unified `THEME_MAP` validation. This single check serves both HTML and terminal paths:
486
+
487
+ ```javascript
488
+ // In action callback, replace:
489
+ // diagramColors = resolveDiagramTheme(options.theme);
490
+ // With:
491
+ if (!(options.theme in THEME_MAP)) {
492
+ const names = Object.keys(THEME_MAP).join(', ');
493
+ console.error(`Unknown theme: ${options.theme}\nAvailable themes: ${names}`);
494
+ process.exit(1);
495
+ }
496
+ const themeEntry = THEME_MAP[options.theme];
497
+ const diagramColors = MERMAID_THEMES[themeEntry.mermaidTheme];
498
+ if (!diagramColors) {
499
+ console.error(`Internal error: mermaid theme '${themeEntry.mermaidTheme}' not found in beautiful-mermaid`);
500
+ process.exit(1);
501
+ }
502
+ ```
503
+
504
+ This replaces `resolveDiagramTheme()` entirely. Both paths then use `diagramColors` directly:
505
+ - **HTML path**: `renderToHTML(combined, diagramColors)` -- unchanged
506
+ - **Terminal path**: `themeEntry.shikiTheme` for Shiki, `diagramColors` for ASCII diagrams
507
+
508
+ ### Step 11: Update tests
509
+
510
+ **test/memd.test.js changes:**
511
+
512
+ 1. **Update `--no-color strips ANSI from all highlight themes` test** (lines 285-298):
513
+ - Currently tests only 5 hand-crafted themes: `dracula, one-dark, github-dark, solarized-dark, nord`
514
+ - Expand to test all 15 themes since Shiki provides highlighting for all
515
+
516
+ ```javascript
517
+ describe('--no-color strips ANSI from all themes', () => {
518
+ const themes = [
519
+ 'nord', 'dracula', 'one-dark', 'github-dark', 'github-light',
520
+ 'solarized-dark', 'solarized-light', 'catppuccin-mocha', 'catppuccin-latte',
521
+ 'tokyo-night', 'tokyo-night-storm', 'tokyo-night-light',
522
+ 'nord-light', 'zinc-dark', 'zinc-light',
523
+ ];
524
+
525
+ for (const theme of themes) {
526
+ it(`--theme ${theme} --no-color has no ANSI codes`, async () => {
527
+ const output = await run(
528
+ ['--no-pager', '--no-color', '--theme', theme, 'test/test-highlight.md'],
529
+ { waitFor: t => t.includes('TypeScript') },
530
+ );
531
+ // eslint-disable-next-line no-control-regex
532
+ expect(output).not.toMatch(/\x1b\[[\d;]*m/);
533
+ });
534
+ }
535
+ });
536
+ ```
537
+
538
+ 2. **Add Shiki highlighting test** (new):
539
+ ```javascript
540
+ it('syntax highlighting produces truecolor ANSI for known languages', async () => {
541
+ const output = await run(
542
+ ['--no-pager', '--theme', 'nord', 'test/test-highlight.md'],
543
+ { waitFor: t => t.includes('TypeScript') },
544
+ );
545
+ // Truecolor ANSI escape: ESC[38;2;R;G;Bm
546
+ // eslint-disable-next-line no-control-regex
547
+ expect(output).toMatch(/\x1b\[38;2;\d+;\d+;\d+m/);
548
+ });
549
+ ```
550
+
551
+ 3. **Add unknown language fallback test** (new):
552
+ ```javascript
553
+ it('unknown language falls back to plain text without errors', async () => {
554
+ const session = await launchTerminal({
555
+ command: 'sh',
556
+ args: ['-c', `echo '# Test\n\n\`\`\`unknownlang\nsome code\n\`\`\`' | node ${MAIN} --no-pager --no-color`],
557
+ cols: 80,
558
+ rows: 10,
559
+ waitForData: false,
560
+ });
561
+ const output = (await session.text({
562
+ waitFor: t => t.includes('some code'),
563
+ timeout: 8000,
564
+ })).trim();
565
+ expect(output).toContain('some code');
566
+ // No error output expected
567
+ expect(output).not.toContain('Error');
568
+ expect(output).not.toContain('Could not find the language');
569
+ });
570
+ ```
571
+
572
+ 4. **Add cli-highlight bypass verification test** (new):
573
+ ```javascript
574
+ it('cli-highlight is not invoked (no highlight.js errors in output)', async () => {
575
+ // Use a language that highlight.js would warn about but Shiki handles gracefully
576
+ const session = await launchTerminal({
577
+ command: 'sh',
578
+ args: ['-c', `echo '# Test\n\n\`\`\`rust\nfn main() {}\n\`\`\`' | node ${MAIN} --no-pager`],
579
+ cols: 80,
580
+ rows: 10,
581
+ waitForData: false,
582
+ });
583
+ const output = (await session.text({
584
+ waitFor: t => t.includes('fn main'),
585
+ timeout: 8000,
586
+ })).trim();
587
+ expect(output).toContain('fn main');
588
+ expect(output).not.toContain('Could not find the language');
589
+ });
590
+ ```
591
+
592
+ 5. **Existing tests that should pass without changes:**
593
+ - `--version`, `--help`, basic rendering tests (test1.md, test2.md, test-br.md, test-cjk.md)
594
+ - HTML path tests (no changes to HTML rendering)
595
+ - Terminal theme render tests (dracula, tokyo-night, one-dark, nonexistent, default nord)
596
+ - `--no-color strips ANSI` test (line 85-92)
597
+ - stdin test
598
+
599
+ ### Step 12: Update README.md theme list
600
+
601
+ The theme list in README.md (lines 31-34) should remain unchanged since all 15 themes are preserved. No action needed unless the order or formatting changes.
602
+
603
+ ## Dependency Changes
604
+
605
+ ### package.json (after)
606
+
607
+ ```json
608
+ {
609
+ "engines": {
610
+ "node": ">=20"
611
+ },
612
+ "dependencies": {
613
+ "beautiful-mermaid": "^1.1.2",
614
+ "chalk": "^5.6.2",
615
+ "commander": "^14.0.3",
616
+ "marked": "^17.0.3",
617
+ "marked-terminal": "^7.3.0",
618
+ "shiki": "^4.0.0"
619
+ }
620
+ }
621
+ ```
622
+
623
+ - **Added**: `shiki` (its `dependencies` include `@shikijs/themes`, `@shikijs/langs`, `@shikijs/engine-javascript`, `@shikijs/core`, `@shikijs/types` -- all automatically installed by `pnpm add shiki`)
624
+ - **Removed**: `cli-highlight` (remains as transitive dependency of `marked-terminal`, but memd code no longer imports it)
625
+ - **Unchanged**: `beautiful-mermaid`, `chalk`, `commander`, `marked`, `marked-terminal`
626
+
627
+ ## File-level Diff Summary
628
+
629
+ ### main.js
630
+
631
+ ```
632
+ REMOVE import { highlight, supportsLanguage, plain } from 'cli-highlight'
633
+ ADD import { createHighlighterCoreSync } from 'shiki/core'
634
+ ADD import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
635
+ ADD import themeNord from '@shikijs/themes/nord' (x14 themes)
636
+ ADD import langJavascript from '@shikijs/langs/javascript' (x19 langs)
637
+ REMOVE hexToHsl() (lines 19-37)
638
+ REMOVE hslToHex() (lines 39-61)
639
+ REMOVE mute() (lines 64-69)
640
+ REMOVE buildTheme() (lines 88-133)
641
+ REMOVE TERMINAL_THEMES (lines 136-172)
642
+ REMOVE buildMarkdownFromDiagramColors() (lines 175-193)
643
+ REMOVE resolveTerminalTheme() (lines 195-197)
644
+ REMOVE resolveDiagramTheme() (lines 230-234)
645
+ ADD getHighlighter() lazy singleton (createHighlighterCoreSync)
646
+ ADD THEME_MAP
647
+ ADD THEME_NAMES = Object.keys(THEME_MAP)
648
+ ADD softenHex()
649
+ ADD tokensToAnsi()
650
+ ADD highlightWithShiki()
651
+ ADD extractMarkdownStyle()
652
+ MODIFY action: unified theme validation via THEME_MAP (replaces resolveDiagramTheme)
653
+ MODIFY action: markedTerminal() with extractMarkdownStyle() + null highlightOptions
654
+ MODIFY action: code renderer always uses Shiki (override after markedTerminal)
655
+ REMOVE action: console.error suppression hack
656
+ KEEP mixHex(), MIX, diagramColorsToAsciiTheme(), resolveThemeColors()
657
+ KEEP escapeHtml(), readMarkdownFile(), convertMermaidToAscii(), convertMermaidToSVG()
658
+ KEEP renderToHTML(), readStdin(), shouldUsePager(), spawnPager()
659
+ ```
660
+
661
+ ### package.json
662
+
663
+ ```
664
+ ADD "engines": { "node": ">=20" }
665
+ ADD "shiki": "^4.0.0"
666
+ REMOVE "cli-highlight": "^2.1.11"
667
+ ```
668
+
669
+ ### test/memd.test.js
670
+
671
+ ```
672
+ MODIFY --no-color theme loop: expand from 5 themes to 15 themes
673
+ ADD truecolor ANSI test (Shiki produces ESC[38;2;R;G;Bm)
674
+ ADD unknown language fallback test
675
+ ADD cli-highlight bypass verification test
676
+ KEEP all other existing tests
677
+ ```
678
+
679
+ ## Implementation Order
680
+
681
+ 1. `pnpm add shiki && pnpm remove cli-highlight`
682
+ 2. Add `"engines": { "node": ">=20" }` to package.json
683
+ 3. Replace imports in main.js (remove cli-highlight, add Shiki)
684
+ 4. Add highlighter lazy singleton + THEME_MAP + THEME_NAMES at module level
685
+ 5. Add `softenHex()`, `tokensToAnsi()`, `highlightWithShiki()`, `extractMarkdownStyle()`
686
+ 6. Rewire action: unified THEME_MAP validation (replaces resolveDiagramTheme), markedTerminal with null, code renderer with Shiki
687
+ 7. Remove dead code: hexToHsl, hslToHex, mute, buildTheme, TERMINAL_THEMES, buildMarkdownFromDiagramColors, resolveTerminalTheme, resolveDiagramTheme, console.error hack
688
+ 8. Run existing tests: `pnpm test`
689
+ 9. Update tests (expand --no-color loop, add Shiki-specific tests, add bypass test)
690
+ 10. Manual verification: `node main.js test/test-highlight.md` with several themes
691
+
692
+ ## Risk Assessment
693
+
694
+ | Risk | Likelihood | Mitigation |
695
+ |------|-----------|------------|
696
+ | Shiki startup time slows CLI | Low | Lazy init via `getHighlighter()`: only pays cost when rendering (not for --version/--help). Estimated 100-300ms on first render call. |
697
+ | Package size increase | Low | shiki ~10MB vs cli-highlight ~8MB (with highlight.js). Same order. Tree-shaking via individual imports. |
698
+ | Code renderer override not intercepting all code blocks | Low | marked v17+ applies later `marked.use()` overrides first. Code renderer MUST be registered after `markedTerminal()`. Verified by cli-highlight bypass test. |
699
+ | `codeToTokensBase()` API differs from plan | Low | Verified in Shiki v4.0.2 docs. Sync API via `createHighlighterCoreSync()`. |
700
+ | Language aliases not resolved | Very Low | Confirmed: `getLoadedLanguages()` includes all aliases (js, ts, bash, py, yml, etc.). No manual alias map needed. |
701
+ | `extractMarkdownStyle()` returns wrong colors for some themes | Low | Test all 15 themes visually. Token scopes are standard TextMate scopes. `softenHex()` prevents overly saturated output. |
702
+ | `softenHex()` amount too strong/weak | Low | Default `amount=0.15` is conservative. sRGB blend is more predictable than HSL mute (no hue shifts). Tune after visual testing. |
703
+
704
+ ## Scope Exclusions
705
+
706
+ - HTML path: unchanged (uses `new Marked()` without syntax highlighting; confirmed no cli-highlight usage)
707
+ - Diagram rendering: unchanged (beautiful-mermaid)
708
+ - Pager logic: unchanged
709
+ - `--ascii` option: unchanged
710
+ - stdin/file reading: unchanged