Prepare 1.0 docs and style migration

This commit is contained in:
Ronan Berder
2026-06-17 11:19:31 +08:00
parent 815e3ffffe
commit 1d33827162
183 changed files with 8805 additions and 13225 deletions
+2
View File
@@ -7,10 +7,12 @@ const excludedComponents = new Set(['form']);
// Keep cascade stable for components whose selectors intentionally compose.
const componentOrder = [
'alert',
'avatar',
'badge',
'button',
'button-group',
'card',
'chart',
'collapsible',
'command',
'combobox',
-195
View File
@@ -1,195 +0,0 @@
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('<svg', value === true ? `<svg ${name}` : `<svg ${name}="${escaped}"`);
}
return out;
}
class LucideExtension {
tags = ['lucide'];
parse(parser, nodes) {
const token = parser.nextToken();
const args = parser.parseSignature(null, true);
parser.advanceAfterBlockEnd(token.value);
return new nodes.CallExtension(this, 'run', args);
}
run(_context, iconName, attrs = {}) {
const svg = lucideIcons[pascalIconName(iconName)] || '';
return new Nunjucks.runtime.SafeString(addAttrs(svg, attrs));
}
}
function renderTemplate(env, source, context) {
return new Promise((resolve, reject) => {
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('<pre class="scrollbar">', '<pre class="scrollbar" data-copy-code-initialized="true">');
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 = /<h1[\s>]/.test(rendered);
const heading = hasOwnHeading ? '' : `# ${title}`;
const description = !hasOwnHeading && data.description ? `\n\n<p class="text-muted-foreground text-[1.05rem] sm:text-base">${escapeHtml(data.description)}</p>` : '';
await fs.writeFile(outPath, `${heading}${description}\n\n${rendered.trim()}\n\n<div id="toaster" class="toaster"></div>\n<script src="/assets/js/htmx.min.js" defer></script>\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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
}
await main();
-28
View File
@@ -1,28 +0,0 @@
import { cp, mkdir, rm } from 'node:fs/promises';
const fontFiles = [
'geist-sans-latin-400-normal.woff2',
'geist-sans-latin-500-normal.woff2',
'geist-sans-latin-600-normal.woff2',
'geist-sans-latin-700-normal.woff2',
'geist-mono-latin-400-normal.woff2',
'geist-mono-latin-500-normal.woff2',
'geist-mono-latin-600-normal.woff2',
'geist-mono-latin-700-normal.woff2',
];
await rm('./public/assets', { recursive: true, force: true });
await mkdir('./public/assets', { recursive: true });
await cp('./docs/src/assets', './public/assets', {
recursive: true,
filter: (source) => !source.endsWith('.DS_Store') && !/styles(?:-[a-z]+)?\.css$/.test(source),
});
await mkdir('./public/assets/js', { recursive: true });
await cp('./node_modules/htmx.org/dist/htmx.min.js', './public/assets/js/htmx.min.js');
await mkdir('./public/assets/fonts/geist', { recursive: true });
for (const file of fontFiles) {
const packageName = file.startsWith('geist-sans') ? 'geist-sans' : 'geist-mono';
await cp(`./node_modules/@fontsource/${packageName}/files/${file}`, `./public/assets/fonts/geist/${file}`);
}