memd-cli 3.0.1 → 3.1.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 +22 -0
- package/CLAUDE.md +72 -3
- package/README.md +25 -0
- package/main.js +2 -1
- package/package.json +3 -3
- package/render-shared.js +1 -1
- package/test/complex.md +21 -0
- package/test/memd.test.js +114 -99
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(echo:*)",
|
|
5
|
+
"Bash(FORCE_COLOR=1 echo:*)",
|
|
6
|
+
"Bash(script:*)",
|
|
7
|
+
"Bash(FORCE_COLOR=1 node:*)",
|
|
8
|
+
"Bash(node -e \"import\\('beautiful-mermaid/src/ascii/ansi.ts'\\).then\\(m=>console.log\\(Object.keys\\(m\\)\\)\\).catch\\(e=>console.error\\('ERROR:',e.message\\)\\)\" 2>&1 | head -5)",
|
|
9
|
+
"Bash(node main.js test/test-highlight.md --no-pager --theme catppuccin-mocha 2>&1 | head -3 | xxd | head -10)",
|
|
10
|
+
"Bash(FORCE_COLOR=1 node main.js test/test-highlight.md --no-pager --theme catppuccin-mocha 2>&1 | head -3 | xxd | head -5)",
|
|
11
|
+
"Bash(FORCE_COLOR=1 node main.js test/test-highlight.md --no-pager --theme zinc-dark 2>&1 | head -3 | xxd | head -5)",
|
|
12
|
+
"mcp__grep-github__searchGitHub",
|
|
13
|
+
"Bash(npx npm-check-updates:*)",
|
|
14
|
+
"Bash(MEMD_THEME= pnpm test 2>&1 | tail -20)",
|
|
15
|
+
"Bash(MEMD_THEME= npx vitest run 2>&1)",
|
|
16
|
+
"Bash(node:*)",
|
|
17
|
+
"Bash(printf:*)",
|
|
18
|
+
"WebFetch(domain:registry.npmjs.org)",
|
|
19
|
+
"WebFetch(domain:api.github.com)"
|
|
20
|
+
]
|
|
21
|
+
}
|
|
22
|
+
}
|
package/CLAUDE.md
CHANGED
|
@@ -1,4 +1,73 @@
|
|
|
1
|
-
|
|
1
|
+
# memd
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Markdown viewer CLI with Mermaid diagram support. Terminal rendering and HTTP serve mode.
|
|
4
|
+
|
|
5
|
+
## Structure
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
memd/
|
|
9
|
+
main.js # CLI entry point (commander). Terminal render + `serve` sub-command
|
|
10
|
+
render-shared.js # HTML rendering: Mermaid SVG conversion, marked HTML output
|
|
11
|
+
render-utils.js # Pure helpers: escapeHtml, mixHex, resolveThemeColors
|
|
12
|
+
render-worker.js # Worker thread for serve mode (calls renderToHTML)
|
|
13
|
+
package.json
|
|
14
|
+
pnpm-lock.yaml
|
|
15
|
+
.npmrc
|
|
16
|
+
test/
|
|
17
|
+
memd.test.js # vitest tests (CLI, HTML output, serve, TTY, theme)
|
|
18
|
+
test1.md # basic markdown + mermaid
|
|
19
|
+
test2.md # complex mermaid diagram
|
|
20
|
+
test3.md # multiple mermaid blocks (marker ID uniqueness)
|
|
21
|
+
complex.md # graph TD with <br>, special chars, edge labels
|
|
22
|
+
test-br.md # <br> tag line breaks in mermaid nodes
|
|
23
|
+
test-cjk.md # Japanese labels in mermaid
|
|
24
|
+
test-highlight.md # syntax highlighting test
|
|
25
|
+
poc_md.ts # PoC script
|
|
26
|
+
poc_mermaid.ts # PoC script
|
|
27
|
+
pixel.png # test image for static file serving
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pnpm install # install dependencies
|
|
34
|
+
pnpm test # run tests (vitest run --maxConcurrency=20)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Manual testing
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
node main.js test/test1.md
|
|
41
|
+
node main.js test/test2.md
|
|
42
|
+
node main.js test/complex.md
|
|
43
|
+
node main.js --html test/test1.md # HTML output to stdout
|
|
44
|
+
node main.js serve --dir test --port 3000 # HTTP serve mode
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Key CLI flags
|
|
48
|
+
|
|
49
|
+
- `--no-pager` -- disable pager (less)
|
|
50
|
+
- `--no-color` -- strip ANSI escape codes
|
|
51
|
+
- `--ascii` -- ASCII-only diagram rendering
|
|
52
|
+
- `--html` -- output HTML instead of terminal
|
|
53
|
+
- `--theme <name>` -- color theme (default: `nord`)
|
|
54
|
+
- `--width <n>` -- terminal width override
|
|
55
|
+
|
|
56
|
+
## Environment variables
|
|
57
|
+
|
|
58
|
+
- `MEMD_THEME` -- default theme (overridden by `--theme` flag)
|
|
59
|
+
- `FORCE_COLOR=3` -- force truecolor ANSI output
|
|
60
|
+
|
|
61
|
+
## Available themes
|
|
62
|
+
|
|
63
|
+
nord, dracula, one-dark, github-dark, github-light, solarized-dark, solarized-light,
|
|
64
|
+
catppuccin-mocha, catppuccin-latte, tokyo-night, tokyo-night-storm, tokyo-night-light,
|
|
65
|
+
nord-light, zinc-dark, zinc-light
|
|
66
|
+
|
|
67
|
+
## Architecture notes
|
|
68
|
+
|
|
69
|
+
- `main.js` handles both terminal rendering (marked + marked-terminal + shiki) and `serve` sub-command (HTTP server with worker pool)
|
|
70
|
+
- `render-shared.js` converts Mermaid fenced blocks to SVG via `@ktrysmt/beautiful-mermaid`, then renders full HTML with `marked`
|
|
71
|
+
- `render-worker.js` runs `renderToHTML` in a worker thread for non-blocking serve mode
|
|
72
|
+
- `render-utils.js` provides theme color resolution and HTML escaping (shared by main.js and render-shared.js)
|
|
73
|
+
- Serve mode supports: directory listing, ETag/304 caching, gzip, static file serving (images/css), sidebar navigation, `--watch` with SSE live reload, CSP nonce
|
package/README.md
CHANGED
|
@@ -363,6 +363,31 @@ Note:
|
|
|
363
363
|
* Each worker loads the Mermaid rendering library in an independent V8 isolate. Each worker consumes approximately 80-120 MB of memory. The default is `min(num_CPUs-1, 4)` workers. In memory-constrained environments, specify `--workers 1`. Recommended memory: 512 MB + (number of workers x 120 MB).
|
|
364
364
|
|
|
365
365
|
|
|
366
|
+
## Environment Variables
|
|
367
|
+
|
|
368
|
+
| Variable | Description | Default |
|
|
369
|
+
|---|---|---|
|
|
370
|
+
| `MEMD_THEME` | Color theme for both terminal and HTML output | `nord` |
|
|
371
|
+
| `NO_COLOR` | Disable colored terminal output (any value) | _(unset)_ |
|
|
372
|
+
| `NO_PAGER` | Disable pager (any value) | _(unset)_ |
|
|
373
|
+
| `PAGER` | Pager command | `less` |
|
|
374
|
+
| `MEMD_SERVE_RESPAWN_MAX` | Max worker respawns before marking dead | `5` |
|
|
375
|
+
| `MEMD_SERVE_RESPAWN_WINDOW_MS` | Time window for respawn limit (ms) | `60000` |
|
|
376
|
+
| `MEMD_SERVE_RENDER_TIMEOUT_MS` | Per-request render timeout (ms) | `30000` |
|
|
377
|
+
| `MEMD_SERVE_DEAD_RECOVERY_MS` | Cooldown before recovering a dead worker (ms) | `300000` |
|
|
378
|
+
| `MEMD_SERVE_CACHE_MAX_ENTRIES` | Max number of cached rendered pages | `200` |
|
|
379
|
+
| `MEMD_SERVE_CACHE_MAX_BYTES` | Max total bytes for render cache | `52428800` (50 MB) |
|
|
380
|
+
| `MEMD_SERVE_MD_MAX_SIZE` | Max markdown file size to render (bytes) | `10485760` (10 MB) |
|
|
381
|
+
| `MEMD_SERVE_GZIP_CACHE_MAX` | Max number of cached gzip responses | `200` |
|
|
382
|
+
|
|
383
|
+
```bash
|
|
384
|
+
# Examples
|
|
385
|
+
MEMD_THEME=github-dark memd README.md
|
|
386
|
+
MEMD_THEME=dracula memd serve
|
|
387
|
+
NO_COLOR=1 memd README.md
|
|
388
|
+
MEMD_SERVE_RENDER_TIMEOUT_MS=60000 memd serve
|
|
389
|
+
```
|
|
390
|
+
|
|
366
391
|
## Author
|
|
367
392
|
|
|
368
393
|
[ktrysmt](https://github.com/ktrysmt)
|
package/main.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// @ts-nocheck
|
|
3
3
|
import { marked } from 'marked';
|
|
4
4
|
import { markedTerminal } from 'marked-terminal';
|
|
5
|
-
import { renderMermaidASCII, THEMES as MERMAID_THEMES } from 'beautiful-mermaid';
|
|
5
|
+
import { renderMermaidASCII, THEMES as MERMAID_THEMES } from '@ktrysmt/beautiful-mermaid';
|
|
6
6
|
import chalk from 'chalk';
|
|
7
7
|
import { program } from 'commander';
|
|
8
8
|
import { spawn } from 'child_process';
|
|
@@ -566,6 +566,7 @@ async function main() {
|
|
|
566
566
|
.name('memd')
|
|
567
567
|
.version(packageJson.version, '-v, --version', 'output the version number')
|
|
568
568
|
.description('Render markdown with mermaid diagrams')
|
|
569
|
+
.enablePositionalOptions()
|
|
569
570
|
.argument('[files...]', 'markdown file(s) to render')
|
|
570
571
|
.option('--no-pager', 'disable pager (less)')
|
|
571
572
|
.option('--no-mouse', 'disable mouse scroll in pager')
|
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memd-cli",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "main.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"memd": "main.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"test": "vitest run"
|
|
10
|
+
"test": "vitest run --maxConcurrency=20"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"beautiful-mermaid": "^1.1.
|
|
13
|
+
"@ktrysmt/beautiful-mermaid": "^1.1.4",
|
|
14
14
|
"chalk": "^5.6.2",
|
|
15
15
|
"commander": "^14.0.3",
|
|
16
16
|
"marked": "^17.0.4",
|
package/render-shared.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { Marked } from 'marked';
|
|
3
|
-
import { renderMermaidSVG } from 'beautiful-mermaid';
|
|
3
|
+
import { renderMermaidSVG } from '@ktrysmt/beautiful-mermaid';
|
|
4
4
|
import { escapeHtml, resolveThemeColors } from './render-utils.js';
|
|
5
5
|
|
|
6
6
|
export const MERMAID_MODAL_SCRIPT = [
|
package/test/complex.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
|
|
2
|
+
```mermaid
|
|
3
|
+
graph TD
|
|
4
|
+
A["AAA<br>(keita)"] --> C["CCC"]
|
|
5
|
+
B["BBB<br>(yuriko)"] --> C
|
|
6
|
+
C --> D["DDDD"]
|
|
7
|
+
D --> E["EEEE"]
|
|
8
|
+
|
|
9
|
+
A1["1 / 2"] --> A
|
|
10
|
+
A2["3 / 4"] --> A
|
|
11
|
+
A3["5 / 6"] --> A
|
|
12
|
+
A4["XXX<br>(YYY ZZZ)"] --> A
|
|
13
|
+
|
|
14
|
+
B1["77 77<br>(7 / 7 / 7)"] --> B
|
|
15
|
+
B2["88-88<br>(99 99)"] --> B
|
|
16
|
+
B3["111s 222s"] --> B
|
|
17
|
+
|
|
18
|
+
D --> F{"F?"}
|
|
19
|
+
F -->|Yes| G["High level<br>Tr"]
|
|
20
|
+
F -->|No| H["Dumb Tr<br>S"]
|
|
21
|
+
```
|
package/test/memd.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
2
2
|
import { launchTerminal } from 'tuistory'
|
|
3
|
-
import { execSync, spawn } from 'child_process'
|
|
3
|
+
import { execSync, execFile, execFileSync, spawn } from 'child_process'
|
|
4
4
|
import fs from 'fs'
|
|
5
5
|
import path from 'path'
|
|
6
6
|
import { fileURLToPath } from 'url'
|
|
@@ -11,30 +11,25 @@ const MAIN = path.join(__dirname, '..', 'main.js')
|
|
|
11
11
|
// Strip MEMD_THEME from env so tests always get the built-in default (nord)
|
|
12
12
|
delete process.env.MEMD_THEME
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
waitForData: false,
|
|
21
|
-
})
|
|
22
|
-
const waitText = waitFor ?? (t => t.trim().length > 0)
|
|
23
|
-
const output = await session.text({ waitFor: waitText, timeout: 8000 })
|
|
24
|
-
return output.trim()
|
|
14
|
+
function run(args) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
execFile('node', [MAIN, ...args], { encoding: 'utf-8', timeout: 15000 }, (err, stdout, stderr) => {
|
|
17
|
+
resolve(((stdout ?? '') + (stderr ?? '')).trim())
|
|
18
|
+
})
|
|
19
|
+
})
|
|
25
20
|
}
|
|
26
21
|
|
|
27
22
|
function runSync(args) {
|
|
28
|
-
return
|
|
23
|
+
return execFileSync('node', [MAIN, ...args], { encoding: 'utf-8', timeout: 15000 })
|
|
29
24
|
}
|
|
30
25
|
|
|
31
26
|
describe('memd CLI', () => {
|
|
32
|
-
it('--version', async () => {
|
|
27
|
+
it.concurrent('--version', async () => {
|
|
33
28
|
const output = await run(['-v'])
|
|
34
|
-
expect(output).toContain('3.0
|
|
29
|
+
expect(output).toContain('3.1.0')
|
|
35
30
|
})
|
|
36
31
|
|
|
37
|
-
it('--help', async () => {
|
|
32
|
+
it.concurrent('--help', async () => {
|
|
38
33
|
const output = await run(['--help'])
|
|
39
34
|
expect(output).toContain('Usage: memd')
|
|
40
35
|
expect(output).toContain('--no-pager')
|
|
@@ -47,7 +42,6 @@ describe('memd CLI', () => {
|
|
|
47
42
|
it('renders test1.md (basic markdown + mermaid)', async () => {
|
|
48
43
|
const output = await run(
|
|
49
44
|
['--no-pager', '--no-color', '--width', '80', 'test/test1.md'],
|
|
50
|
-
{ waitFor: t => t.includes('More text.') },
|
|
51
45
|
)
|
|
52
46
|
expect(output).toMatchInlineSnapshot(`
|
|
53
47
|
"# Hello
|
|
@@ -63,10 +57,9 @@ describe('memd CLI', () => {
|
|
|
63
57
|
`)
|
|
64
58
|
})
|
|
65
59
|
|
|
66
|
-
it('renders test2.md (complex mermaid diagram)', async () => {
|
|
60
|
+
it.concurrent('renders test2.md (complex mermaid diagram)', async () => {
|
|
67
61
|
const output = await run(
|
|
68
62
|
['--no-pager', '--no-color', '--width', '80', 'test/test2.md'],
|
|
69
|
-
{ waitFor: t => t.includes('More text after the diagram.') },
|
|
70
63
|
)
|
|
71
64
|
expect(output).toContain('Start')
|
|
72
65
|
expect(output).toContain('Decision?')
|
|
@@ -75,10 +68,9 @@ describe('memd CLI', () => {
|
|
|
75
68
|
expect(output).toContain('More text after the diagram.')
|
|
76
69
|
})
|
|
77
70
|
|
|
78
|
-
it('--ascii renders ASCII-only diagram', async () => {
|
|
71
|
+
it.concurrent('--ascii renders ASCII-only diagram', async () => {
|
|
79
72
|
const output = await run(
|
|
80
73
|
['--no-pager', '--no-color', '--width', '80', '--ascii', 'test/test1.md'],
|
|
81
|
-
{ waitFor: t => t.includes('More text.') },
|
|
82
74
|
)
|
|
83
75
|
expect(output).toContain('+---+')
|
|
84
76
|
expect(output).toContain('---->')
|
|
@@ -86,35 +78,23 @@ describe('memd CLI', () => {
|
|
|
86
78
|
expect(output).not.toContain('►')
|
|
87
79
|
})
|
|
88
80
|
|
|
89
|
-
it('--no-color strips ANSI escape codes', async () => {
|
|
81
|
+
it.concurrent('--no-color strips ANSI escape codes', async () => {
|
|
90
82
|
const output = await run(
|
|
91
83
|
['--no-pager', '--no-color', '--width', '80', 'test/test1.md'],
|
|
92
|
-
{ waitFor: t => t.includes('More text.') },
|
|
93
84
|
)
|
|
94
85
|
// eslint-disable-next-line no-control-regex
|
|
95
86
|
expect(output).not.toMatch(/\x1b\[[\d;]*m/)
|
|
96
87
|
})
|
|
97
88
|
|
|
98
|
-
it('error on missing file', async () => {
|
|
99
|
-
const
|
|
100
|
-
command: 'node',
|
|
101
|
-
args: [MAIN, '--no-pager', 'test/nonexistent.md'],
|
|
102
|
-
cols: 80,
|
|
103
|
-
rows: 10,
|
|
104
|
-
waitForData: false,
|
|
105
|
-
})
|
|
106
|
-
const output = (await session.text({
|
|
107
|
-
waitFor: t => t.includes('Error'),
|
|
108
|
-
timeout: 8000,
|
|
109
|
-
})).trim()
|
|
89
|
+
it.concurrent('error on missing file', async () => {
|
|
90
|
+
const output = await run(['--no-pager', 'test/nonexistent.md'])
|
|
110
91
|
expect(output).toContain('Error reading file')
|
|
111
92
|
expect(output).toContain('nonexistent.md')
|
|
112
93
|
})
|
|
113
94
|
|
|
114
|
-
it('renders test-br.md (<br> tag line breaks)', async () => {
|
|
95
|
+
it.concurrent('renders test-br.md (<br> tag line breaks)', async () => {
|
|
115
96
|
const output = await run(
|
|
116
97
|
['--no-pager', '--no-color', '--width', '80', 'test/test-br.md'],
|
|
117
|
-
{ waitFor: t => t.includes('All three variants') },
|
|
118
98
|
)
|
|
119
99
|
// Each node should have its label split across two lines
|
|
120
100
|
expect(output).toContain('Line1')
|
|
@@ -130,10 +110,9 @@ describe('memd CLI', () => {
|
|
|
130
110
|
expect(diagramSection).not.toMatch(/<br\s*\/?>/)
|
|
131
111
|
})
|
|
132
112
|
|
|
133
|
-
it('renders test-cjk.md (Japanese labels)', async () => {
|
|
113
|
+
it.concurrent('renders test-cjk.md (Japanese labels)', async () => {
|
|
134
114
|
const output = await run(
|
|
135
115
|
['--no-pager', '--no-color', '--width', '80', 'test/test-cjk.md'],
|
|
136
|
-
{ waitFor: t => t.includes('Japanese labels') },
|
|
137
116
|
)
|
|
138
117
|
expect(output).toContain('開始')
|
|
139
118
|
expect(output).toContain('判定')
|
|
@@ -143,25 +122,41 @@ describe('memd CLI', () => {
|
|
|
143
122
|
expect(output).toContain('いいえ')
|
|
144
123
|
})
|
|
145
124
|
|
|
146
|
-
it('
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
125
|
+
it.concurrent('renders complex.md (graph TD with <br> and special chars)', async () => {
|
|
126
|
+
const output = await run(
|
|
127
|
+
['--no-pager', '--no-color', '--width', '80', 'test/complex.md'],
|
|
128
|
+
)
|
|
129
|
+
// Node labels with <br> should render as multi-line text
|
|
130
|
+
expect(output).toContain('AAA')
|
|
131
|
+
expect(output).toContain('keita')
|
|
132
|
+
expect(output).toContain('BBB')
|
|
133
|
+
expect(output).toContain('yuriko')
|
|
134
|
+
// Leaf nodes
|
|
135
|
+
expect(output).toContain('1 / 2')
|
|
136
|
+
expect(output).toContain('XXX')
|
|
137
|
+
expect(output).toContain('YYY ZZZ')
|
|
138
|
+
// Decision node and edge labels
|
|
139
|
+
expect(output).toContain('F?')
|
|
140
|
+
expect(output).toContain('Yes')
|
|
141
|
+
expect(output).toContain('No')
|
|
142
|
+
expect(output).toContain('High level')
|
|
143
|
+
expect(output).toContain('Dumb Tr')
|
|
144
|
+
// <br> tags should not appear in rendered output
|
|
145
|
+
expect(output).not.toMatch(/<br\s*\/?>/)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('reads markdown from stdin via shell', () => {
|
|
149
|
+
const output = execSync(
|
|
150
|
+
`echo '# stdin test' | node ${MAIN} --no-pager --no-color`,
|
|
151
|
+
{ encoding: 'utf-8', timeout: 15000 },
|
|
152
|
+
).trim()
|
|
158
153
|
expect(output).toContain('stdin test')
|
|
159
154
|
})
|
|
160
155
|
|
|
161
156
|
// --html output tests
|
|
162
157
|
describe('--html output', () => {
|
|
163
158
|
it('--html + file -> stdout', () => {
|
|
164
|
-
const output = runSync('--html test/test1.md')
|
|
159
|
+
const output = runSync(['--html', 'test/test1.md'])
|
|
165
160
|
expect(output).toContain('<!DOCTYPE html>')
|
|
166
161
|
expect(output).toContain('<svg')
|
|
167
162
|
// Contains theme CSS colors (nord default: bg=#2e3440, fg=#d8dee9)
|
|
@@ -184,7 +179,7 @@ describe('memd CLI', () => {
|
|
|
184
179
|
})
|
|
185
180
|
|
|
186
181
|
it('--html + multiple Mermaid blocks have unique marker IDs', () => {
|
|
187
|
-
const output = runSync('--html test/test3.md')
|
|
182
|
+
const output = runSync(['--html', 'test/test3.md'])
|
|
188
183
|
expect(output).toContain('<svg')
|
|
189
184
|
// Check for prefixed marker IDs (m0-, m1-, etc.)
|
|
190
185
|
expect(output).toMatch(/id="m0-/)
|
|
@@ -192,7 +187,7 @@ describe('memd CLI', () => {
|
|
|
192
187
|
})
|
|
193
188
|
|
|
194
189
|
it('--html + multiple files -> combined single HTML', () => {
|
|
195
|
-
const output = runSync('--html test/test1.md test/test2.md')
|
|
190
|
+
const output = runSync(['--html', 'test/test1.md', 'test/test2.md'])
|
|
196
191
|
expect(output).toContain('<!DOCTYPE html>')
|
|
197
192
|
// Content from both files
|
|
198
193
|
expect(output).toContain('Hello')
|
|
@@ -206,13 +201,13 @@ describe('memd CLI', () => {
|
|
|
206
201
|
// Theme tests (HTML path)
|
|
207
202
|
describe('theme (HTML path)', () => {
|
|
208
203
|
it('--html --theme dracula uses dracula colors', () => {
|
|
209
|
-
const output = runSync('--html --theme dracula test/test1.md')
|
|
204
|
+
const output = runSync(['--html', '--theme', 'dracula', 'test/test1.md'])
|
|
210
205
|
expect(output).toContain('#282a36') // bg
|
|
211
206
|
expect(output).toContain('#f8f8f2') // fg
|
|
212
207
|
})
|
|
213
208
|
|
|
214
209
|
it('--html --theme tokyo-night uses tokyo-night colors', () => {
|
|
215
|
-
const output = runSync('--html --theme tokyo-night test/test1.md')
|
|
210
|
+
const output = runSync(['--html', '--theme', 'tokyo-night', 'test/test1.md'])
|
|
216
211
|
expect(output).toContain('#1a1b26') // bg
|
|
217
212
|
expect(output).toContain('#a9b1d6') // fg
|
|
218
213
|
})
|
|
@@ -224,7 +219,7 @@ describe('memd CLI', () => {
|
|
|
224
219
|
})
|
|
225
220
|
|
|
226
221
|
it('--html --no-color outputs full color HTML (silently ignored)', () => {
|
|
227
|
-
const output = runSync('--html --no-color test/test1.md')
|
|
222
|
+
const output = runSync(['--html', '--no-color', 'test/test1.md'])
|
|
228
223
|
expect(output).toContain('<!DOCTYPE html>')
|
|
229
224
|
expect(output).toContain('#2e3440')
|
|
230
225
|
})
|
|
@@ -232,53 +227,39 @@ describe('memd CLI', () => {
|
|
|
232
227
|
|
|
233
228
|
// Theme tests (terminal path)
|
|
234
229
|
describe('theme (terminal path)', () => {
|
|
235
|
-
it('--theme dracula renders terminal output', async () => {
|
|
230
|
+
it.concurrent('--theme dracula renders terminal output', async () => {
|
|
236
231
|
const output = await run(
|
|
237
232
|
['--no-pager', '--no-color', '--theme', 'dracula', 'test/test1.md'],
|
|
238
|
-
{ waitFor: t => t.includes('More text.') },
|
|
239
233
|
)
|
|
240
234
|
expect(output).toContain('Hello')
|
|
241
235
|
expect(output).toContain('More text.')
|
|
242
236
|
})
|
|
243
237
|
|
|
244
|
-
it('--theme tokyo-night (no highlight) renders terminal output', async () => {
|
|
238
|
+
it.concurrent('--theme tokyo-night (no highlight) renders terminal output', async () => {
|
|
245
239
|
const output = await run(
|
|
246
240
|
['--no-pager', '--no-color', '--theme', 'tokyo-night', 'test/test1.md'],
|
|
247
|
-
{ waitFor: t => t.includes('More text.') },
|
|
248
241
|
)
|
|
249
242
|
expect(output).toContain('Hello')
|
|
250
243
|
expect(output).toContain('More text.')
|
|
251
244
|
})
|
|
252
245
|
|
|
253
|
-
it('--theme one-dark renders terminal output', async () => {
|
|
246
|
+
it.concurrent('--theme one-dark renders terminal output', async () => {
|
|
254
247
|
const output = await run(
|
|
255
248
|
['--no-pager', '--no-color', '--theme', 'one-dark', 'test/test1.md'],
|
|
256
|
-
{ waitFor: t => t.includes('More text.') },
|
|
257
249
|
)
|
|
258
250
|
expect(output).toContain('Hello')
|
|
259
251
|
expect(output).toContain('More text.')
|
|
260
252
|
})
|
|
261
253
|
|
|
262
|
-
it('--theme nonexistent exits with error', async () => {
|
|
263
|
-
const
|
|
264
|
-
command: 'node',
|
|
265
|
-
args: [MAIN, '--no-pager', '--no-color', '--theme', 'nonexistent', 'test/test1.md'],
|
|
266
|
-
cols: 80,
|
|
267
|
-
rows: 10,
|
|
268
|
-
waitForData: false,
|
|
269
|
-
})
|
|
270
|
-
const output = (await session.text({
|
|
271
|
-
waitFor: t => t.includes('Unknown theme'),
|
|
272
|
-
timeout: 8000,
|
|
273
|
-
})).trim()
|
|
254
|
+
it.concurrent('--theme nonexistent exits with error', async () => {
|
|
255
|
+
const output = await run(['--no-pager', '--no-color', '--theme', 'nonexistent', 'test/test1.md'])
|
|
274
256
|
expect(output).toContain('Unknown theme')
|
|
275
257
|
expect(output).toContain('Available themes')
|
|
276
258
|
})
|
|
277
259
|
|
|
278
|
-
it('default theme is nord', async () => {
|
|
260
|
+
it.concurrent('default theme is nord', async () => {
|
|
279
261
|
const output = await run(
|
|
280
262
|
['--no-pager', '--no-color', 'test/test1.md'],
|
|
281
|
-
{ waitFor: t => t.includes('More text.') },
|
|
282
263
|
)
|
|
283
264
|
expect(output).toContain('Hello')
|
|
284
265
|
expect(output).toContain('More text.')
|
|
@@ -317,7 +298,7 @@ describe('memd CLI', () => {
|
|
|
317
298
|
})
|
|
318
299
|
|
|
319
300
|
// chalk.level = 0 + Shiki: verify no ANSI codes for all themes
|
|
320
|
-
describe('--no-color strips ANSI from all themes', () => {
|
|
301
|
+
describe.concurrent('--no-color strips ANSI from all themes', () => {
|
|
321
302
|
const themes = [
|
|
322
303
|
'nord', 'dracula', 'one-dark', 'github-dark', 'github-light',
|
|
323
304
|
'solarized-dark', 'solarized-light', 'catppuccin-mocha', 'catppuccin-latte',
|
|
@@ -329,7 +310,6 @@ describe('memd CLI', () => {
|
|
|
329
310
|
it(`--theme ${theme} --no-color has no ANSI codes`, async () => {
|
|
330
311
|
const output = await run(
|
|
331
312
|
['--no-pager', '--no-color', '--theme', theme, 'test/test-highlight.md'],
|
|
332
|
-
{ waitFor: t => t.includes('TypeScript') },
|
|
333
313
|
)
|
|
334
314
|
// eslint-disable-next-line no-control-regex
|
|
335
315
|
expect(output).not.toMatch(/\x1b\[[\d;]*m/)
|
|
@@ -347,37 +327,72 @@ describe('memd CLI', () => {
|
|
|
347
327
|
expect(output).toMatch(/\x1b\[38;2;\d+;\d+;\d+m/)
|
|
348
328
|
})
|
|
349
329
|
|
|
350
|
-
it('unknown language falls back to plain text without errors',
|
|
330
|
+
it('unknown language falls back to plain text without errors', () => {
|
|
331
|
+
const output = execSync(`node ${MAIN} --no-pager --no-color`, {
|
|
332
|
+
encoding: 'utf-8',
|
|
333
|
+
timeout: 15000,
|
|
334
|
+
input: '# Test\n\n```unknownlang\nsome code\n```',
|
|
335
|
+
}).trim()
|
|
336
|
+
expect(output).toContain('some code')
|
|
337
|
+
expect(output).not.toContain('Error')
|
|
338
|
+
expect(output).not.toContain('Could not find the language')
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('cli-highlight is not invoked (no highlight.js errors in output)', () => {
|
|
342
|
+
const output = execSync(`node ${MAIN} --no-pager --no-color`, {
|
|
343
|
+
encoding: 'utf-8',
|
|
344
|
+
timeout: 15000,
|
|
345
|
+
input: '# Test\n\n```rust\nfn main() {}\n```',
|
|
346
|
+
}).trim()
|
|
347
|
+
expect(output).toContain('fn main')
|
|
348
|
+
expect(output).not.toContain('Could not find the language')
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
describe('TTY behavior (PTY required)', () => {
|
|
353
|
+
it('auto-detects color in TTY (no --no-color)', async () => {
|
|
351
354
|
const session = await launchTerminal({
|
|
352
|
-
command: '
|
|
353
|
-
args: ['-
|
|
355
|
+
command: 'node',
|
|
356
|
+
args: [MAIN, '--no-pager', '--width', '80', 'test/test-highlight.md'],
|
|
357
|
+
cols: 80,
|
|
358
|
+
rows: 30,
|
|
359
|
+
waitForData: false,
|
|
360
|
+
})
|
|
361
|
+
await session.text({ waitFor: t => t.includes('TypeScript'), timeout: 8000 })
|
|
362
|
+
const data = await session.getTerminalData()
|
|
363
|
+
const hasColor = data.lines.some(line =>
|
|
364
|
+
line.spans.some(span => span.fg !== null)
|
|
365
|
+
)
|
|
366
|
+
expect(hasColor).toBe(true)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('pager activates for long output in TTY (no --no-pager)', async () => {
|
|
370
|
+
// test3.md is long enough to exceed terminal rows
|
|
371
|
+
const session = await launchTerminal({
|
|
372
|
+
command: 'node',
|
|
373
|
+
args: [MAIN, '--no-color', '--width', '80', 'test/test3.md'],
|
|
354
374
|
cols: 80,
|
|
355
375
|
rows: 10,
|
|
356
376
|
waitForData: false,
|
|
357
377
|
})
|
|
358
|
-
const output =
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
expect(output).toContain('some code')
|
|
363
|
-
expect(output).not.toContain('Error')
|
|
364
|
-
expect(output).not.toContain('Could not find the language')
|
|
378
|
+
const output = await session.text({ timeout: 8000 })
|
|
379
|
+
// less shows ':' or '(END)' prompt; partial output means pager is holding
|
|
380
|
+
expect(output).not.toContain('Error Handling Example')
|
|
381
|
+
await session.write('q')
|
|
365
382
|
})
|
|
366
383
|
|
|
367
|
-
it('
|
|
384
|
+
it('pager quit with q exits cleanly', async () => {
|
|
368
385
|
const session = await launchTerminal({
|
|
369
|
-
command: '
|
|
370
|
-
args: ['-
|
|
386
|
+
command: 'node',
|
|
387
|
+
args: [MAIN, '--no-color', '--width', '80', 'test/test3.md'],
|
|
371
388
|
cols: 80,
|
|
372
389
|
rows: 10,
|
|
373
390
|
waitForData: false,
|
|
374
391
|
})
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
expect(output).toContain('fn main')
|
|
380
|
-
expect(output).not.toContain('Could not find the language')
|
|
392
|
+
await session.text({ timeout: 8000 })
|
|
393
|
+
await session.write('q')
|
|
394
|
+
// After quitting, session should end without error
|
|
395
|
+
await session.waitIdle({ timeout: 3000 })
|
|
381
396
|
})
|
|
382
397
|
})
|
|
383
398
|
|
|
@@ -414,7 +429,7 @@ describe('memd serve', () => {
|
|
|
414
429
|
})
|
|
415
430
|
|
|
416
431
|
it('serve --help shows options', () => {
|
|
417
|
-
const output = runSync('serve --help')
|
|
432
|
+
const output = runSync(['serve', '--help'])
|
|
418
433
|
expect(output).toContain('-d, --dir')
|
|
419
434
|
expect(output).toContain('--port')
|
|
420
435
|
expect(output).toContain('--host')
|