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.
@@ -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
- ## how to test
1
+ # memd
2
2
 
3
- - `node main.js test/test1.md`
4
- - `node main.js test/test2.md`
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.1",
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.3",
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 = [
@@ -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
- async function run(args, { waitFor = null } = {}) {
15
- const session = await launchTerminal({
16
- command: 'node',
17
- args: [MAIN, ...args],
18
- cols: 80,
19
- rows: 30,
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 execSync(`node ${MAIN} ${args}`, { encoding: 'utf-8', timeout: 15000 })
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.1')
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 session = await launchTerminal({
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('reads markdown from stdin via shell', async () => {
147
- const session = await launchTerminal({
148
- command: 'sh',
149
- args: ['-c', `echo '# stdin test' | node ${MAIN} --no-pager --no-color`],
150
- cols: 80,
151
- rows: 10,
152
- waitForData: false,
153
- })
154
- const output = (await session.text({
155
- waitFor: t => t.includes('stdin test'),
156
- timeout: 8000,
157
- })).trim()
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 session = await launchTerminal({
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', async () => {
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: 'sh',
353
- args: ['-c', `echo '# Test\n\n\`\`\`unknownlang\nsome code\n\`\`\`' | node ${MAIN} --no-pager --no-color`],
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 = (await session.text({
359
- waitFor: t => t.includes('some code'),
360
- timeout: 8000,
361
- })).trim()
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('cli-highlight is not invoked (no highlight.js errors in output)', async () => {
384
+ it('pager quit with q exits cleanly', async () => {
368
385
  const session = await launchTerminal({
369
- command: 'sh',
370
- args: ['-c', `echo '# Test\n\n\`\`\`rust\nfn main() {}\n\`\`\`' | node ${MAIN} --no-pager`],
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
- const output = (await session.text({
376
- waitFor: t => t.includes('fn main'),
377
- timeout: 8000,
378
- })).trim()
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')