import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { site } from '../site'; const docsRoute = '/docs'; const repository = join(import.meta.dirname, '..', '..'); const content = join(import.meta.dirname, '..', 'content', 'docs'); const assets = join(import.meta.dirname, '..', 'public'); const app = join(import.meta.dirname, '..', 'app'); const slugFor: Record = { 'Q&A': 'questions' }; const slugOf = (title: string) => slugFor[title] ?? title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, ''); const cleanTitle = (heading: string) => heading.replace(/^#+ /, '').replace(/`/g, ''); const githubAnchor = (title: string) => title .toLowerCase() .replace(/[^a-z0-9 _-]/g, '') .trim() .replace(/ +/g, '-'); function asFileTree(block: string) { const rows = block .split('\n') .filter((line) => line.trim()) .map((line) => { const connector = line.search(/[├└]── /); const depth = connector < 0 ? 0 : line.slice(0, connector).length / 4 + 1; const name = connector < 0 ? line.trim() : line.slice(connector + 4).trim(); return { depth, name }; }); const render = (start: number, depth: number): [string, number] => { let index = start; let out = ''; while (index < rows.length && rows[index].depth === depth) { const { name } = rows[index]; const pad = '\t'.repeat(depth + 1); index += 1; if (!name.endsWith('/')) { out += `${pad}\n`; continue; } const [children, next] = render(index, depth + 1); index = next; out += `${pad}\n${children}${pad}\n`; } return [out, index]; }; return `\n${render(0, 0)[0]}`; } function asCallout(kind: string, block: string) { const types: Record = { NOTE: 'info', TIP: 'idea', IMPORTANT: 'info', WARNING: 'warn', CAUTION: 'error' }; const body = block .split('\n') .map((line) => line.replace(/^> ?/, '')) .join('\n') .trim(); return `\n${body}\n\n`; } function asMdx(markdown: string, shift: number) { let inFence = false; const lines = markdown.split('\n').map((line) => { if (line.startsWith('```')) { inFence = !inFence; return line; } if (inFence) return line; const heading = /^(#{1,6}) /.exec(line); if (!heading) return line.replace(/\{/g, '\\{').replace(/ (/^<\/?[A-Za-z]/.test(line.slice(offset)) ? match : '<')); return '#'.repeat(Math.max(2, heading[1].length - shift)) + line.slice(heading[1].length); }); return lines .join('\n') .replace(/```tree\n([\s\S]*?)```/g, (_, block: string) => asFileTree(block)) .replace(/```mermaid\n([\s\S]*?)```/g, (_, chart: string) => ``) .replace(/^> \[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\n((?:>.*\n?)*)/gm, (_, kind: string, block: string) => asCallout(kind, block)) .replace(/<(https?:\/\/[^>\s]+)>/g, '[$1]($1)'); } type Frontmatter = { title: string; description?: string; icon?: string; lead?: string }; const frontmatter = ({ title, description, icon, lead }: Frontmatter) => [ '---', `title: ${JSON.stringify(title)}`, ...(description ? [`description: ${JSON.stringify(description)}`] : []), ...(lead ? [`lead: ${JSON.stringify(lead)}`] : []), ...(icon ? [`icon: ${icon}`] : []), '---', '', ].join('\n'); const plainText = (markdown: string) => markdown .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') .replace(/<(https?:\/\/[^>\s]+)>/g, '$1') .replace(/[`*_]/g, '') .replace(/\s+/g, ' ') .trim(); function firstParagraph(markdown: string) { let inFence = false; const paragraph: string[] = []; for (const line of markdown.split('\n')) { if (line.startsWith('```')) { inFence = !inFence; continue; } if (inFence) continue; const isProse = line.trim() && !/^(#|>|\||-|\d+\.|<)/.test(line); if (isProse) paragraph.push(line); else if (paragraph.length) break; } const text = plainText(paragraph.join(' ')).replace(/:$/, '.'); if (text.length <= 160) return text; return text.slice(0, text.lastIndexOf(' ', 157)) + '…'; } const written: string[] = []; const titles = new Map(); function takeLead(markdown: string) { const end = markdown.indexOf('\n\n'); if (end < 0) return { lead: plainText(markdown), rest: '' }; return { lead: plainText(markdown.slice(0, end)), rest: markdown.slice(end).trim() }; } function writePage(path: string, title: string, text: string, options: { description?: string; shift?: number; lead?: boolean } = {}) { const { description, shift = 1, lead } = options; mkdirSync(join(content, path, '..'), { recursive: true }); const page = asMdx(text, shift).trim(); const { lead: subtitle, rest: body } = lead ? takeLead(page) : { lead: undefined, rest: page }; writeFileSync( join(content, `${path}.mdx`), frontmatter({ title, description: description ?? subtitle ?? firstParagraph(body), icon: site.icons[path], lead: subtitle }) + body + '\n' ); written.push(path); titles.set(path, title); } function linkAcrossPages() { const pageOf = new Map(); for (const path of written) { pageOf.set(path.split('/').pop()!, path); const anchor = githubAnchor(titles.get(path) ?? ''); if (anchor && !pageOf.has(anchor)) pageOf.set(anchor, path); } for (const path of written) { const file = join(content, `${path}.mdx`); const before = readFileSync(file, 'utf8'); const after = before.replace(/\]\(#([a-z0-9-]+)\)/g, (whole, anchor: string) => { const target = pageOf.get(anchor); if (!target || target === path) return whole; return `](${docsRoute}/${target === 'index' ? '' : target})`; }); if (after !== before) writeFileSync(file, after); } } function splitOn(markdown: string, level: number) { const marker = '#'.repeat(level) + ' '; const parts: string[][] = [[]]; let inFence = false; for (const line of markdown.split('\n')) { if (line.startsWith('```')) inFence = !inFence; if (!inFence && line.startsWith(marker)) parts.push([]); parts[parts.length - 1].push(line); } const [intro, ...chunks] = parts.map((lines) => lines.join('\n')); return { intro, chunks: chunks.map((chunk) => { const [heading, ...rest] = chunk.split('\n'); return { title: cleanTitle(heading), text: rest.join('\n') }; }), }; } function writeGroup(slug: string, title: string, text: string) { const { intro, chunks: pages } = splitOn(text, 2); mkdirSync(join(content, slug), { recursive: true }); const rows = pages.map((page) => { const body = asMdx(page.text, 1).trim(); const lead = plainText(body.slice(0, Math.max(body.indexOf('\n\n'), 0) || body.length)); return `| [${page.title}](${docsRoute}/${slug}/${slugOf(page.title)}) | ${lead} |`; }); const table = ['| page | what it covers |', '| --- | --- |', ...rows].join('\n'); const opening = intro.trim() ? `${asMdx(intro, 1).trim()}\n\n` : ''; writeFileSync( join(content, slug, 'index.mdx'), frontmatter({ title, description: site.groupDescriptions[slug] ?? firstParagraph(intro), icon: site.icons[slug] }) + opening + table + '\n' ); written.push(join(slug, 'index')); titles.set(join(slug, 'index'), title); for (const page of pages) writePage(join(slug, slugOf(page.title)), page.title, page.text, { lead: true }); writeFileSync( join(content, slug, 'meta.json'), JSON.stringify({ title, ...(site.icons[slug] ? { icon: site.icons[slug] } : {}), pages: pages.map((page) => slugOf(page.title)) }, null, 2) + '\n' ); } function writeTheme() { writeFileSync( join(app, 'theme.css'), `:root {\n --color-fd-primary: ${site.color.light};\n --color-fd-primary-foreground: #ffffff;\n}\n.dark {\n --color-fd-primary: ${site.color.dark};\n --color-fd-primary-foreground: #0b1020;\n}\n` ); const logo = join(repository, 'assets', 'blueclaw.logo.svg'); copyFileSync(logo, join(assets, 'logo.svg')); copyFileSync(logo, join(assets, 'favicon.svg')); } function writeCrawlerFiles() { const urls = written.map((path) => `${site.origin}${docsRoute}${path === 'index' ? '' : '/' + path.replace(/\/index$/, '')}/`); const entries = urls.map((url) => ` ${url}`).join('\n'); writeFileSync(join(assets, 'sitemap.xml'), `\n\n${entries}\n\n`); writeFileSync(join(assets, 'robots.txt'), `User-agent: *\nAllow: /\n\nSitemap: ${site.origin}/sitemap.xml\n`); writeFileSync(join(assets, '_redirects'), `/ ${docsRoute} 302\n`); } rmSync(content, { recursive: true, force: true }); mkdirSync(content, { recursive: true }); const reference = readFileSync(join(repository, 'DOCS.md'), 'utf8'); const [overview, ...sections] = splitOn(reference, 1).chunks; writePage('index', overview.title, overview.text, { description: site.description }); const gettingStarted: string[] = ['index']; const lookup: string[] = []; const rest: string[] = []; for (const section of sections) { const slug = slugOf(section.title); const { intro, chunks } = splitOn(section.text, 2); if (chunks.length === 0) writePage(slug, section.title, intro, { description: site.groupDescriptions[slug] }); else writeGroup(slug, section.title, section.text); if (slug === 'index') continue; if (site.gettingStarted.includes(slug)) gettingStarted.push(slug); else if (chunks.length > 0) lookup.push(slug); else rest.push(slug); } const sidebar = ['---Getting started---', ...gettingStarted, '---Reference---', ...lookup, ...(rest.length ? ['---More---', ...rest] : [])]; linkAcrossPages(); writeFileSync(join(content, 'meta.json'), JSON.stringify({ title: site.name, pages: sidebar }, null, 2) + '\n'); writeFileSync(join(content, 'sources.json'), JSON.stringify(Object.fromEntries(sidebar.filter((entry) => !entry.startsWith('---')).map((slug) => [slug, 'DOCS.md'])), null, 2) + '\n'); writeTheme(); writeCrawlerFiles(); console.log(`wrote ${written.length} pages: ${sidebar.filter((entry) => !entry.startsWith('---')).join(', ')}`);