From 85f62bf842470298fb7cf355f078159faceeb685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20OLIVIER?= Date: Fri, 19 Jun 2026 12:08:45 +0200 Subject: [PATCH] feat: change css + watcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Cédric OLIVIER --- README.md | 33 +++- index.js | 43 ++++- lib/converter.js | 488 +++++++++++++++++++++++++++++------------------ lib/server.js | 73 ++++++- lib/watcher.js | 7 +- 5 files changed, 437 insertions(+), 207 deletions(-) diff --git a/README.md b/README.md index a905ad7..5f77622 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,13 @@ md-render README.md # Conversion vers un fichier spécifique md-render README.md -o documentation.html -# Mode watch — ouvre http://localhost:3000 dans votre navigateur +# Mode preview — ouvre dans le navigateur sans créer de fichier +md-render README.md --preview + +# Mode preview sur un port personnalisé +md-render README.md --preview --port 8080 + +# Mode watch — ouvre http://localhost:3000 avec rechargement automatique md-render README.md --watch # Mode watch sur un port personnalisé @@ -27,12 +33,24 @@ md-render README.md --watch --port 8080 ## Options -| Option | Alias | Description | Défaut | -|---------------------|-------|---------------------------------------------|--------| -| `--watch` | `-w` | Lance le serveur avec rechargement auto | — | -| `--port ` | `-p` | Port du serveur en mode watch | `3000` | -| `--output `| `-o` | Fichier HTML de sortie | `.html` | -| `--help` | `-h` | Affiche l'aide | — | +| Option | Alias | Description | Défaut | +|---------------------|-------|----------------------------------------------------|----------------| +| `--watch` | `-w` | Lance le serveur avec rechargement auto | — | +| `--preview` | `-v` | Ouvre dans le navigateur sans créer de fichier | — | +| `--port ` | `-p` | Port du serveur en mode watch ou preview | `3000` | +| `--output `| `-o` | Fichier HTML de sortie | `.html` | +| `--help` | `-h` | Affiche l'aide | — | + +## Mode preview + +En mode `--preview`, le Markdown est converti en mémoire et affiché directement dans votre +navigateur — **aucun fichier HTML n'est créé sur le disque**. Le navigateur s'ouvre automatiquement. +Appuyez sur `Ctrl+C` pour arrêter le serveur. + +```bash +md-render README.md --preview # fichier unique +md-render mon-wiki/ --preview # dossier entier (avec sidebar) +``` ## Mode watch @@ -46,6 +64,7 @@ Les assets relatifs (images, etc.) placés dans le même répertoire que le `.md - 🎨 Coloration syntaxique du code (highlight.js) - 📄 Style GitHub-like +- 👁 Prévisualisation instantanée sans fichier (`--preview`) - 🔄 Rechargement automatique en mode watch - 🖼 Serveur de fichiers statiques pour les images locales - ⚡ Debounce intelligent pour éviter les rechargements en rafale diff --git a/index.js b/index.js index 628cb91..b989360 100755 --- a/index.js +++ b/index.js @@ -9,9 +9,9 @@ const { startServer } = require('./lib/server'); const { startWatcher } = require('./lib/watcher'); const argv = minimist(process.argv.slice(2), { - alias: { o: 'output', p: 'port', w: 'watch', h: 'help' }, + alias: { o: 'output', p: 'port', w: 'watch', v: 'preview', h: 'help' }, default: { port: 3000 }, - boolean: ['watch', 'help'], + boolean: ['watch', 'preview', 'open', 'help'], }); if (argv.help) { @@ -20,13 +20,17 @@ Usage: md-render [options] Options: -w, --watch Surveille les modifications et recharge automatiquement - -p, --port Port du serveur en mode watch (défaut: 3000) + -v, --preview Comme --watch, mais ouvre aussi le navigateur + --open Ouvre le navigateur (combinable avec --watch) + -p, --port Port du serveur (défaut: 3000) -o, --output Dossier de sortie HTML (mode dossier, sans --watch) -h, --help Affiche cette aide Exemples: md-render README.md Convertit README.md → README.html - md-render README.md --watch Prévisualisation live sur localhost:3000 + md-render README.md --watch Live reload sur localhost:3000 + md-render README.md --watch --open Live reload + ouvre le navigateur + md-render README.md --preview Alias de --watch --open md-render ../my-portal.wiki/ Convertit tous les .md du dossier md-render ../my-portal.wiki/ --watch Wiki live sur localhost:3000 `); @@ -51,24 +55,47 @@ if (!fs.existsSync(absInput)) { const stat = fs.statSync(absInput); const isDir = stat.isDirectory(); -// ── WATCH MODE ────────────────────────────────────────────────────────────── -if (argv.watch) { +// ── SERVER MODE (--watch ou --preview) ────────────────────────────────────── +if (argv.watch || argv.preview) { const port = parseInt(argv.port, 10); if (isNaN(port) || port < 1 || port > 65535) { console.error(`\n✗ Erreur : port invalide "${argv.port}".\n`); process.exit(1); } - const server = startServer({ + const shouldOpen = argv.preview || argv.open; + + function openBrowser(url) { + const { exec } = require('child_process'); + const cmd = + process.platform === 'darwin' ? `open "${url}"` : + process.platform === 'win32' ? `start "" "${url}"` : + `xdg-open "${url}"`; + exec(cmd, (err) => { + if (err) console.warn(` ⚠ Impossible d'ouvrir le navigateur : ${err.message}`); + }); + } + + const serverObj = startServer({ inputFile: isDir ? null : absInput, inputDir: isDir ? absInput : null, port, + onReady: shouldOpen ? (p) => { + console.log(' Appuyez sur Ctrl+C pour arrêter.\n'); + openBrowser(`http://localhost:${p}`); + } : null, }); startWatcher({ inputFile: isDir ? null : absInput, inputDir: isDir ? absInput : null, - server, + server: serverObj, + }); + + process.on('SIGINT', () => { + serverObj.server.close(); + console.log('\n👋 Au revoir !'); + process.exit(0); }); return; } diff --git a/lib/converter.js b/lib/converter.js index 81bca9f..e1d9077 100644 --- a/lib/converter.js +++ b/lib/converter.js @@ -19,13 +19,13 @@ marked.use(markedHighlight({ const STYLES = ` /* ── Reset ── */ *, *::before, *::after { box-sizing: border-box; } - ::selection { background: #ffc3c3; } + ::selection { background: #c7d2fe; } html, body { height: 100%; margin: 0; } body { font-family: 'Poppins', sans-serif; - background: #fff; - color: #222; - text-rendering: optimizeLegibility; + background: #f1f5f9; + color: #1e293b; + line-height: 1.7; -webkit-font-smoothing: antialiased; overflow-x: hidden; } @@ -33,192 +33,287 @@ const STYLES = ` /* ── Sidebar ── */ .sidebar { position: fixed; - top: 0; - left: 0; + top: 0; left: 0; width: 264px; height: 100vh; - background: #1a1b2e; + background: #0f172a; display: flex; flex-direction: column; z-index: 99; overflow: hidden; + border-right: 1px solid #1e293b; } .sidebar__title-block { - margin: 48px 24px 8px; + margin: 32px 24px 0; + padding-bottom: 16px; + border-bottom: 1px solid rgba(255,255,255,0.07); flex-shrink: 0; } - .sidebar__title-block a { - text-decoration: none; - } + .sidebar__title-block a { text-decoration: none; } .sidebar__title-block__title { color: #fff; - font-size: 26px; + font-size: 20px; font-weight: 700; - margin: 0 0 4px; - line-height: 1.25; - pointer-events: none; + margin: 0; + line-height: 1.3; } - .sidebar__title-block__version { - color: rgba(255,255,255,0.3); - font-size: 14px; - font-weight: 700; - display: block; - margin-bottom: 24px; + .sidebar__title-bolt { + font-size: 1.1em; + filter: drop-shadow(0 0 4px #fbbf24); + margin-right: 4px; } /* ── Sidebar menu ── */ .sidebar__menu { list-style: none; margin: 0; - padding: 8px 0 48px 0; + padding: 12px 0 48px; flex: 1; overflow-y: auto; } - .sidebar__menu::-webkit-scrollbar { width: 0; } + .sidebar__menu::-webkit-scrollbar { width: 4px; } + .sidebar__menu::-webkit-scrollbar-track { background: transparent; } + .sidebar__menu::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 4px; } - /* First-level = section group label (non-clickable) */ - .sidebar__menu__group-label { - display: block; - padding: 16px 24px 6px; - color: rgba(255,255,255,0.35); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; - } + /* ── Tree nav ── */ + .nav-tree { list-style: none; margin: 0; padding: 0; } + .nav-tree li { margin: 0; } - /* First-level links (root files or group header links) */ - .sidebar__menu__first-level-title { margin: 0; } - .sidebar__menu__first-level-title__link { + /* Root-level file links */ + .nav-root-link { display: block; - padding: 7px 24px; - color: rgba(255,255,255,0.85); - text-decoration: none; - font-size: 16px; - font-weight: 400; - line-height: 1.4; - transition: color 0.2s; - } - .sidebar__menu__first-level-title__link:hover { color: #ff5577; } - .sidebar__menu__first-level-title__link.active { - color: #ff5577; - font-weight: 500; - } - - /* Second-level links (files inside a group) */ - .sidebar__menu__second-level { list-style: none; padding: 0; margin: 0; } - .sidebar__menu__second-level-title { margin: 0; } - .sidebar__menu__second-level-title__link { - display: block; - padding: 5px 24px 5px 36px; - color: rgba(255,255,255,0.55); + padding: 6px 24px; + color: rgba(255,255,255,0.6); text-decoration: none; font-size: 14px; font-weight: 400; line-height: 1.4; - transition: color 0.2s; + transition: color 0.15s, background 0.15s; } - .sidebar__menu__second-level-title__link:hover { color: #ff5577; } - .sidebar__menu__second-level-title__link.active { - color: #ff5577; - opacity: 1; + .nav-root-link:hover { color: #fff; background: rgba(255,255,255,0.06); } + .nav-root-link.active { color: #818cf8; font-weight: 600; } + + /* Folder
block */ + .nav-folder { margin: 0; } + .nav-folder > summary { + display: flex; + align-items: center; + list-style: none; + cursor: default; + user-select: none; + padding: 0; + color: rgba(255,255,255,0.35); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; } + .nav-folder > summary::-webkit-details-marker { display: none; } + + /* Arrow */ + .nav-arrow { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 28px; + height: 28px; + font-size: 9px; + opacity: 0.4; + cursor: pointer; + transition: transform 0.18s, opacity 0.2s; + border-radius: 4px; + color: rgba(255,255,255,0.7); + } + .nav-arrow:hover { opacity: 1; background: rgba(255,255,255,0.08); } + .nav-folder[open] > summary .nav-arrow { transform: rotate(90deg); opacity: 0.8; } + + /* Folder label */ + .nav-folder-link, .nav-folder-name { + flex: 1; + display: block; + padding: 6px 24px 6px 0; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(255,255,255,0.38); + text-decoration: none; + transition: color 0.15s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .nav-folder-link:hover { color: rgba(255,255,255,0.85); } + .nav-folder-link.active { color: #818cf8; } + + /* Nested folder (depth ≥ 2) */ + .nav-folder .nav-folder > summary { + font-size: 13px; + font-weight: 500; + text-transform: none; + letter-spacing: 0; + } + .nav-folder .nav-folder > summary .nav-folder-link, + .nav-folder .nav-folder > summary .nav-folder-name { + font-size: 13px; + font-weight: 500; + text-transform: none; + letter-spacing: 0; + color: rgba(255,255,255,0.45); + } + .nav-folder .nav-folder > summary .nav-folder-link:hover { color: rgba(255,255,255,0.9); } + + /* File links inside folders */ + .nav-file-link { + display: block; + text-decoration: none; + font-size: 13.5px; + font-weight: 400; + line-height: 1.4; + color: rgba(255,255,255,0.5); + transition: color 0.15s, background 0.15s; + } + .nav-file-link:hover { color: #fff; background: rgba(255,255,255,0.06); } + .nav-file-link.active { color: #818cf8; font-weight: 600; } + + /* Indentation per depth level */ + .nav-depth-1 > .nav-file-link { padding: 5px 24px 5px 52px; } + .nav-depth-1 > .nav-folder > summary { padding-left: 24px; } + .nav-depth-2 > .nav-file-link { padding: 4px 24px 4px 68px; } + .nav-depth-2 > .nav-folder > summary { padding-left: 40px; } + .nav-depth-3 > .nav-file-link { padding: 4px 24px 4px 84px; } + .nav-depth-3 > .nav-folder > summary { padding-left: 56px; } /* ── Content ── */ .content { margin-left: 264px; - padding: 48px 72px 96px; - max-width: 1080px; + padding: 32px 40px 96px; min-height: 100vh; + } + .content-card { + max-width: 860px; background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 2.5rem 2.75rem; + font-size: 0.94rem; + line-height: 1.75; + color: #1e293b; } /* ── Typography ── */ - .content a { color: #0000ee; text-decoration: none; transition: color 0.16s; } - .content a:hover { color: #ff5577; } - .content a:visited { color: #551a8b; } - .content a:visited:hover { color: #ff5577; } + .content-card a { color: #4f46e5; text-decoration: none; border-bottom: 1px solid #c7d2fe; transition: color 0.15s, border-color 0.15s; } + .content-card a:hover { color: #3730a3; border-color: #4f46e5; } + .content-card a:visited { color: #6d28d9; } + .content-card a:visited:hover { color: #3730a3; } - .content h1, .content h2, .content h3, - .content h4, .content h5, .content h6 { - color: #222; - margin-top: 48px; - margin-bottom: 12px; - line-height: 1.3; - } - .content h1 { font-size: 2em; margin-top: 0; } - .content h2 { font-size: 1.5em; } - .content h3 { font-size: 1.25em; } + .content-card h1 { font-size: 1.6rem; font-weight: 800; color: #0f172a; margin: 0 0 1.5rem; padding-bottom: 0.75rem; border-bottom: 2px solid #e2e8f0; line-height: 1.3; } + .content-card h2 { font-size: 1.1rem; font-weight: 700; color: #0f172a; margin: 2.5rem 0 0.9rem; padding-bottom: 0.35rem; border-bottom: 1px solid #f1f5f9; line-height: 1.3; } + .content-card h3 { font-size: 0.97rem; font-weight: 700; color: #1e293b; margin: 1.75rem 0 0.55rem; line-height: 1.3; } + .content-card h4, .content-card h5, .content-card h6 { font-size: 0.87rem; font-weight: 700; color: #334155; margin: 1.2rem 0 0.4rem; text-transform: uppercase; letter-spacing: 0.04em; line-height: 1.3; } - .content hr { margin: 72px 0; border: none; border-top: 1px solid rgba(34,34,34,0.15); } + .content-card hr { border: none; border-top: 1px solid #e2e8f0; margin: 2rem 0; } + .content-card p { margin: 0 0 1rem; } - .content p { color: #222; font-size: 1em; line-height: 1.64em; margin: 0 0 1em; } - - .content code { + .content-card :not(pre) > code { font-family: 'Roboto Mono', 'SFMono-Regular', Consolas, monospace; - font-size: 85%; - font-weight: 700; - background: #f4f4f7; - padding: 0.15em 0.4em; + font-size: 0.84em; + background: #f1f5f9; + color: #4f46e5; + border: 1px solid #e2e8f0; border-radius: 4px; - line-height: 1.4; + padding: 0.1em 0.4em; } - .content pre { background: #f4f4f7; padding: 20px 24px; border-radius: 6px; overflow-x: auto; margin: 16px 0; } - .content pre code { background: none; padding: 0; font-weight: 400; font-size: 13px; } - - .content blockquote { - border-left: 3px solid rgba(34,34,34,0.25); - margin: 24px 0; - padding: 4px 24px; - color: rgba(34,34,34,0.65); + .content-card pre { + background: #0f172a; + border-radius: 10px; + margin: 1.2rem 0; + overflow-x: auto; + border: 1px solid #1e293b; + } + .content-card pre code { + display: block; + padding: 1.1rem 1.25rem; + background: none; + color: #e2e8f0; + border: none; + font-family: 'Roboto Mono', 'SFMono-Regular', Consolas, monospace; + font-size: 0.82rem; + line-height: 1.6; + font-weight: 400; } - .content img { max-width: 100%; margin: 8px 0; } + .content-card blockquote { + margin: 1.2rem 0; + padding: 0.8rem 1.1rem; + border-radius: 0 8px 8px 0; + border-left: 4px solid #818cf8; + background: #f5f3ff; + color: #4338ca; + font-size: 0.9rem; + } - .content table { + .content-card img { max-width: 100%; margin: 8px 0; border-radius: 8px; } + + .content-card table { width: 100%; - border-spacing: 0; border-collapse: collapse; - margin: 24px 0 48px; - font-size: 14px; - color: #222; + font-size: 0.875rem; + margin: 1.2rem 0; + border-radius: 8px; + overflow: hidden; + border: 1px solid #cbd5e1; } - .content table th { - border-bottom: 1px solid #ff5577; - padding: 10px; + .content-card thead { background: #e2e8f0; } + .content-card th { + padding: 0.6rem 1rem; + font-weight: 700; + font-size: 0.77rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #334155; + border-bottom: 2px solid #94a3b8; + border-right: 1px solid #cbd5e1; text-align: left; - font-weight: 600; } - .content table td { - border-bottom: 1px solid rgba(255,85,119,0.25); - padding: 10px; + .content-card th:last-child { border-right: none; } + .content-card td { + padding: 0.6rem 1rem; + border-bottom: 1px solid #e2e8f0; + border-right: 1px solid #e2e8f0; + color: #374151; + vertical-align: top; } + .content-card td:last-child { border-right: none; } + .content-card tbody tr:last-child td { border-bottom: none; } + .content-card tbody tr:nth-child(even) { background: #f8fafc; } + .content-card tbody tr:hover { background: #eff6ff; } - .content ul li, .content ol li { - color: #222; - font-size: 1em; - line-height: 1.64em; - margin-bottom: 8px; - } - .content ul li code, .content ol li code { font-weight: 700; } + .content-card ul, .content-card ol { padding-left: 1.5rem; margin: 0 0 1rem; } + .content-card li { margin-bottom: 0.3rem; } + .content-card li code { font-weight: 700; } + .content-card strong { font-weight: 700; color: #0f172a; } /* ── Responsive ── */ @media screen and (max-width: 1020px) { - .sidebar { - left: -300px; - transition: left 0.2s cubic-bezier(0.09, 0.46, 0.45, 0.94); - } - .content { margin-left: 0; padding: 32px 24px 64px; } + .sidebar { left: -300px; transition: left 0.2s cubic-bezier(0.09, 0.46, 0.45, 0.94); } + .content { margin-left: 0; padding: 24px 16px 64px; } } @media screen and (max-width: 540px) { - .content { padding: 24px 16px 48px; } + .content-card { padding: 1.25rem; border-radius: 8px; } }`; const SSE_SCRIPT = ` `; // ───────────────────────────────────────────────────────────────────────────── @@ -233,68 +328,95 @@ function escapeHtml(str) { .replace(/"/g, '"'); } -/** Build the sidebar HTML from the list of all pages (Shell theme structure). */ +/** Build a nested tree object from a flat list of relative paths. */ +function buildTree(relPaths) { + const tree = { _files: [], _dirs: {} }; + relPaths.forEach((rel) => { + const parts = rel.split(path.sep); + let node = tree; + for (let i = 0; i < parts.length - 1; i++) { + const dir = parts[i]; + if (!node._dirs[dir]) node._dirs[dir] = { _files: [], _dirs: {} }; + node = node._dirs[dir]; + } + node._files.push(rel); + }); + return tree; +} + +/** Render a tree node as HTML list items (recursive). */ +function renderTree(node, baseDir, currentRel, depth) { + let html = ''; + const depthClass = depth > 0 ? ` class="nav-depth-${Math.min(depth, 3)}"` : ''; + + // Files first (sorted) + node._files.slice().sort().forEach((rel) => { + const pagePath = rel.replace(/\.md$/i, ''); + const label = path.basename(pagePath).replace(/-/g, ' '); + const href = '/' + pagePath.split('/').map(encodeURIComponent).join('/'); + const isActive = rel === currentRel; + const cls = isActive ? ' active' : ''; + if (depth === 0) { + html += `\n
  • ${escapeHtml(label)}
  • `; + } else { + html += `\n${escapeHtml(label)}`; + } + }); + + // Sub-directories (sorted) + Object.keys(node._dirs).sort().forEach((dirName) => { + const child = node._dirs[dirName]; + const isOpen = currentRel && currentRel.split(path.sep).includes(dirName); + const openAttr = isOpen ? ' open' : ''; + + // Look for a home.md in this sub-directory to link the folder name to + const homeFile = child._files.find((f) => /^home\.md$/i.test(path.basename(f))); + let folderLabel; + if (homeFile) { + const pagePath = homeFile.replace(/\.md$/i, ''); + const href = '/' + pagePath.split('/').map(encodeURIComponent).join('/'); + const isActive = homeFile === currentRel; + const cls = isActive ? ' active' : ''; + folderLabel = `${escapeHtml(dirName)}`; + } else { + folderLabel = `${escapeHtml(dirName)}`; + } + + // Arrow is the only toggle; preventDefault cancels the browser's native
    activation, + // stopPropagation prevents it from bubbling further, then we toggle manually. + const arrow = ``; + + // Render child files, excluding home.md (already used as folder link) + const childNode = { + _files: homeFile ? child._files.filter((f) => f !== homeFile) : child._files, + _dirs: child._dirs, + }; + + html += `\n`; + }); + + return html; +} + +/** Build the sidebar HTML from the list of all pages. */ function buildSidebar(baseDir, files, currentFile) { const currentRel = currentFile ? path.relative(baseDir, currentFile) : null; - - // Group by first-level subdirectory - const groups = new Map(); // groupName → [rel, ...] - files.forEach((f) => { - const rel = path.relative(baseDir, f); - const parts = rel.split(path.sep); - const group = parts.length > 1 ? parts[0] : ''; - if (!groups.has(group)) groups.set(group, []); - groups.get(group).push(rel); - }); + const relPaths = files.map((f) => path.relative(baseDir, f)); + const tree = buildTree(relPaths); const dirName = path.basename(baseDir); let html = ` `; @@ -309,8 +431,9 @@ function htmlShell({ title, sidebar, body, watchMode }) { const hasSidebar = !!sidebar; const noSidebarStyle = hasSidebar ? '' : ` `; return ` @@ -318,15 +441,18 @@ function htmlShell({ title, sidebar, body, watchMode }) { ${escapeHtml(title)} + - - + + ${noSidebarStyle} ${hasSidebar ? sidebar : ''}
    +
    ${body} +
    ${watchMode ? SSE_SCRIPT : ''} `; diff --git a/lib/server.js b/lib/server.js index 8fdecf8..0492447 100644 --- a/lib/server.js +++ b/lib/server.js @@ -42,16 +42,33 @@ function notifyClients(clients) { return clients.filter((c) => !dead.includes(c) && !c.writableEnded); } -function startServer({ inputFile, inputDir, port }) { +function startServer({ inputFile, inputDir, port, onReady }) { let clients = []; const isDir = !!inputDir; const baseDir = isDir ? path.resolve(inputDir) : path.dirname(path.resolve(inputFile)); + // --- cache --- + // pageCache: filePath → rendered HTML string + // fileListCache: cached list of all .md files (null = stale) + const pageCache = new Map(); + let fileListCache = null; + + function getCachedFiles() { + if (!fileListCache) fileListCache = findMarkdownFiles(baseDir); + return fileListCache; + } + // --- rebuild --- function rebuild(changedFile) { if (changedFile) { const rel = path.relative(baseDir, changedFile); console.log(` ↺ ${rel} — rechargement...`); + // Only this page's HTML is stale; other pages' sidebars are still valid + pageCache.delete(changedFile); + } else { + // File added or removed: file list changed → all caches stale + pageCache.clear(); + fileListCache = null; } clients = notifyClients(clients); } @@ -67,7 +84,9 @@ function startServer({ inputFile, inputDir, port }) { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', }); + if (req.socket) req.socket.setNoDelay(true); res.write(': keep-alive\n\n'); clients.push(res); @@ -86,8 +105,11 @@ function startServer({ inputFile, inputDir, port }) { // ── SINGLE FILE MODE ────────────────────────────────────────────────────── if (!isDir) { if (pathname === '/' || pathname === '/index.html') { + if (!pageCache.has(inputFile)) { + pageCache.set(inputFile, convert(inputFile, true)); + } res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(convert(inputFile, true)); + res.end(pageCache.get(inputFile)); return; } return serveStatic(req, res, pathname, baseDir, port); @@ -97,7 +119,7 @@ function startServer({ inputFile, inputDir, port }) { // Index page if (pathname === '/') { - const files = findMarkdownFiles(baseDir); + const files = getCachedFiles(); // Redirect to Home.md if it exists const homePage = files.find((f) => /^home\.md$/i.test(path.basename(f)) && path.dirname(f) === baseDir @@ -108,8 +130,12 @@ function startServer({ inputFile, inputDir, port }) { res.end(); return; } + const indexKey = '__index__'; + if (!pageCache.has(indexKey)) { + pageCache.set(indexKey, buildIndex(baseDir, files, true)); + } res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(buildIndex(baseDir, files, true)); + res.end(pageCache.get(indexKey)); return; } @@ -127,10 +153,12 @@ function startServer({ inputFile, inputDir, port }) { if (!candidate.startsWith(baseDir + path.sep) && candidate !== baseDir) continue; if (fs.existsSync(candidate) && /\.md$/i.test(candidate)) { try { - const files = findMarkdownFiles(baseDir); - const html = convert(candidate, true, { baseDir, files }); + if (!pageCache.has(candidate)) { + const files = getCachedFiles(); + pageCache.set(candidate, convert(candidate, true, { baseDir, files })); + } res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(html); + res.end(pageCache.get(candidate)); } catch (err) { res.writeHead(500); res.end(`Erreur de conversion : ${err.message}`); @@ -143,12 +171,41 @@ function startServer({ inputFile, inputDir, port }) { serveStatic(req, res, pathname, baseDir, port); }); + function warmCache() { + const files = getCachedFiles(); + let count = 0; + if (isDir) { + for (const file of files) { + if (!pageCache.has(file)) { + try { + pageCache.set(file, convert(file, true, { baseDir, files })); + count++; + } catch { /* ignore errors on warm-up */ } + } + } + } else { + if (!pageCache.has(inputFile)) { + try { + pageCache.set(inputFile, convert(inputFile, true)); + count++; + } catch { /* ignore */ } + } + } + if (count > 0) console.log(`🔥 Cache préchauffé : ${count} page(s) prête(s)\n`); + } + server.listen(port, () => { console.log(`\n🚀 Serveur démarré : http://localhost:${port}`); if (isDir) { console.log(`📂 Dossier : ${baseDir}`); } - console.log('👁 Surveillance des modifications... (Ctrl+C pour arrêter)\n'); + if (onReady) { + onReady(port); + } else { + console.log('👁 Surveillance des modifications... (Ctrl+C pour arrêter)'); + } + // Pre-render all pages so first visits are instant + setImmediate(warmCache); }); server.on('error', (err) => { diff --git a/lib/watcher.js b/lib/watcher.js index 7e9f7a8..8854f7a 100644 --- a/lib/watcher.js +++ b/lib/watcher.js @@ -9,9 +9,10 @@ function startWatcher({ inputFile, inputDir, server }) { const watcher = chokidar.watch(watchTarget, { persistent: true, ignoreInitial: true, - ignored: /(^|[/\\])\../, // ignore hidden files + ignored: /(^|[/\\])\.\./, // ignore hidden files usePolling: true, - interval: 500, + interval: 300, + awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 }, }); let debounceTimer = null; @@ -33,7 +34,7 @@ function startWatcher({ inputFile, inputDir, server }) { watcher.on('add', (filePath) => { if (!isDir || isMd(filePath)) { console.log(` + Nouveau fichier : ${path.basename(filePath)}`); - schedule(filePath); + schedule(null); // file list changed → full cache invalidation } }); watcher.on('unlink', (filePath) => {