memd-cli 2.0.1 → 3.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 +40 -1
- package/main.js +902 -97
- package/package.json +4 -4
- package/render-shared.js +95 -0
- package/render-utils.js +31 -0
- package/render-worker.js +13 -0
- package/test/memd.test.js +324 -3
- package/test/pixel.png +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memd-cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "main.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
"test": "vitest run"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"beautiful-mermaid": "^1.1.
|
|
13
|
+
"beautiful-mermaid": "^1.1.3",
|
|
14
14
|
"chalk": "^5.6.2",
|
|
15
15
|
"commander": "^14.0.3",
|
|
16
|
-
"marked": "^17.0.
|
|
16
|
+
"marked": "^17.0.4",
|
|
17
17
|
"marked-terminal": "^7.3.0",
|
|
18
18
|
"shiki": "^4.0.2"
|
|
19
19
|
},
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"node": ">=20"
|
|
26
26
|
},
|
|
27
27
|
"license": "MIT",
|
|
28
|
-
"packageManager": "pnpm@10.
|
|
28
|
+
"packageManager": "pnpm@10.32.1",
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"tuistory": "^0.0.16",
|
|
31
31
|
"vitest": "^4.0.18"
|
package/render-shared.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { Marked } from 'marked';
|
|
3
|
+
import { renderMermaidSVG } from 'beautiful-mermaid';
|
|
4
|
+
import { escapeHtml, resolveThemeColors } from './render-utils.js';
|
|
5
|
+
|
|
6
|
+
export const MERMAID_MODAL_SCRIPT = [
|
|
7
|
+
"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
|
+
"document.addEventListener('keydown',function(e){if(e.key==='Escape'){var m=document.querySelector('.mermaid-modal');if(m)m.remove()}});",
|
|
9
|
+
].join('');
|
|
10
|
+
|
|
11
|
+
export function convertMermaidToSVG(markdown, diagramTheme) {
|
|
12
|
+
const mermaidRegex = /```mermaid\s+([\s\S]+?)```/g;
|
|
13
|
+
let svgIndex = 0;
|
|
14
|
+
return markdown.replace(mermaidRegex, (_, code) => {
|
|
15
|
+
try {
|
|
16
|
+
const prefix = `m${svgIndex++}`;
|
|
17
|
+
let svg = renderMermaidSVG(code.trim(), diagramTheme);
|
|
18
|
+
svg = svg.replace(/@import url\([^)]+\);\s*/g, '');
|
|
19
|
+
// Prefix all id="..." and url(#...) to avoid cross-SVG collisions
|
|
20
|
+
// Note: regex uses ` id=` (with leading space) to avoid matching `data-id`
|
|
21
|
+
svg = svg.replace(/ id="([^"]+)"/g, ` id="${prefix}-$1"`);
|
|
22
|
+
svg = svg.replace(/url\(#([^)]+)\)/g, `url(#${prefix}-$1)`);
|
|
23
|
+
return svg;
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return `<pre class="mermaid-error">${escapeHtml(e.message)}\n\n${escapeHtml(code.trim())}</pre>`;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const htmlMarked = new Marked();
|
|
31
|
+
|
|
32
|
+
// NOTE: Raw HTML in markdown (CSS, HTML tags) is passed through unsanitized.
|
|
33
|
+
// JavaScript in markdown IS executable via inline HTML. This is intentional for a
|
|
34
|
+
// development-only tool; CSS/HTML authoring in markdown is a useful feature.
|
|
35
|
+
// The serve path mitigates XSS via CSP nonce-based script-src.
|
|
36
|
+
export function renderToHTML(markdown, diagramColors) {
|
|
37
|
+
const processed = convertMermaidToSVG(markdown, diagramColors);
|
|
38
|
+
// Protect trusted SVGs/error blocks from HTML sanitization by replacing with placeholders
|
|
39
|
+
const nonce = crypto.randomUUID();
|
|
40
|
+
const svgStore = [];
|
|
41
|
+
const withPlaceholders = processed.replace(/<svg[\s\S]*?<\/svg>|<pre class="mermaid-error">[\s\S]*?<\/pre>/g, (match) => {
|
|
42
|
+
const id = svgStore.length;
|
|
43
|
+
svgStore.push(match);
|
|
44
|
+
return `MEMD_SVG_${nonce}_${id}`;
|
|
45
|
+
});
|
|
46
|
+
let body = htmlMarked.parse(withPlaceholders);
|
|
47
|
+
// Restore SVGs and wrap Mermaid diagrams in scrollable containers
|
|
48
|
+
for (let i = 0; i < svgStore.length; i++) {
|
|
49
|
+
const stored = svgStore[i];
|
|
50
|
+
const wrapped = stored.startsWith('<svg')
|
|
51
|
+
? `<div class="mermaid-diagram">${stored}</div>`
|
|
52
|
+
: stored;
|
|
53
|
+
body = body.replace(`MEMD_SVG_${nonce}_${i}`, wrapped);
|
|
54
|
+
}
|
|
55
|
+
// Unwrap <p> tags around block-level mermaid containers
|
|
56
|
+
body = body.replace(/<p>\s*(<div class="mermaid-diagram">[\s\S]*?<\/div>)\s*<\/p>/g, '$1');
|
|
57
|
+
const t = resolveThemeColors(diagramColors);
|
|
58
|
+
|
|
59
|
+
const titleMatch = markdown.match(/^#\s+(.+)$/m);
|
|
60
|
+
const title = titleMatch ? titleMatch[1].replace(/<[^>]+>/g, '').trim() : '';
|
|
61
|
+
|
|
62
|
+
return `<!DOCTYPE html>
|
|
63
|
+
<html lang="en">
|
|
64
|
+
<head>
|
|
65
|
+
<meta charset="utf-8">
|
|
66
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
67
|
+
${title ? `<title>${escapeHtml(title)}</title>` : ''}
|
|
68
|
+
<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; }
|
|
70
|
+
a { color: ${t.accent}; }
|
|
71
|
+
hr { border-color: ${t.line}; }
|
|
72
|
+
blockquote { border-left: 3px solid ${t.line}; color: ${t.muted}; padding-left: 1rem; }
|
|
73
|
+
svg { max-width: 100%; height: auto; }
|
|
74
|
+
.mermaid-diagram { cursor: zoom-in; text-align: center; }
|
|
75
|
+
.mermaid-modal { position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 9999; display: flex; align-items: center; justify-content: center; cursor: zoom-out; padding: 2rem; }
|
|
76
|
+
.mermaid-modal svg { max-width: calc(100vw - 4rem); max-height: calc(100vh - 4rem); }
|
|
77
|
+
pre { background: color-mix(in srgb, ${t.fg} 8%, ${t.bg}); padding: 1rem; border-radius: 6px; overflow-x: auto; }
|
|
78
|
+
code { font-size: 0.9em; color: ${t.accent}; }
|
|
79
|
+
pre code { color: inherit; }
|
|
80
|
+
table { border-collapse: collapse; }
|
|
81
|
+
th, td { border: 1px solid ${t.line}; padding: 0.4rem 0.8rem; }
|
|
82
|
+
th { background: color-mix(in srgb, ${t.fg} 5%, ${t.bg}); }
|
|
83
|
+
.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; }
|
|
84
|
+
</style>
|
|
85
|
+
<!--memd:head-->
|
|
86
|
+
</head>
|
|
87
|
+
<body>
|
|
88
|
+
<!--memd:content-->
|
|
89
|
+
${body.trimEnd()}
|
|
90
|
+
<!--/memd:content-->
|
|
91
|
+
<!--memd:scripts-->
|
|
92
|
+
</body>
|
|
93
|
+
</html>
|
|
94
|
+
`;
|
|
95
|
+
}
|
package/render-utils.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function escapeHtml(str) {
|
|
2
|
+
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
// Color mixing: blend hex1 into hex2 at pct% (sRGB linear interpolation)
|
|
6
|
+
// Equivalent to CSS color-mix(in srgb, hex1 pct%, hex2)
|
|
7
|
+
export function mixHex(hex1, hex2, pct) {
|
|
8
|
+
const p = pct / 100;
|
|
9
|
+
const parse = (h, o) => parseInt(h.slice(o, o + 2), 16);
|
|
10
|
+
const mix = (c1, c2) => Math.round(c1 * p + c2 * (1 - p));
|
|
11
|
+
const toHex = x => x.toString(16).padStart(2, '0');
|
|
12
|
+
const r = mix(parse(hex1, 1), parse(hex2, 1));
|
|
13
|
+
const g = mix(parse(hex1, 3), parse(hex2, 3));
|
|
14
|
+
const b = mix(parse(hex1, 5), parse(hex2, 5));
|
|
15
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// MIX ratios from beautiful-mermaid theme.ts:64-87
|
|
19
|
+
export const MIX = { line: 50, arrow: 85, textSec: 60, nodeStroke: 20 };
|
|
20
|
+
|
|
21
|
+
// Resolve optional DiagramColors fields for HTML template CSS
|
|
22
|
+
export function resolveThemeColors(colors) {
|
|
23
|
+
return {
|
|
24
|
+
bg: colors.bg,
|
|
25
|
+
fg: colors.fg,
|
|
26
|
+
line: colors.line ?? mixHex(colors.fg, colors.bg, MIX.line),
|
|
27
|
+
accent: colors.accent ?? mixHex(colors.fg, colors.bg, MIX.arrow),
|
|
28
|
+
muted: colors.muted ?? mixHex(colors.fg, colors.bg, MIX.textSec),
|
|
29
|
+
border: colors.border ?? mixHex(colors.fg, colors.bg, MIX.nodeStroke),
|
|
30
|
+
};
|
|
31
|
+
}
|
package/render-worker.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { parentPort } from 'node:worker_threads';
|
|
2
|
+
import { renderToHTML } from './render-shared.js';
|
|
3
|
+
|
|
4
|
+
if (!parentPort) throw new Error('This file must be run as a worker thread');
|
|
5
|
+
|
|
6
|
+
parentPort.on('message', ({ id, markdown, diagramColors }) => {
|
|
7
|
+
try {
|
|
8
|
+
const html = renderToHTML(markdown, diagramColors);
|
|
9
|
+
parentPort.postMessage({ id, html });
|
|
10
|
+
} catch (e) {
|
|
11
|
+
parentPort.postMessage({ id, error: e.message });
|
|
12
|
+
}
|
|
13
|
+
});
|
package/test/memd.test.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
2
2
|
import { launchTerminal } from 'tuistory'
|
|
3
|
-
import { execSync } from 'child_process'
|
|
3
|
+
import { execSync, spawn } from 'child_process'
|
|
4
|
+
import fs from 'fs'
|
|
4
5
|
import path from 'path'
|
|
5
6
|
import { fileURLToPath } from 'url'
|
|
6
7
|
|
|
7
8
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
8
9
|
const MAIN = path.join(__dirname, '..', 'main.js')
|
|
9
10
|
|
|
11
|
+
// Strip MEMD_THEME from env so tests always get the built-in default (nord)
|
|
12
|
+
delete process.env.MEMD_THEME
|
|
13
|
+
|
|
10
14
|
async function run(args, { waitFor = null } = {}) {
|
|
11
15
|
const session = await launchTerminal({
|
|
12
16
|
command: 'node',
|
|
@@ -27,7 +31,7 @@ function runSync(args) {
|
|
|
27
31
|
describe('memd CLI', () => {
|
|
28
32
|
it('--version', async () => {
|
|
29
33
|
const output = await run(['-v'])
|
|
30
|
-
expect(output).toContain('
|
|
34
|
+
expect(output).toContain('3.0.0')
|
|
31
35
|
})
|
|
32
36
|
|
|
33
37
|
it('--help', async () => {
|
|
@@ -279,6 +283,37 @@ describe('memd CLI', () => {
|
|
|
279
283
|
expect(output).toContain('Hello')
|
|
280
284
|
expect(output).toContain('More text.')
|
|
281
285
|
})
|
|
286
|
+
|
|
287
|
+
it('MEMD_THEME env sets default theme (HTML path)', () => {
|
|
288
|
+
const output = execSync(`node ${MAIN} --html test/test1.md`, {
|
|
289
|
+
encoding: 'utf-8',
|
|
290
|
+
timeout: 15000,
|
|
291
|
+
env: { ...process.env, MEMD_THEME: 'dracula' },
|
|
292
|
+
})
|
|
293
|
+
expect(output).toContain('#282a36') // dracula bg
|
|
294
|
+
expect(output).toContain('#f8f8f2') // dracula fg
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('--theme flag overrides MEMD_THEME env', () => {
|
|
298
|
+
const output = execSync(`node ${MAIN} --html --theme tokyo-night test/test1.md`, {
|
|
299
|
+
encoding: 'utf-8',
|
|
300
|
+
timeout: 15000,
|
|
301
|
+
env: { ...process.env, MEMD_THEME: 'dracula' },
|
|
302
|
+
})
|
|
303
|
+
expect(output).toContain('#1a1b26') // tokyo-night bg
|
|
304
|
+
expect(output).not.toContain('#282a36') // not dracula bg
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('invalid MEMD_THEME env exits with error', () => {
|
|
308
|
+
expect(() => {
|
|
309
|
+
execSync(`node ${MAIN} --html test/test1.md`, {
|
|
310
|
+
encoding: 'utf-8',
|
|
311
|
+
timeout: 15000,
|
|
312
|
+
stdio: 'pipe',
|
|
313
|
+
env: { ...process.env, MEMD_THEME: 'nonexistent' },
|
|
314
|
+
})
|
|
315
|
+
}).toThrow()
|
|
316
|
+
})
|
|
282
317
|
})
|
|
283
318
|
|
|
284
319
|
// chalk.level = 0 + Shiki: verify no ANSI codes for all themes
|
|
@@ -345,3 +380,289 @@ describe('memd CLI', () => {
|
|
|
345
380
|
expect(output).not.toContain('Could not find the language')
|
|
346
381
|
})
|
|
347
382
|
})
|
|
383
|
+
|
|
384
|
+
describe('memd serve', () => {
|
|
385
|
+
const PORT = 19876
|
|
386
|
+
const BASE_URL = `http://127.0.0.1:${PORT}`
|
|
387
|
+
let serverProcess
|
|
388
|
+
|
|
389
|
+
beforeAll(async () => {
|
|
390
|
+
serverProcess = spawn('node', [MAIN, 'serve', '--port', String(PORT), '--dir', __dirname, '--host', '127.0.0.1'], {
|
|
391
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
392
|
+
})
|
|
393
|
+
await new Promise((resolve, reject) => {
|
|
394
|
+
serverProcess.stdout.on('data', (data) => {
|
|
395
|
+
if (data.toString().includes('http://')) resolve()
|
|
396
|
+
})
|
|
397
|
+
serverProcess.on('error', reject)
|
|
398
|
+
setTimeout(() => reject(new Error('Server did not start in time')), 10000)
|
|
399
|
+
})
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
afterAll(async () => {
|
|
403
|
+
if (serverProcess) {
|
|
404
|
+
const exited = new Promise((resolve, reject) => {
|
|
405
|
+
serverProcess.on('close', resolve)
|
|
406
|
+
setTimeout(() => {
|
|
407
|
+
serverProcess.kill('SIGKILL')
|
|
408
|
+
reject(new Error('Server did not exit within 5s after SIGTERM'))
|
|
409
|
+
}, 5000)
|
|
410
|
+
})
|
|
411
|
+
serverProcess.kill('SIGTERM')
|
|
412
|
+
await exited
|
|
413
|
+
}
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
it('serve --help shows options', () => {
|
|
417
|
+
const output = runSync('serve --help')
|
|
418
|
+
expect(output).toContain('-d, --dir')
|
|
419
|
+
expect(output).toContain('--port')
|
|
420
|
+
expect(output).toContain('--host')
|
|
421
|
+
expect(output).toContain('--watch')
|
|
422
|
+
expect(output).toContain('--theme')
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('serves directory listing at root', async () => {
|
|
426
|
+
const res = await fetch(`${BASE_URL}/`)
|
|
427
|
+
expect(res.status).toBe(200)
|
|
428
|
+
expect(res.headers.get('content-type')).toContain('text/html')
|
|
429
|
+
const body = await res.text()
|
|
430
|
+
expect(body).toContain('Index of /')
|
|
431
|
+
expect(body).toContain('test1.md')
|
|
432
|
+
expect(body).toContain('test2.md')
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
it('serves .md file as HTML', async () => {
|
|
436
|
+
const res = await fetch(`${BASE_URL}/test1.md`)
|
|
437
|
+
expect(res.status).toBe(200)
|
|
438
|
+
expect(res.headers.get('content-type')).toContain('text/html')
|
|
439
|
+
const body = await res.text()
|
|
440
|
+
expect(body).toContain('<!DOCTYPE html>')
|
|
441
|
+
expect(body).toContain('<svg')
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
it('serves extensionless URL as .md', async () => {
|
|
445
|
+
const res = await fetch(`${BASE_URL}/test1`)
|
|
446
|
+
expect(res.status).toBe(200)
|
|
447
|
+
const body = await res.text()
|
|
448
|
+
expect(body).toContain('<!DOCTYPE html>')
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('returns 304 on matching ETag', async () => {
|
|
452
|
+
const res1 = await fetch(`${BASE_URL}/test1`)
|
|
453
|
+
const etag = res1.headers.get('etag')
|
|
454
|
+
expect(etag).toBeTruthy()
|
|
455
|
+
const res2 = await fetch(`${BASE_URL}/test1`, {
|
|
456
|
+
headers: { 'If-None-Match': etag },
|
|
457
|
+
})
|
|
458
|
+
expect(res2.status).toBe(304)
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it('returns 404 for missing file', async () => {
|
|
462
|
+
const res = await fetch(`${BASE_URL}/nonexistent`)
|
|
463
|
+
expect(res.status).toBe(404)
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('blocks path traversal', async () => {
|
|
467
|
+
const res = await fetch(`${BASE_URL}/%2e%2e/package.json`)
|
|
468
|
+
expect([403, 404]).toContain(res.status)
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
it('returns 404 for non-.md static files', async () => {
|
|
472
|
+
const res = await fetch(`${BASE_URL}/memd.test.js`)
|
|
473
|
+
expect(res.status).toBe(404)
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('handles concurrent rendering without blocking', async () => {
|
|
477
|
+
const urls = [
|
|
478
|
+
`${BASE_URL}/test1.md`,
|
|
479
|
+
`${BASE_URL}/test2.md`,
|
|
480
|
+
`${BASE_URL}/test1`,
|
|
481
|
+
`${BASE_URL}/test2`,
|
|
482
|
+
]
|
|
483
|
+
const results = await Promise.all(urls.map(u => fetch(u)))
|
|
484
|
+
for (const res of results) {
|
|
485
|
+
expect(res.status).toBe(200)
|
|
486
|
+
const body = await res.text()
|
|
487
|
+
expect(body).toContain('<!DOCTYPE html>')
|
|
488
|
+
}
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
// 4a. Static file serving
|
|
492
|
+
it('serves image files with correct Content-Type', async () => {
|
|
493
|
+
const res = await fetch(`${BASE_URL}/pixel.png`)
|
|
494
|
+
expect(res.status).toBe(200)
|
|
495
|
+
expect(res.headers.get('content-type')).toBe('image/png')
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('returns ETag header for static files', async () => {
|
|
499
|
+
const res = await fetch(`${BASE_URL}/pixel.png`)
|
|
500
|
+
expect(res.status).toBe(200)
|
|
501
|
+
expect(res.headers.get('etag')).toBeTruthy()
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('returns 304 on matching ETag for static files', async () => {
|
|
505
|
+
const res1 = await fetch(`${BASE_URL}/pixel.png`)
|
|
506
|
+
const etag = res1.headers.get('etag')
|
|
507
|
+
expect(etag).toBeTruthy()
|
|
508
|
+
const res2 = await fetch(`${BASE_URL}/pixel.png`, {
|
|
509
|
+
headers: { 'If-None-Match': etag },
|
|
510
|
+
})
|
|
511
|
+
expect(res2.status).toBe(304)
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
// 4b. Worker error recovery
|
|
515
|
+
it('server continues to respond after an error', async () => {
|
|
516
|
+
const res1 = await fetch(`${BASE_URL}/test1`)
|
|
517
|
+
expect(res1.status).toBe(200)
|
|
518
|
+
await res1.text()
|
|
519
|
+
const res2 = await fetch(`${BASE_URL}/nonexistent-error-test`)
|
|
520
|
+
expect(res2.status).toBe(404)
|
|
521
|
+
await res2.text()
|
|
522
|
+
const res3 = await fetch(`${BASE_URL}/test1`)
|
|
523
|
+
expect(res3.status).toBe(200)
|
|
524
|
+
const body = await res3.text()
|
|
525
|
+
expect(body).toContain('<!DOCTYPE html>')
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
// 4c. Cache behavior
|
|
529
|
+
it('second request for same .md returns same ETag (cache hit)', async () => {
|
|
530
|
+
const res1 = await fetch(`${BASE_URL}/test1`)
|
|
531
|
+
await res1.text()
|
|
532
|
+
const etag1 = res1.headers.get('etag')
|
|
533
|
+
const res2 = await fetch(`${BASE_URL}/test1`)
|
|
534
|
+
await res2.text()
|
|
535
|
+
const etag2 = res2.headers.get('etag')
|
|
536
|
+
expect(etag1).toBeTruthy()
|
|
537
|
+
expect(etag1).toBe(etag2)
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
it('modified .md file returns updated content (cache invalidation)', async () => {
|
|
541
|
+
const tmpFile = path.join(__dirname, 'test-cache-tmp.md')
|
|
542
|
+
fs.writeFileSync(tmpFile, '# Original')
|
|
543
|
+
try {
|
|
544
|
+
const res1 = await fetch(`${BASE_URL}/test-cache-tmp`)
|
|
545
|
+
expect(res1.status).toBe(200)
|
|
546
|
+
const body1 = await res1.text()
|
|
547
|
+
expect(body1).toContain('Original')
|
|
548
|
+
await new Promise(r => setTimeout(r, 50))
|
|
549
|
+
fs.writeFileSync(tmpFile, '# Updated')
|
|
550
|
+
const res2 = await fetch(`${BASE_URL}/test-cache-tmp`)
|
|
551
|
+
expect(res2.status).toBe(200)
|
|
552
|
+
const body2 = await res2.text()
|
|
553
|
+
expect(body2).toContain('Updated')
|
|
554
|
+
} finally {
|
|
555
|
+
fs.unlinkSync(tmpFile)
|
|
556
|
+
}
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
// 4d. Non-watch mode SSE
|
|
560
|
+
it('non-watch mode returns 404 for /_memd/events', async () => {
|
|
561
|
+
const res = await fetch(`${BASE_URL}/_memd/events`)
|
|
562
|
+
expect(res.status).toBe(404)
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
// 4f. Directory redirect
|
|
566
|
+
it('directory without trailing slash returns 302', async () => {
|
|
567
|
+
const tmpDir = path.join(__dirname, 'tmpsubdir')
|
|
568
|
+
fs.mkdirSync(tmpDir, { recursive: true })
|
|
569
|
+
try {
|
|
570
|
+
const res = await fetch(`${BASE_URL}/tmpsubdir`, { redirect: 'manual' })
|
|
571
|
+
expect(res.status).toBe(302)
|
|
572
|
+
expect(res.headers.get('location')).toBe('/tmpsubdir/')
|
|
573
|
+
} finally {
|
|
574
|
+
fs.rmdirSync(tmpDir)
|
|
575
|
+
}
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
// 4g. Path traversal vectors
|
|
579
|
+
it('../ encoded traversal returns 403 or 404', async () => {
|
|
580
|
+
const res = await fetch(`${BASE_URL}/%2e%2e/package.json`)
|
|
581
|
+
expect([403, 404]).toContain(res.status)
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
it('..%2f encoded traversal returns 403 or 404', async () => {
|
|
585
|
+
const res = await fetch(`${BASE_URL}/..%2fpackage.json`)
|
|
586
|
+
expect([403, 404]).toContain(res.status)
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
it('null byte in URL path returns 400 or 403', async () => {
|
|
590
|
+
const res = await fetch(`${BASE_URL}/test%00.md`)
|
|
591
|
+
expect([400, 403]).toContain(res.status)
|
|
592
|
+
})
|
|
593
|
+
|
|
594
|
+
// Sidebar
|
|
595
|
+
it('.md file response contains sidebar', async () => {
|
|
596
|
+
const res = await fetch(`${BASE_URL}/test1.md`)
|
|
597
|
+
const body = await res.text()
|
|
598
|
+
expect(body).toContain('memd-sidebar')
|
|
599
|
+
expect(body).toContain('memd-layout')
|
|
600
|
+
expect(body).toContain('aria-current="page"')
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
// isDotPath design: '..' allowed by isDotPath, caught by resolveServePath
|
|
604
|
+
describe('isDotPath and resolveServePath interaction', () => {
|
|
605
|
+
it('isDotPath allows .. (traversal caught by resolveServePath)', async () => {
|
|
606
|
+
const res = await fetch(`${BASE_URL}/../package.json`)
|
|
607
|
+
expect([403, 404]).toContain(res.status)
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
it('isDotPath blocks dotfiles like .hidden', async () => {
|
|
611
|
+
const res = await fetch(`${BASE_URL}/.hidden`)
|
|
612
|
+
expect(res.status).toBe(403)
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
it('isDotPath blocks .git paths', async () => {
|
|
616
|
+
const res = await fetch(`${BASE_URL}/.git/config`)
|
|
617
|
+
expect(res.status).toBe(403)
|
|
618
|
+
})
|
|
619
|
+
})
|
|
620
|
+
})
|
|
621
|
+
|
|
622
|
+
// 4d. Watch mode SSE
|
|
623
|
+
describe('memd serve --watch', () => {
|
|
624
|
+
const WATCH_PORT = 19877
|
|
625
|
+
const WATCH_URL = `http://127.0.0.1:${WATCH_PORT}`
|
|
626
|
+
let watchProcess
|
|
627
|
+
|
|
628
|
+
beforeAll(async () => {
|
|
629
|
+
watchProcess = spawn('node', [MAIN, 'serve', '--port', String(WATCH_PORT), '--dir', __dirname, '--host', '127.0.0.1', '--watch'], {
|
|
630
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
631
|
+
})
|
|
632
|
+
await new Promise((resolve, reject) => {
|
|
633
|
+
watchProcess.stdout.on('data', (data) => {
|
|
634
|
+
if (data.toString().includes('http://')) resolve()
|
|
635
|
+
})
|
|
636
|
+
watchProcess.on('error', reject)
|
|
637
|
+
setTimeout(() => reject(new Error('Watch server did not start in time')), 10000)
|
|
638
|
+
})
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
afterAll(async () => {
|
|
642
|
+
if (watchProcess) {
|
|
643
|
+
const exited = new Promise((resolve, reject) => {
|
|
644
|
+
watchProcess.on('close', resolve)
|
|
645
|
+
setTimeout(() => {
|
|
646
|
+
watchProcess.kill('SIGKILL')
|
|
647
|
+
reject(new Error('Watch server did not exit within 5s after SIGTERM'))
|
|
648
|
+
}, 5000)
|
|
649
|
+
})
|
|
650
|
+
watchProcess.kill('SIGTERM')
|
|
651
|
+
await exited
|
|
652
|
+
}
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
it('/_memd/events returns text/event-stream content type', async () => {
|
|
656
|
+
const controller = new AbortController()
|
|
657
|
+
const timeout = setTimeout(() => controller.abort(), 3000)
|
|
658
|
+
try {
|
|
659
|
+
const res = await fetch(`${WATCH_URL}/_memd/events`, { signal: controller.signal })
|
|
660
|
+
expect(res.status).toBe(200)
|
|
661
|
+
expect(res.headers.get('content-type')).toBe('text/event-stream')
|
|
662
|
+
} finally {
|
|
663
|
+
clearTimeout(timeout)
|
|
664
|
+
controller.abort()
|
|
665
|
+
}
|
|
666
|
+
})
|
|
667
|
+
})
|
|
668
|
+
|
package/test/pixel.png
ADDED
|
Binary file
|