import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import * as lucideIcons from 'lucide-static'; import Nunjucks from 'nunjucks'; import prettier from 'prettier'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const sourceDir = path.join(root, 'docs/src'); const outputDir = path.join(root, 'docs/generated'); const fragmentsDir = path.join(outputDir, 'fragments'); const pkg = JSON.parse(await fs.readFile(path.join(root, 'package.json'), 'utf8')); const pageFiles = [ 'introduction.md', 'installation.md', 'customization.md', 'cli.md', 'kitchen-sink.njk', ...(await collectFiles(path.join(sourceDir, 'components'))).map((file) => path.relative(sourceDir, file)), ]; function parseFrontMatter(source) { if (!source.startsWith('---')) return { data: {}, body: source }; const match = source.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/); if (!match) return { data: {}, body: source }; const data = {}; for (const line of match[1].split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const keyMatch = trimmed.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); if (!keyMatch) continue; let value = keyMatch[2].trim(); if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1); } data[keyMatch[1]] = value; } return { data, body: source.slice(match[0].length) }; } async function collectFiles(dir) { const entries = await fs.readdir(dir, { withFileTypes: true }); const files = []; for (const entry of entries) { const full = path.join(dir, entry.name); if (entry.isDirectory()) files.push(...(await collectFiles(full))); else if (entry.isFile() && /\.(md|njk)$/.test(entry.name)) files.push(full); } return files.sort(); } function pascalIconName(iconName) { const normalized = String(iconName || '').trim(); if (!normalized) return ''; if (normalized.includes('-')) { return normalized.split('-').map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(''); } return normalized.charAt(0).toUpperCase() + normalized.slice(1); } function addAttrs(svg, attrs = {}) { let out = String(svg || ''); for (const [name, value] of Object.entries(attrs || {})) { if (value === undefined || value === null || value === false) continue; const escaped = String(value).replaceAll('&', '&').replaceAll('"', '"'); if (name === 'class' && out.includes('class="')) out = out.replace('class="', `class="${escaped} `); else out = out.replace(' { env.renderString(source, context, (error, result) => { if (error) reject(error); else resolve(result); }); }); } function outputSlug(relativeFile) { const normalized = relativeFile.replaceAll(path.sep, '/').replace(/\.(md|njk)$/, ''); return normalized === 'introduction' ? 'index' : normalized; } function normalizeNavigationItem(item) { if (typeof item === 'string') return item === 'introduction' ? 'index' : item; if (item?.type === 'item') { if (item.slug) return { ...item, slug: item.slug === 'introduction' ? 'index' : item.slug }; if (item.url && item.url.startsWith('/')) { const slug = item.url.replace(/^\/+|\/+$/g, '') || 'index'; return { slug, icon: item.icon }; } return null; } if (item?.type === 'submenu') { return { ...item, items: (item.items || []).map(normalizeNavigationItem).filter(Boolean) }; } return item; } async function main() { await fs.rm(outputDir, { recursive: true, force: true }); await fs.mkdir(outputDir, { recursive: true }); const docsConfig = JSON.parse(await fs.readFile(path.join(sourceDir, '_data/docs.json'), 'utf8')); const menu = docsConfig.menu.map((group) => ({ ...group, items: (group.items || []).map(normalizeNavigationItem).filter(Boolean), })); await fs.writeFile(path.join(outputDir, 'docs.json'), `${JSON.stringify({ menu }, null, 2)}\n`); const env = new Nunjucks.Environment( new Nunjucks.FileSystemLoader([ path.join(root, 'src/nunjucks'), path.join(sourceDir, '_includes'), ]), { autoescape: true }, ); env.addExtension('LucideExtension', new LucideExtension()); env.addFilter('prettyHtml', async (code, callback) => { try { callback(null, await prettier.format(code, { parser: 'html', printWidth: 1000, singleAttributePerLine: false, bracketSameLine: true, htmlWhitespaceSensitivity: 'ignore', })); } catch (error) { callback(error, code); } }, true); const context = { pkg, site: { title: 'Basecoat', description: 'A components library built with Tailwind CSS that works with any web stack.', }, }; for (const relativeFile of pageFiles) { const absoluteFile = path.join(sourceDir, relativeFile); const source = await fs.readFile(absoluteFile, 'utf8'); const { data, body } = parseFrontMatter(source); const rendered = (await renderTemplate(env, body, context)).replaceAll('
', '
');
    const slug = outputSlug(relativeFile);
    const outPath = path.join(outputDir, `${slug}.md`);
    await fs.mkdir(path.dirname(outPath), { recursive: true });
    const title = data.title || titleFromSlug(slug);
    const hasOwnHeading = /]/.test(rendered);
    const heading = hasOwnHeading ? '' : `# ${title}`;
    const description = !hasOwnHeading && data.description ? `\n\n

${escapeHtml(data.description)}

` : ''; await fs.writeFile(outPath, `${heading}${description}\n\n${rendered.trim()}\n\n
\n\n`); } for (const type of ['success', 'error', 'info', 'warning']) { const source = await fs.readFile(path.join(sourceDir, 'fragments/toast', `${type}.njk`), 'utf8'); const rendered = await renderTemplate(env, source, context); const outPath = path.join(fragmentsDir, 'toast', `${type}.html`); await fs.mkdir(path.dirname(outPath), { recursive: true }); await fs.writeFile(outPath, rendered.trim()); } } function titleFromSlug(slug) { const last = slug.split('/').at(-1) || slug; return last.replaceAll('-', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()); } function escapeHtml(value) { return String(value || '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); } await main();