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/.claude/settings.local.json +6 -1
- package/README.md +19 -5
- package/highlight-plan.md +710 -0
- package/main.js +332 -214
- 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
package/main.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// @ts-nocheck
|
|
3
|
-
import { marked } from 'marked';
|
|
3
|
+
import { marked, Marked } from 'marked';
|
|
4
4
|
import { markedTerminal } from 'marked-terminal';
|
|
5
|
-
import { renderMermaidASCII } from 'beautiful-mermaid';
|
|
6
|
-
import { highlight, supportsLanguage, plain } from 'cli-highlight';
|
|
5
|
+
import { renderMermaidASCII, renderMermaidSVG, THEMES as MERMAID_THEMES } from 'beautiful-mermaid';
|
|
7
6
|
import chalk from 'chalk';
|
|
8
7
|
import { program } from 'commander';
|
|
9
8
|
import { spawn } from 'child_process';
|
|
@@ -11,151 +10,198 @@ import * as fs from 'fs';
|
|
|
11
10
|
import * as path from 'path';
|
|
12
11
|
import { fileURLToPath } from 'url';
|
|
13
12
|
|
|
13
|
+
import { createHighlighterCoreSync } from 'shiki/core';
|
|
14
|
+
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
|
|
15
|
+
import { bundledThemes } from 'shiki/themes';
|
|
16
|
+
import { bundledLanguages } from 'shiki/langs';
|
|
17
|
+
|
|
14
18
|
const __filename = fileURLToPath(import.meta.url);
|
|
15
19
|
const __dirname = path.dirname(__filename);
|
|
16
20
|
const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf-8'));
|
|
17
21
|
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
22
|
+
// Shiki theme IDs used by memd (keys into bundledThemes)
|
|
23
|
+
const SHIKI_THEME_IDS = [
|
|
24
|
+
'nord', 'dracula', 'one-dark-pro',
|
|
25
|
+
'github-dark', 'github-light',
|
|
26
|
+
'solarized-dark', 'solarized-light',
|
|
27
|
+
'catppuccin-mocha', 'catppuccin-latte',
|
|
28
|
+
'tokyo-night', 'one-light',
|
|
29
|
+
'everforest-light', 'min-dark', 'min-light',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// Language IDs to preload (top 19 for >95% real-world coverage)
|
|
33
|
+
const SHIKI_LANG_IDS = [
|
|
34
|
+
'javascript', 'typescript', 'python', 'shellscript',
|
|
35
|
+
'go', 'rust', 'java', 'c', 'cpp',
|
|
36
|
+
'ruby', 'php', 'html', 'css', 'json',
|
|
37
|
+
'yaml', 'toml', 'sql', 'markdown', 'diff',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
let _highlighter;
|
|
41
|
+
async function getHighlighter() {
|
|
42
|
+
if (!_highlighter) {
|
|
43
|
+
const [themes, langs] = await Promise.all([
|
|
44
|
+
Promise.all(SHIKI_THEME_IDS.map(id => bundledThemes[id]().then(m => m.default))),
|
|
45
|
+
Promise.all(SHIKI_LANG_IDS.map(id => bundledLanguages[id]().then(m => m.default))),
|
|
46
|
+
]);
|
|
47
|
+
_highlighter = createHighlighterCoreSync({
|
|
48
|
+
themes,
|
|
49
|
+
langs,
|
|
50
|
+
engine: createJavaScriptRegexEngine(),
|
|
51
|
+
});
|
|
35
52
|
}
|
|
36
|
-
return
|
|
53
|
+
return _highlighter;
|
|
37
54
|
}
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
// Maps memd theme name -> { shikiTheme: string (Shiki theme ID), mermaidTheme: string }
|
|
57
|
+
const THEME_MAP = {
|
|
58
|
+
'nord': { shikiTheme: 'nord', mermaidTheme: 'nord' },
|
|
59
|
+
'dracula': { shikiTheme: 'dracula', mermaidTheme: 'dracula' },
|
|
60
|
+
'one-dark': { shikiTheme: 'one-dark-pro', mermaidTheme: 'one-dark' },
|
|
61
|
+
'github-dark': { shikiTheme: 'github-dark', mermaidTheme: 'github-dark' },
|
|
62
|
+
'github-light': { shikiTheme: 'github-light', mermaidTheme: 'github-light' },
|
|
63
|
+
'solarized-dark': { shikiTheme: 'solarized-dark', mermaidTheme: 'solarized-dark' },
|
|
64
|
+
'solarized-light': { shikiTheme: 'solarized-light', mermaidTheme: 'solarized-light' },
|
|
65
|
+
'catppuccin-mocha': { shikiTheme: 'catppuccin-mocha', mermaidTheme: 'catppuccin-mocha' },
|
|
66
|
+
'catppuccin-latte': { shikiTheme: 'catppuccin-latte', mermaidTheme: 'catppuccin-latte' },
|
|
67
|
+
'tokyo-night': { shikiTheme: 'tokyo-night', mermaidTheme: 'tokyo-night' },
|
|
68
|
+
'tokyo-night-storm': { shikiTheme: 'tokyo-night', mermaidTheme: 'tokyo-night-storm' },
|
|
69
|
+
'tokyo-night-light': { shikiTheme: 'one-light', mermaidTheme: 'tokyo-night-light' },
|
|
70
|
+
'nord-light': { shikiTheme: 'everforest-light', mermaidTheme: 'nord-light' },
|
|
71
|
+
'zinc-dark': { shikiTheme: 'min-dark', mermaidTheme: 'zinc-dark' },
|
|
72
|
+
'zinc-light': { shikiTheme: 'min-light', mermaidTheme: 'zinc-light' },
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// Single source of truth for available theme names (used in --help and validation)
|
|
76
|
+
const THEME_NAMES = Object.keys(THEME_MAP);
|
|
77
|
+
|
|
78
|
+
// Color mixing: blend hex1 into hex2 at pct% (sRGB linear interpolation)
|
|
79
|
+
// Equivalent to CSS color-mix(in srgb, hex1 pct%, hex2)
|
|
80
|
+
function mixHex(hex1, hex2, pct) {
|
|
81
|
+
const p = pct / 100;
|
|
82
|
+
const parse = (h, o) => parseInt(h.slice(o, o + 2), 16);
|
|
83
|
+
const mix = (c1, c2) => Math.round(c1 * p + c2 * (1 - p));
|
|
84
|
+
const toHex = x => x.toString(16).padStart(2, '0');
|
|
85
|
+
const r = mix(parse(hex1, 1), parse(hex2, 1));
|
|
86
|
+
const g = mix(parse(hex1, 3), parse(hex2, 3));
|
|
87
|
+
const b = mix(parse(hex1, 5), parse(hex2, 5));
|
|
60
88
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
61
89
|
}
|
|
62
90
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
91
|
+
// MIX ratios from beautiful-mermaid theme.ts:64-87
|
|
92
|
+
const MIX = { line: 50, arrow: 85, textSec: 60, nodeStroke: 20 };
|
|
93
|
+
|
|
94
|
+
// Gentle desaturation by blending toward neutral gray (#808080) in sRGB space.
|
|
95
|
+
// amount=0.15 means 15% toward gray. Replaces the old mute() (HSL-based) with a simpler,
|
|
96
|
+
// more predictable sRGB blend that avoids hue shifts from HSL rounding.
|
|
97
|
+
function softenHex(hex, amount = 0.15) {
|
|
98
|
+
const parse = (h, o) => parseInt(h.slice(o, o + 2), 16);
|
|
99
|
+
const mix = (c, gray) => Math.round(c * (1 - amount) + gray * amount);
|
|
100
|
+
const toHex = x => x.toString(16).padStart(2, '0');
|
|
101
|
+
const r = mix(parse(hex, 1), 128);
|
|
102
|
+
const g = mix(parse(hex, 3), 128);
|
|
103
|
+
const b = mix(parse(hex, 5), 128);
|
|
104
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function tokensToAnsi(lines) {
|
|
108
|
+
if (chalk.level === 0) {
|
|
109
|
+
// --no-color: return plain text without ANSI codes
|
|
110
|
+
return lines.map(line => line.map(t => t.content).join('')).join('\n');
|
|
111
|
+
}
|
|
112
|
+
return lines.map(line =>
|
|
113
|
+
line.map(token => {
|
|
114
|
+
if (!token.color) return token.content;
|
|
115
|
+
const r = parseInt(token.color.slice(1, 3), 16);
|
|
116
|
+
const g = parseInt(token.color.slice(3, 5), 16);
|
|
117
|
+
const b = parseInt(token.color.slice(5, 7), 16);
|
|
118
|
+
return `\x1b[38;2;${r};${g};${b}m${token.content}\x1b[39m`;
|
|
119
|
+
}).join('')
|
|
120
|
+
).join('\n');
|
|
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
|
-
//
|
|
72
|
-
function
|
|
73
|
-
const
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
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
|
-
|
|
202
|
+
function escapeHtml(str) {
|
|
203
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
204
|
+
}
|
|
159
205
|
|
|
160
206
|
function readMarkdownFile(filePath) {
|
|
161
207
|
const absolutePath = path.resolve(filePath);
|
|
@@ -171,20 +217,25 @@ function readMarkdownFile(filePath) {
|
|
|
171
217
|
throw new Error('File not found: ' + absolutePath);
|
|
172
218
|
}
|
|
173
219
|
|
|
174
|
-
|
|
220
|
+
// Resolve symlinks and re-check to prevent symlink-based traversal
|
|
221
|
+
const realPath = fs.realpathSync(absolutePath);
|
|
222
|
+
const realRelative = path.relative(currentDirResolved, realPath);
|
|
223
|
+
if (realRelative.startsWith('..') || path.isAbsolute(realRelative)) {
|
|
224
|
+
throw new Error('Invalid path: access outside current directory is not allowed');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return fs.readFileSync(realPath, 'utf-8');
|
|
175
228
|
}
|
|
176
229
|
|
|
177
|
-
function convertMermaidToAscii(markdown,
|
|
230
|
+
function convertMermaidToAscii(markdown, { useAscii = false, colorMode, theme } = {}) {
|
|
178
231
|
const mermaidRegex = /```mermaid\s+([\s\S]+?)```/g;
|
|
179
232
|
|
|
180
233
|
return markdown.replace(mermaidRegex, function (_, code) {
|
|
181
234
|
try {
|
|
182
235
|
const mermaidOptions = {};
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
// Set color mode based on --no-color flag
|
|
187
|
-
mermaidOptions.colorMode = options.color === false ? 'none' : 'none';
|
|
236
|
+
if (useAscii) mermaidOptions.useAscii = true;
|
|
237
|
+
if (colorMode !== undefined) mermaidOptions.colorMode = colorMode;
|
|
238
|
+
if (theme) mermaidOptions.theme = theme;
|
|
188
239
|
const asciiArt = renderMermaidASCII(code.trim(), mermaidOptions);
|
|
189
240
|
return '```text\n' + asciiArt + '\n```';
|
|
190
241
|
} catch (error) {
|
|
@@ -196,17 +247,56 @@ function convertMermaidToAscii(markdown, options = {}) {
|
|
|
196
247
|
});
|
|
197
248
|
}
|
|
198
249
|
|
|
199
|
-
function
|
|
200
|
-
const
|
|
201
|
-
let
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
250
|
+
function convertMermaidToSVG(markdown, diagramTheme) {
|
|
251
|
+
const mermaidRegex = /```mermaid\s+([\s\S]+?)```/g;
|
|
252
|
+
let svgIndex = 0;
|
|
253
|
+
return markdown.replace(mermaidRegex, (_, code) => {
|
|
254
|
+
try {
|
|
255
|
+
const prefix = `m${svgIndex++}`;
|
|
256
|
+
let svg = renderMermaidSVG(code.trim(), diagramTheme);
|
|
257
|
+
svg = svg.replace(/@import url\([^)]+\);\s*/g, '');
|
|
258
|
+
// Prefix all id="..." and url(#...) to avoid cross-SVG collisions
|
|
259
|
+
// Note: regex uses ` id=` (with leading space) to avoid matching `data-id`
|
|
260
|
+
svg = svg.replace(/ id="([^"]+)"/g, ` id="${prefix}-$1"`);
|
|
261
|
+
svg = svg.replace(/url\(#([^)]+)\)/g, `url(#${prefix}-$1)`);
|
|
262
|
+
return svg;
|
|
263
|
+
} catch (e) {
|
|
264
|
+
return `<pre class="mermaid-error">${escapeHtml(e.message)}\n\n${escapeHtml(code.trim())}</pre>`;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
208
268
|
|
|
209
|
-
|
|
269
|
+
function renderToHTML(markdown, diagramColors) {
|
|
270
|
+
const processed = convertMermaidToSVG(markdown, diagramColors);
|
|
271
|
+
const htmlMarked = new Marked();
|
|
272
|
+
const body = htmlMarked.parse(processed);
|
|
273
|
+
const t = resolveThemeColors(diagramColors);
|
|
274
|
+
|
|
275
|
+
return `<!DOCTYPE html>
|
|
276
|
+
<html lang="en">
|
|
277
|
+
<head>
|
|
278
|
+
<meta charset="utf-8">
|
|
279
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
280
|
+
<style>
|
|
281
|
+
body { background: ${t.bg}; color: ${t.fg}; font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
|
|
282
|
+
a { color: ${t.accent}; }
|
|
283
|
+
hr { border-color: ${t.line}; }
|
|
284
|
+
blockquote { border-left: 3px solid ${t.line}; color: ${t.muted}; padding-left: 1rem; }
|
|
285
|
+
svg { max-width: 100%; height: auto; }
|
|
286
|
+
pre { background: color-mix(in srgb, ${t.fg} 8%, ${t.bg}); padding: 1rem; border-radius: 6px; overflow-x: auto; }
|
|
287
|
+
code { font-size: 0.9em; color: ${t.accent}; }
|
|
288
|
+
pre code { color: inherit; }
|
|
289
|
+
table { border-collapse: collapse; }
|
|
290
|
+
th, td { border: 1px solid ${t.line}; padding: 0.4rem 0.8rem; }
|
|
291
|
+
th { background: color-mix(in srgb, ${t.fg} 5%, ${t.bg}); }
|
|
292
|
+
.mermaid-error { background: color-mix(in srgb, ${t.accent} 10%, ${t.bg}); border: 1px solid color-mix(in srgb, ${t.accent} 40%, ${t.bg}); color: ${t.fg}; padding: 1rem; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; }
|
|
293
|
+
</style>
|
|
294
|
+
</head>
|
|
295
|
+
<body>
|
|
296
|
+
${body.trimEnd()}
|
|
297
|
+
</body>
|
|
298
|
+
</html>
|
|
299
|
+
`;
|
|
210
300
|
}
|
|
211
301
|
|
|
212
302
|
function readStdin() {
|
|
@@ -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
|
|
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('--
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
console.error(`Unknown theme: ${
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const shouldHighlight = useColor;
|
|
293
|
-
|
|
294
|
-
// Configure marked with terminal renderer
|
|
295
|
-
const selectedTheme = THEMES[themeName];
|
|
296
|
-
const highlightOptions = shouldHighlight
|
|
297
|
-
? {
|
|
298
|
-
ignoreIllegals: true,
|
|
299
|
-
...(selectedTheme.highlight ? { theme: selectedTheme.highlight } : {}),
|
|
300
|
-
}
|
|
301
|
-
: undefined;
|
|
302
|
-
|
|
303
|
-
marked.use(markedTerminal({
|
|
304
|
-
reflowText: true,
|
|
305
|
-
width: options.width ?? process.stdout.columns ?? 80,
|
|
306
|
-
...selectedTheme.markdown,
|
|
307
|
-
}, highlightOptions));
|
|
308
|
-
|
|
309
|
-
// Override link renderer to avoid OSC 8 escape sequences (fixes tmux display issues)
|
|
310
|
-
marked.use({
|
|
311
|
-
renderer: {
|
|
312
|
-
link(token) {
|
|
313
|
-
// URLとテキストを両方表示する場合
|
|
314
|
-
return token.text + (token.text !== token.href ? ` (${token.href})` : '');
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
// If highlighting is disabled, override the code renderer to bypass cli-highlight
|
|
320
|
-
if (!shouldHighlight) {
|
|
321
|
-
marked.use({
|
|
322
|
-
renderer: {
|
|
323
|
-
code(token) {
|
|
324
|
-
// Extract code text from token
|
|
325
|
-
const codeText = typeof token === 'string' ? token : (token.text || token);
|
|
326
|
-
// Return plain code without highlighting
|
|
327
|
-
// Note: We still need to format it as marked-terminal would
|
|
328
|
-
const lines = String(codeText).split('\n');
|
|
329
|
-
const indented = lines.map(line => ' ' + line).join('\n');
|
|
330
|
-
return indented + '\n';
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
});
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
|