memd-cli 3.2.1 → 3.4.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.md CHANGED
@@ -41,7 +41,8 @@ node main.js test/test1.md
41
41
  node main.js test/test2.md
42
42
  node main.js test/complex.md
43
43
  node main.js --html test/test1.md # HTML output to stdout
44
- node main.js serve --dir test --port 3000 # HTTP serve mode
44
+ node main.js serve test --port 3000 # HTTP serve mode (directory)
45
+ node main.js serve test/test1.md # HTTP serve mode (single file)
45
46
  ```
46
47
 
47
48
  ## Key CLI flags
@@ -70,4 +71,5 @@ nord-light, zinc-dark, zinc-light
70
71
  - `render-shared.js` converts Mermaid fenced blocks to SVG via `@ktrysmt/beautiful-mermaid`, then renders full HTML with `marked`
71
72
  - `render-worker.js` runs `renderToHTML` in a worker thread for non-blocking serve mode
72
73
  - `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
74
+ - Serve mode supports: directory or single-file serving, directory listing, ETag/304 caching, gzip, static file serving (images/css), sidebar navigation, `--watch` with SSE live reload, CSP nonce
75
+ - Single-file mode (`serve foo.md`): sets baseDir to the file's parent directory, root `/` redirects to `/<filename>`
package/main.js CHANGED
@@ -622,10 +622,12 @@ async function main() {
622
622
 
623
623
  if (options.html) {
624
624
  // 3a. HTML path
625
- const { renderToHTML, MERMAID_MODAL_SCRIPT } = await import('./render-shared.js');
625
+ const { renderToHTML, MERMAID_MODAL_SCRIPT, WIDTH_TOGGLE_SCRIPT } = await import('./render-shared.js');
626
626
  const combined = markdownParts.join('\n\n');
627
627
  let html = renderToHTML(combined, diagramColors);
628
- html = html.replace('<!--memd:scripts-->', html.includes('mermaid-diagram') ? `<script>${MERMAID_MODAL_SCRIPT}</script>` : '');
628
+ let inlineScripts = WIDTH_TOGGLE_SCRIPT;
629
+ if (html.includes('mermaid-diagram')) inlineScripts += MERMAID_MODAL_SCRIPT;
630
+ html = html.replace('<!--memd:scripts-->', `<script>${inlineScripts}</script>`);
629
631
  process.stdout.write(html);
630
632
  } else {
631
633
  // 3b. Terminal path
@@ -707,13 +709,13 @@ async function main() {
707
709
  program
708
710
  .command('serve')
709
711
  .description('Start HTTP server to serve .md files as HTML')
710
- .option('-d, --dir <path>', 'directory to serve', '.')
712
+ .argument('[path]', 'directory or .md file to serve', '.')
711
713
  .option('-p, --port <number>', 'port number (0-65535)', Number, 8888)
712
714
  .option('--host <string>', 'host to bind', '127.0.0.1')
713
715
  .option('--workers <number>', 'number of render workers (default: min(cpus-1, 4))', Number)
714
716
  .option('--watch', 'watch for file changes and live-reload')
715
717
  .option('--theme <name>', `color theme (env: MEMD_THEME)\n${THEME_NAMES.join(', ')}`, process.env.MEMD_THEME || 'nord')
716
- .action(async (options) => {
718
+ .action(async (servePath, options) => {
717
719
  if (!(options.theme in THEME_MAP)) {
718
720
  const names = Object.keys(THEME_MAP).join(', ');
719
721
  console.error(`Unknown theme: ${options.theme}\nAvailable themes: ${names}`);
@@ -732,14 +734,26 @@ async function main() {
732
734
  }
733
735
 
734
736
  let baseDir;
737
+ let singleFile = null; // basename of .md file in single-file mode
738
+ let resolvedServePath;
735
739
  try {
736
- baseDir = fs.realpathSync(path.resolve(options.dir));
740
+ resolvedServePath = fs.realpathSync(path.resolve(servePath));
737
741
  } catch {
738
- console.error(`Directory not found: ${options.dir}`);
742
+ console.error(`Path not found: ${servePath}`);
739
743
  process.exit(1);
740
744
  }
741
- if (!fs.statSync(baseDir).isDirectory()) {
742
- console.error(`Not a directory: ${options.dir}`);
745
+ const serveStat = fs.statSync(resolvedServePath);
746
+ if (serveStat.isFile()) {
747
+ if (!resolvedServePath.endsWith('.md')) {
748
+ console.error(`Not a .md file: ${servePath}`);
749
+ process.exit(1);
750
+ }
751
+ singleFile = path.basename(resolvedServePath);
752
+ baseDir = path.dirname(resolvedServePath);
753
+ } else if (serveStat.isDirectory()) {
754
+ baseDir = resolvedServePath;
755
+ } else {
756
+ console.error(`Not a file or directory: ${servePath}`);
743
757
  process.exit(1);
744
758
  }
745
759
  if (baseDir === '/') {
@@ -751,7 +765,7 @@ async function main() {
751
765
  console.error('Invalid --workers: must be a positive integer');
752
766
  process.exit(1);
753
767
  }
754
- const { MERMAID_MODAL_SCRIPT: mermaidModalScript } = await import('./render-shared.js');
768
+ const { MERMAID_MODAL_SCRIPT: mermaidModalScript, WIDTH_TOGGLE_SCRIPT: widthToggleScript } = await import('./render-shared.js');
755
769
  const poolSize = options.workers ?? Math.min(Math.max(1, os.cpus().length - 1), 4);
756
770
  const workerPath = new URL('./render-worker.js', import.meta.url);
757
771
  const pool = createRenderPool(workerPath, poolSize, {
@@ -861,7 +875,10 @@ async function main() {
861
875
  .memd-sidebar a { color: ${t.accent}; text-decoration: none; display: block; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.9rem; }
862
876
  .memd-sidebar a:hover { background: color-mix(in srgb, ${t.fg} 5%, ${t.bg}); }
863
877
  .memd-sidebar a[aria-current="page"] { background: color-mix(in srgb, ${t.accent} 12%, ${t.bg}); }
864
- .memd-content { max-width: 800px; padding: 2rem 1rem; }
878
+ .memd-content { max-width: 70%; padding: 2rem 1rem; margin: 0 auto; }
879
+ @media (max-width: 1024px) { .memd-content { max-width: 85%; } }
880
+ @media (max-width: 768px) { .memd-content { max-width: 100%; } }
881
+ body.memd-full-width .memd-content { max-width: none; margin: 0; }
865
882
  body:has(.memd-layout) { max-width: none; margin: 0; padding: 0; }
866
883
  .memd-hamburger { display: none; position: fixed; top: 0.5rem; left: 0.5rem; z-index: 10; background: color-mix(in srgb, ${t.fg} 8%, ${t.bg}); border: 1px solid ${t.line}; color: ${t.fg}; padding: 0.3rem 0.5rem; cursor: pointer; border-radius: 4px; font-size: 1.2rem; }
867
884
  @media (max-width: 768px) {
@@ -884,7 +901,7 @@ body:has(.memd-layout) { max-width: none; margin: 0; padding: 0; }
884
901
  res.end();
885
902
  return;
886
903
  }
887
- let scripts = '';
904
+ let scripts = widthToggleScript;
888
905
  if (options.watch) {
889
906
  scripts += 'new EventSource("/_memd/events").onmessage=function(){location.reload()};';
890
907
  }
@@ -1069,6 +1086,14 @@ body:has(.memd-layout) { max-width: none; margin: 0; padding: 0; }
1069
1086
  return;
1070
1087
  }
1071
1088
 
1089
+ // Single-file mode: redirect root to the served file
1090
+ if (singleFile && urlPath === '/') {
1091
+ const target = '/' + encodeURIComponent(singleFile) + parsedUrl.search;
1092
+ res.writeHead(302, { Location: target });
1093
+ res.end();
1094
+ return;
1095
+ }
1096
+
1072
1097
  if (isDotPath(urlPath)) {
1073
1098
  res.writeHead(403, { 'Content-Type': 'text/plain' });
1074
1099
  res.end('Forbidden');
@@ -1277,7 +1302,11 @@ body:has(.memd-layout) { max-width: none; margin: 0; padding: 0; }
1277
1302
  let displayHost = options.host === '0.0.0.0' || options.host === '::' ? 'localhost' : options.host;
1278
1303
  if (displayHost.includes(':')) displayHost = `[${displayHost}]`;
1279
1304
  console.log(`memd serve`);
1280
- console.log(` Directory: ${baseDir}`);
1305
+ if (singleFile) {
1306
+ console.log(` File: ${path.join(baseDir, singleFile)}`);
1307
+ } else {
1308
+ console.log(` Directory: ${baseDir}`);
1309
+ }
1281
1310
  console.log(` Theme: ${options.theme}`);
1282
1311
  if (options.watch) console.log(' Watch: enabled');
1283
1312
  console.log(` URL: http://${displayHost}:${addr.port}/`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memd-cli",
3
- "version": "3.2.1",
3
+ "version": "3.4.0",
4
4
  "type": "module",
5
5
  "main": "main.js",
6
6
  "bin": {
package/render-shared.js CHANGED
@@ -3,6 +3,8 @@ import { Marked } from 'marked';
3
3
  import { renderMermaidSVG } from '@ktrysmt/beautiful-mermaid';
4
4
  import { escapeHtml, resolveThemeColors } from './render-utils.js';
5
5
 
6
+ export const WIDTH_TOGGLE_SCRIPT = "(function(){var b=document.body,btn=document.querySelector('.memd-width-toggle');if(!btn)return;var sb=document.querySelector('.memd-sidebar');if(sb){sb.insertBefore(btn,sb.firstChild);btn.style.position='static';btn.style.margin='0 0 0.5rem 0';btn.style.width='100%'}function u(){btn.textContent=b.classList.contains('memd-full-width')?'Full':'Smart'}if(localStorage.getItem('memd-width')==='full')b.classList.add('memd-full-width');u();btn.onclick=function(){b.classList.toggle('memd-full-width');localStorage.setItem('memd-width',b.classList.contains('memd-full-width')?'full':'smart');u()}})();";
7
+
6
8
  export const MERMAID_MODAL_SCRIPT = [
7
9
  "document.addEventListener('click',function(e){var d=e.target.closest('.mermaid-diagram');if(d){var o=document.createElement('div');o.className='mermaid-modal';o.innerHTML=d.querySelector('svg').outerHTML;o.onclick=function(){o.remove()};document.body.appendChild(o)}});",
8
10
  "document.addEventListener('keydown',function(e){if(e.key==='Escape'){var m=document.querySelector('.mermaid-modal');if(m)m.remove()}});",
@@ -66,7 +68,12 @@ export function renderToHTML(markdown, diagramColors) {
66
68
  <meta name="viewport" content="width=device-width, initial-scale=1">
67
69
  ${title ? `<title>${escapeHtml(title)}</title>` : ''}
68
70
  <style>
69
- 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; }
71
+ body { background: ${t.bg}; color: ${t.fg}; font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; max-width: 70%; margin: 0 auto; padding: 2rem 1rem; }
72
+ @media (max-width: 1024px) { body { max-width: 85%; } }
73
+ @media (max-width: 768px) { body { max-width: 100%; } }
74
+ body.memd-full-width { max-width: none; margin: 0; }
75
+ .memd-width-toggle { position: fixed; top: 0.5rem; left: 0.5rem; z-index: 11; background: color-mix(in srgb, ${t.fg} 8%, ${t.bg}); border: 1px solid ${t.line}; color: ${t.muted}; padding: 0.2rem 0.6rem; cursor: pointer; border-radius: 4px; font-size: 0.75rem; }
76
+ .memd-width-toggle:hover { background: color-mix(in srgb, ${t.fg} 15%, ${t.bg}); }
70
77
  a { color: ${t.accent}; }
71
78
  hr { border-color: ${t.line}; }
72
79
  blockquote { border-left: 3px solid ${t.line}; color: ${t.muted}; padding-left: 1rem; }
@@ -85,6 +92,7 @@ th { background: color-mix(in srgb, ${t.fg} 5%, ${t.bg}); }
85
92
  <!--memd:head-->
86
93
  </head>
87
94
  <body>
95
+ <button class="memd-width-toggle" aria-label="Toggle width"></button>
88
96
  <!--memd:content-->
89
97
  ${body.trimEnd()}
90
98
  <!--/memd:content-->
package/test/memd.test.js CHANGED
@@ -26,7 +26,7 @@ function runSync(args) {
26
26
  describe('memd CLI', () => {
27
27
  it.concurrent('--version', async () => {
28
28
  const output = await run(['-v'])
29
- expect(output).toContain('3.2.1')
29
+ expect(output).toContain('3.4.0')
30
30
  })
31
31
 
32
32
  it.concurrent('--help', async () => {
@@ -416,7 +416,7 @@ describe('memd serve', () => {
416
416
  let serverProcess
417
417
 
418
418
  beforeAll(async () => {
419
- serverProcess = spawn('node', [MAIN, 'serve', '--port', String(PORT), '--dir', __dirname, '--host', '127.0.0.1'], {
419
+ serverProcess = spawn('node', [MAIN, 'serve', __dirname, '--port', String(PORT), '--host', '127.0.0.1'], {
420
420
  stdio: ['pipe', 'pipe', 'pipe'],
421
421
  })
422
422
  await new Promise((resolve, reject) => {
@@ -444,7 +444,7 @@ describe('memd serve', () => {
444
444
 
445
445
  it('serve --help shows options', () => {
446
446
  const output = runSync(['serve', '--help'])
447
- expect(output).toContain('-d, --dir')
447
+ expect(output).toContain('[path]')
448
448
  expect(output).toContain('--port')
449
449
  expect(output).toContain('--host')
450
450
  expect(output).toContain('--watch')
@@ -655,7 +655,7 @@ describe('memd serve --watch', () => {
655
655
  let watchProcess
656
656
 
657
657
  beforeAll(async () => {
658
- watchProcess = spawn('node', [MAIN, 'serve', '--port', String(WATCH_PORT), '--dir', __dirname, '--host', '127.0.0.1', '--watch'], {
658
+ watchProcess = spawn('node', [MAIN, 'serve', __dirname, '--port', String(WATCH_PORT), '--host', '127.0.0.1', '--watch'], {
659
659
  stdio: ['pipe', 'pipe', 'pipe'],
660
660
  })
661
661
  await new Promise((resolve, reject) => {
@@ -1,28 +0,0 @@
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
- "Bash(mkdir:*)",
21
- "Bash(# Restore original dist\ncp node_modules/@ktrysmt/beautiful-mermaid/dist/index.js.bak node_modules/@ktrysmt/beautiful-mermaid/dist/index.js && rm node_modules/@ktrysmt/beautiful-mermaid/dist/index.js.bak && rm -f debug-mermaid*.mjs bm-debug.mjs)",
22
- "Bash(cp dist/index.js.bak dist/index.js)",
23
- "Bash(rm -f dist/index.js.debug)",
24
- "Bash(rm -f pnpm-lock.yaml)",
25
- "Bash(rm -rf node_modules)"
26
- ]
27
- }
28
- }