memd-cli 3.0.1 → 3.0.2

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,20 @@
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
+ ]
19
+ }
20
+ }
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
@@ -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,13 +1,13 @@
1
1
  {
2
2
  "name": "memd-cli",
3
- "version": "3.0.1",
3
+ "version": "3.0.2",
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
13
  "beautiful-mermaid": "^1.1.3",
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.0.2')
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,18 @@ 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('reads markdown from stdin via shell', () => {
126
+ const output = execSync(
127
+ `echo '# stdin test' | node ${MAIN} --no-pager --no-color`,
128
+ { encoding: 'utf-8', timeout: 15000 },
129
+ ).trim()
158
130
  expect(output).toContain('stdin test')
159
131
  })
160
132
 
161
133
  // --html output tests
162
134
  describe('--html output', () => {
163
135
  it('--html + file -> stdout', () => {
164
- const output = runSync('--html test/test1.md')
136
+ const output = runSync(['--html', 'test/test1.md'])
165
137
  expect(output).toContain('<!DOCTYPE html>')
166
138
  expect(output).toContain('<svg')
167
139
  // Contains theme CSS colors (nord default: bg=#2e3440, fg=#d8dee9)
@@ -184,7 +156,7 @@ describe('memd CLI', () => {
184
156
  })
185
157
 
186
158
  it('--html + multiple Mermaid blocks have unique marker IDs', () => {
187
- const output = runSync('--html test/test3.md')
159
+ const output = runSync(['--html', 'test/test3.md'])
188
160
  expect(output).toContain('<svg')
189
161
  // Check for prefixed marker IDs (m0-, m1-, etc.)
190
162
  expect(output).toMatch(/id="m0-/)
@@ -192,7 +164,7 @@ describe('memd CLI', () => {
192
164
  })
193
165
 
194
166
  it('--html + multiple files -> combined single HTML', () => {
195
- const output = runSync('--html test/test1.md test/test2.md')
167
+ const output = runSync(['--html', 'test/test1.md', 'test/test2.md'])
196
168
  expect(output).toContain('<!DOCTYPE html>')
197
169
  // Content from both files
198
170
  expect(output).toContain('Hello')
@@ -206,13 +178,13 @@ describe('memd CLI', () => {
206
178
  // Theme tests (HTML path)
207
179
  describe('theme (HTML path)', () => {
208
180
  it('--html --theme dracula uses dracula colors', () => {
209
- const output = runSync('--html --theme dracula test/test1.md')
181
+ const output = runSync(['--html', '--theme', 'dracula', 'test/test1.md'])
210
182
  expect(output).toContain('#282a36') // bg
211
183
  expect(output).toContain('#f8f8f2') // fg
212
184
  })
213
185
 
214
186
  it('--html --theme tokyo-night uses tokyo-night colors', () => {
215
- const output = runSync('--html --theme tokyo-night test/test1.md')
187
+ const output = runSync(['--html', '--theme', 'tokyo-night', 'test/test1.md'])
216
188
  expect(output).toContain('#1a1b26') // bg
217
189
  expect(output).toContain('#a9b1d6') // fg
218
190
  })
@@ -224,7 +196,7 @@ describe('memd CLI', () => {
224
196
  })
225
197
 
226
198
  it('--html --no-color outputs full color HTML (silently ignored)', () => {
227
- const output = runSync('--html --no-color test/test1.md')
199
+ const output = runSync(['--html', '--no-color', 'test/test1.md'])
228
200
  expect(output).toContain('<!DOCTYPE html>')
229
201
  expect(output).toContain('#2e3440')
230
202
  })
@@ -232,53 +204,39 @@ describe('memd CLI', () => {
232
204
 
233
205
  // Theme tests (terminal path)
234
206
  describe('theme (terminal path)', () => {
235
- it('--theme dracula renders terminal output', async () => {
207
+ it.concurrent('--theme dracula renders terminal output', async () => {
236
208
  const output = await run(
237
209
  ['--no-pager', '--no-color', '--theme', 'dracula', 'test/test1.md'],
238
- { waitFor: t => t.includes('More text.') },
239
210
  )
240
211
  expect(output).toContain('Hello')
241
212
  expect(output).toContain('More text.')
242
213
  })
243
214
 
244
- it('--theme tokyo-night (no highlight) renders terminal output', async () => {
215
+ it.concurrent('--theme tokyo-night (no highlight) renders terminal output', async () => {
245
216
  const output = await run(
246
217
  ['--no-pager', '--no-color', '--theme', 'tokyo-night', 'test/test1.md'],
247
- { waitFor: t => t.includes('More text.') },
248
218
  )
249
219
  expect(output).toContain('Hello')
250
220
  expect(output).toContain('More text.')
251
221
  })
252
222
 
253
- it('--theme one-dark renders terminal output', async () => {
223
+ it.concurrent('--theme one-dark renders terminal output', async () => {
254
224
  const output = await run(
255
225
  ['--no-pager', '--no-color', '--theme', 'one-dark', 'test/test1.md'],
256
- { waitFor: t => t.includes('More text.') },
257
226
  )
258
227
  expect(output).toContain('Hello')
259
228
  expect(output).toContain('More text.')
260
229
  })
261
230
 
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()
231
+ it.concurrent('--theme nonexistent exits with error', async () => {
232
+ const output = await run(['--no-pager', '--no-color', '--theme', 'nonexistent', 'test/test1.md'])
274
233
  expect(output).toContain('Unknown theme')
275
234
  expect(output).toContain('Available themes')
276
235
  })
277
236
 
278
- it('default theme is nord', async () => {
237
+ it.concurrent('default theme is nord', async () => {
279
238
  const output = await run(
280
239
  ['--no-pager', '--no-color', 'test/test1.md'],
281
- { waitFor: t => t.includes('More text.') },
282
240
  )
283
241
  expect(output).toContain('Hello')
284
242
  expect(output).toContain('More text.')
@@ -317,7 +275,7 @@ describe('memd CLI', () => {
317
275
  })
318
276
 
319
277
  // chalk.level = 0 + Shiki: verify no ANSI codes for all themes
320
- describe('--no-color strips ANSI from all themes', () => {
278
+ describe.concurrent('--no-color strips ANSI from all themes', () => {
321
279
  const themes = [
322
280
  'nord', 'dracula', 'one-dark', 'github-dark', 'github-light',
323
281
  'solarized-dark', 'solarized-light', 'catppuccin-mocha', 'catppuccin-latte',
@@ -329,7 +287,6 @@ describe('memd CLI', () => {
329
287
  it(`--theme ${theme} --no-color has no ANSI codes`, async () => {
330
288
  const output = await run(
331
289
  ['--no-pager', '--no-color', '--theme', theme, 'test/test-highlight.md'],
332
- { waitFor: t => t.includes('TypeScript') },
333
290
  )
334
291
  // eslint-disable-next-line no-control-regex
335
292
  expect(output).not.toMatch(/\x1b\[[\d;]*m/)
@@ -347,37 +304,72 @@ describe('memd CLI', () => {
347
304
  expect(output).toMatch(/\x1b\[38;2;\d+;\d+;\d+m/)
348
305
  })
349
306
 
350
- it('unknown language falls back to plain text without errors', async () => {
307
+ it('unknown language falls back to plain text without errors', () => {
308
+ const output = execSync(`node ${MAIN} --no-pager --no-color`, {
309
+ encoding: 'utf-8',
310
+ timeout: 15000,
311
+ input: '# Test\n\n```unknownlang\nsome code\n```',
312
+ }).trim()
313
+ expect(output).toContain('some code')
314
+ expect(output).not.toContain('Error')
315
+ expect(output).not.toContain('Could not find the language')
316
+ })
317
+
318
+ it('cli-highlight is not invoked (no highlight.js errors in output)', () => {
319
+ const output = execSync(`node ${MAIN} --no-pager --no-color`, {
320
+ encoding: 'utf-8',
321
+ timeout: 15000,
322
+ input: '# Test\n\n```rust\nfn main() {}\n```',
323
+ }).trim()
324
+ expect(output).toContain('fn main')
325
+ expect(output).not.toContain('Could not find the language')
326
+ })
327
+ })
328
+
329
+ describe('TTY behavior (PTY required)', () => {
330
+ it('auto-detects color in TTY (no --no-color)', async () => {
331
+ const session = await launchTerminal({
332
+ command: 'node',
333
+ args: [MAIN, '--no-pager', '--width', '80', 'test/test-highlight.md'],
334
+ cols: 80,
335
+ rows: 30,
336
+ waitForData: false,
337
+ })
338
+ await session.text({ waitFor: t => t.includes('TypeScript'), timeout: 8000 })
339
+ const data = await session.getTerminalData()
340
+ const hasColor = data.lines.some(line =>
341
+ line.spans.some(span => span.fg !== null)
342
+ )
343
+ expect(hasColor).toBe(true)
344
+ })
345
+
346
+ it('pager activates for long output in TTY (no --no-pager)', async () => {
347
+ // test3.md is long enough to exceed terminal rows
351
348
  const session = await launchTerminal({
352
- command: 'sh',
353
- args: ['-c', `echo '# Test\n\n\`\`\`unknownlang\nsome code\n\`\`\`' | node ${MAIN} --no-pager --no-color`],
349
+ command: 'node',
350
+ args: [MAIN, '--no-color', '--width', '80', 'test/test3.md'],
354
351
  cols: 80,
355
352
  rows: 10,
356
353
  waitForData: false,
357
354
  })
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')
355
+ const output = await session.text({ timeout: 8000 })
356
+ // less shows ':' or '(END)' prompt; partial output means pager is holding
357
+ expect(output).not.toContain('Error Handling Example')
358
+ await session.write('q')
365
359
  })
366
360
 
367
- it('cli-highlight is not invoked (no highlight.js errors in output)', async () => {
361
+ it('pager quit with q exits cleanly', async () => {
368
362
  const session = await launchTerminal({
369
- command: 'sh',
370
- args: ['-c', `echo '# Test\n\n\`\`\`rust\nfn main() {}\n\`\`\`' | node ${MAIN} --no-pager`],
363
+ command: 'node',
364
+ args: [MAIN, '--no-color', '--width', '80', 'test/test3.md'],
371
365
  cols: 80,
372
366
  rows: 10,
373
367
  waitForData: false,
374
368
  })
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')
369
+ await session.text({ timeout: 8000 })
370
+ await session.write('q')
371
+ // After quitting, session should end without error
372
+ await session.waitIdle({ timeout: 3000 })
381
373
  })
382
374
  })
383
375
 
@@ -414,7 +406,7 @@ describe('memd serve', () => {
414
406
  })
415
407
 
416
408
  it('serve --help shows options', () => {
417
- const output = runSync('serve --help')
409
+ const output = runSync(['serve', '--help'])
418
410
  expect(output).toContain('-d, --dir')
419
411
  expect(output).toContain('--port')
420
412
  expect(output).toContain('--host')