const fs = require('fs'); const path = require('path'); const { marked } = require('marked'); const { markedHighlight } = require('marked-highlight'); const hljs = require('highlight.js'); marked.use(markedHighlight({ langPrefix: 'hljs language-', highlight(code, lang) { const language = hljs.getLanguage(lang) ? lang : 'plaintext'; return hljs.highlight(code, { language }).value; }, })); // ───────────────────────────────────────────────────────────────────────────── // CSS — inspiré de https://github.com/PavelLaptev/markdown-theme-shell // ───────────────────────────────────────────────────────────────────────────── const STYLES = ` /* ── Reset ── */ *, *::before, *::after { box-sizing: border-box; } ::selection { background: #c7d2fe; } html, body { height: 100%; margin: 0; } body { font-family: 'Poppins', sans-serif; background: #f1f5f9; color: #1e293b; line-height: 1.7; -webkit-font-smoothing: antialiased; overflow-x: hidden; } /* ── Sidebar ── */ .sidebar { position: fixed; top: 0; left: 0; width: 264px; height: 100vh; background: #0f172a; display: flex; flex-direction: column; z-index: 99; overflow: hidden; border-right: 1px solid #1e293b; } .sidebar__title-block { 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__title { color: #fff; font-size: 20px; font-weight: 700; margin: 0; line-height: 1.3; } .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: 12px 0 48px; flex: 1; overflow-y: auto; } .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; } /* ── Tree nav ── */ .nav-tree { list-style: none; margin: 0; padding: 0; } .nav-tree li { margin: 0; } /* Root-level file links */ .nav-root-link { display: block; 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.15s, background 0.15s; } .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: 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-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-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-card hr { border: none; border-top: 1px solid #e2e8f0; margin: 2rem 0; } .content-card p { margin: 0 0 1rem; } .content-card :not(pre) > code { font-family: 'Roboto Mono', 'SFMono-Regular', Consolas, monospace; font-size: 0.84em; background: #f1f5f9; color: #4f46e5; border: 1px solid #e2e8f0; border-radius: 4px; padding: 0.1em 0.4em; } .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-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-card img { max-width: 100%; margin: 8px 0; border-radius: 8px; } .content-card table { width: 100%; border-collapse: collapse; font-size: 0.875rem; margin: 1.2rem 0; border-radius: 8px; overflow: hidden; border: 1px solid #cbd5e1; } .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; } .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-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: 24px 16px 64px; } } @media screen and (max-width: 540px) { .content-card { padding: 1.25rem; border-radius: 8px; } }`; const SSE_SCRIPT = ` `; // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } /** 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; const relPaths = files.map((f) => path.relative(baseDir, f)); const tree = buildTree(relPaths); const dirName = path.basename(baseDir); let html = ` `; return html; } // ───────────────────────────────────────────────────────────────────────────── // Shell // ───────────────────────────────────────────────────────────────────────────── function htmlShell({ title, sidebar, body, watchMode }) { const hasSidebar = !!sidebar; const noSidebarStyle = hasSidebar ? '' : ` `; return ` ${escapeHtml(title)} ${noSidebarStyle} ${hasSidebar ? sidebar : ''}
    ${body}
    ${watchMode ? SSE_SCRIPT : ''} `; } // ───────────────────────────────────────────────────────────────────────────── // Public API // ───────────────────────────────────────────────────────────────────────────── /** * Convert a single .md file to a full HTML page. * @param {string} inputFile Absolute path to the .md file * @param {boolean} watchMode Inject SSE live-reload script * @param {{ baseDir: string, files: string[] }} [nav] When set, render with sidebar */ function convert(inputFile, watchMode = false, nav = null) { const content = fs.readFileSync(inputFile, 'utf-8'); const body = marked.parse(content); const title = path.basename(inputFile, '.md'); const sidebar = nav ? buildSidebar(nav.baseDir, nav.files, inputFile) : null; return htmlShell({ title, sidebar, body, watchMode }); } /** * Build an index/home page listing all .md files. * Only used when no Home.md exists. */ function buildIndex(baseDir, files, watchMode = false) { const dirName = path.basename(baseDir); const sidebar = buildSidebar(baseDir, files, null); const groups = new Map(); 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 listHtml = [...groups.keys()].sort().map((group) => { const items = groups.get(group).sort().map((rel) => { const pagePath = rel.replace(/\.md$/i, ''); const label = path.basename(pagePath).replace(/-/g, ' '); const href = '/' + pagePath.split('/').map(encodeURIComponent).join('/'); return `
  • ${escapeHtml(label)}
  • `; }).join('\n'); if (group === '') return `
      \n${items}\n
    `; return `

    ${escapeHtml(group)}

    \n
      \n${items}\n
    `; }).join('\n'); const body = `

    📂 ${escapeHtml(dirName)}

    ${files.length} fichier${files.length > 1 ? 's' : ''} Markdown

    ${listHtml}`; return htmlShell({ title: dirName, sidebar, body, watchMode }); } module.exports = { convert, buildIndex };