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.
- package/.claude/settings.local.json +6 -1
- package/README.md +19 -5
- package/highlight-plan.md +710 -0
- package/main.js +333 -215
- package/package.json +6 -3
- package/poc-html.mjs +134 -0
- package/poc-output.html +460 -0
- package/svgplan.md +805 -0
- package/test/memd.test.js +199 -1
|
@@ -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
|