mirror of
https://github.com/hunvreus/basecoat.git
synced 2026-11-11 04:38:18 +00:00
Prepare 1.0 docs and style migration
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user