|
| 1 | +import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; |
| 2 | +import { dirname, join, relative, resolve } from "node:path"; |
| 3 | +import { fileURLToPath } from "node:url"; |
| 4 | +import { marked } from "marked"; |
| 5 | +import { createHighlighter } from "shiki"; |
| 6 | + |
| 7 | +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); |
| 8 | +const docsRoot = join(root, "src", "content", "docs"); |
| 9 | +const generatedRoot = join(root, "src", "generated"); |
| 10 | +const contentRoot = join(generatedRoot, "docs-content"); |
| 11 | +const indexFile = join(generatedRoot, "docs-index.json"); |
| 12 | +const languages = ["cpp", "c", "bash", "cmake", "json", "yaml", "javascript", "typescript", "html", "css", "markdown", "powershell", "toml", "ini", "sql", "xml", "nginx", "dockerfile", "makefile", "python", "vue", "http"]; |
| 13 | +const highlighter = await createHighlighter({ themes: ["github-dark"], langs: languages }); |
| 14 | +const loadedLanguages = new Set(highlighter.getLoadedLanguages()); |
| 15 | +const aliases = { cc: "cpp", cxx: "cpp", sh: "bash", shell: "bash", console: "bash", js: "javascript", ts: "typescript", yml: "yaml", md: "markdown", text: "text", plaintext: "text", txt: "text", dotenv: "ini", properties: "ini" }; |
| 16 | + |
| 17 | +function escapeHtml(value) { |
| 18 | + return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); |
| 19 | +} |
| 20 | + |
| 21 | +function walk(directory) { |
| 22 | + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { |
| 23 | + const file = join(directory, entry.name); |
| 24 | + return entry.isDirectory() ? walk(file) : entry.name.endsWith(".md") ? [file] : []; |
| 25 | + }); |
| 26 | +} |
| 27 | + |
| 28 | +function pathFor(file) { |
| 29 | + const path = relative(docsRoot, file).replaceAll("\\", "/").replace(/\.md$/, ""); |
| 30 | + return path === "index" ? "" : path.replace(/\/index$/, ""); |
| 31 | +} |
| 32 | + |
| 33 | +function titleFor(source, path) { |
| 34 | + return source.match(/^title:\s*["']?(.+?)["']?\s*$/m)?.[1] || source.match(/^#\s+(.+)$/m)?.[1]?.replace(/[`*_]/g, "") || path.split("/").at(-1).replace(/[-_]/g, " ") || "Vix.cpp Documentation"; |
| 35 | +} |
| 36 | + |
| 37 | +function sectionTitle(path) { |
| 38 | + const known = { api: "API", cli: "CLI", sdks: "SDKs", "app-modules": "Application Modules" }; |
| 39 | + return known[path] || path.split("/").at(-1).replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase()); |
| 40 | +} |
| 41 | + |
| 42 | +function resolveDocPath(href, currentPath, paths) { |
| 43 | + const [target, hash = ""] = href.split("#"); |
| 44 | + if (!target || target.startsWith("mailto:") || /^(https?:)?\/\//.test(target)) return href; |
| 45 | + if (target === "/vixcpp-documentation.pdf" || target === "/vixcpp-documentation-fr.pdf") return `/docs${target}`; |
| 46 | + let candidate; |
| 47 | + if (target.startsWith("/")) candidate = target.replace(/^\/+|\/+$/g, ""); |
| 48 | + else { |
| 49 | + const base = currentPath.split("/").slice(0, -1); |
| 50 | + candidate = [...base, ...target.split("/")].reduce((parts, part) => { |
| 51 | + if (!part || part === ".") return parts; |
| 52 | + if (part === "..") { parts.pop(); return parts; } |
| 53 | + parts.push(part.replace(/\.md$/, "")); |
| 54 | + return parts; |
| 55 | + }, []).join("/").replace(/\/index$/, ""); |
| 56 | + } |
| 57 | + candidate = candidate.replace(/\.md$/, "").replace(/\/index$/, ""); |
| 58 | + return paths.has(candidate) ? `/docs${candidate ? `/${candidate}` : ""}${hash ? `#${hash}` : ""}` : href; |
| 59 | +} |
| 60 | + |
| 61 | +function normalizeAdmonitions(source) { |
| 62 | + return source.replace(/^:::\s*(warning|tip|danger|info)(?:\s+(.+))?\n([\s\S]*?)^:::\s*$/gm, (_, kind, title, body) => |
| 63 | + `<aside class="docs-callout docs-callout--${kind}"><strong>${title || kind}</strong>\n\n${body.trim()}</aside>`); |
| 64 | +} |
| 65 | + |
| 66 | +const files = walk(docsRoot); |
| 67 | +const records = files.map((file) => ({ file, path: pathFor(file), source: readFileSync(file, "utf8") })); |
| 68 | +const folders = new Set(); |
| 69 | +for (const record of records) { |
| 70 | + const parts = record.path.split("/").filter(Boolean); |
| 71 | + for (let length = 1; length < parts.length; length += 1) folders.add(parts.slice(0, length).join("/")); |
| 72 | +} |
| 73 | +for (const folder of folders) { |
| 74 | + if (!records.some((record) => record.path === folder)) records.push({ path: folder, virtual: true, title: sectionTitle(folder) }); |
| 75 | +} |
| 76 | +const paths = new Set(records.map((record) => record.path)); |
| 77 | +const renderer = new marked.Renderer(); |
| 78 | +renderer.code = ({ text, lang = "" }) => { |
| 79 | + const language = aliases[lang.trim().toLowerCase().split(/\s+/)[0]] || lang.trim().toLowerCase().split(/\s+/)[0]; |
| 80 | + if (!language || language === "text" || !loadedLanguages.has(language)) return `<pre class="docs-code docs-code--plain"><code>${escapeHtml(text)}</code></pre>`; |
| 81 | + return highlighter.codeToHtml(text, { lang: language, theme: "github-dark" }).replace('<pre class="shiki', '<pre class="docs-code shiki'); |
| 82 | +}; |
| 83 | +renderer.link = ({ href, title, text }) => `<a href="${escapeHtml(resolveDocPath(href, renderer.currentPath, paths))}"${title ? ` title="${escapeHtml(title)}"` : ""}>${text}</a>`; |
| 84 | +marked.use({ renderer }); |
| 85 | + |
| 86 | +rmSync(contentRoot, { recursive: true, force: true }); |
| 87 | +mkdirSync(contentRoot, { recursive: true }); |
| 88 | +const pages = records.map((record) => { |
| 89 | + renderer.currentPath = record.path; |
| 90 | + const children = records |
| 91 | + .filter((candidate) => candidate.path && candidate.path.startsWith(record.path ? `${record.path}/` : "") && candidate.path.split("/").length === (record.path ? record.path.split("/").length + 1 : 1)) |
| 92 | + .sort((a, b) => a.path.localeCompare(b.path)); |
| 93 | + const page = { path: record.path, title: record.path ? (record.title || titleFor(record.source, record.path)) : "Vix.cpp Documentation" }; |
| 94 | + const html = record.virtual || !record.path |
| 95 | + ? `<h1>${page.title}</h1><p>Browse the Vix.cpp documentation.</p><ul>${children.map((child) => `<li><a href="/docs/${child.path}">${child.title || titleFor(child.source, child.path)}</a></li>`).join("")}</ul>` |
| 96 | + : marked.parse(normalizeAdmonitions(record.source), { async: false }); |
| 97 | + const output = join(contentRoot, `${record.path || "index"}.json`); |
| 98 | + mkdirSync(dirname(output), { recursive: true }); |
| 99 | + writeFileSync(output, `${JSON.stringify({ html })}\n`); |
| 100 | + return page; |
| 101 | +}).sort((a, b) => a.path.localeCompare(b.path)); |
| 102 | +mkdirSync(generatedRoot, { recursive: true }); |
| 103 | +writeFileSync(indexFile, `${JSON.stringify(pages, null, 2)}\n`); |
| 104 | +console.log(`Generated ${pages.length} documentation pages with build-time syntax highlighting.`); |
0 commit comments