Add initial project structure with essential files

- Created .gitignore to exclude build artifacts and dependencies.
- Added index.html as the main entry point for the application.
- Included LICENSE file with Apache 2.0 terms.
- Initialized package.json and package-lock.json for project dependencies.
- Added pnpm-lock.yaml for package management.
- Created QUICKSTART.md for setup instructions.
- Added README.md and README.zh-CN.md for project documentation in English and Chinese.
This commit is contained in:
pftom
2026-04-28 12:25:59 +08:00
commit a98096a042
258 changed files with 67862 additions and 0 deletions
+619
View File
@@ -0,0 +1,619 @@
/**
* Build a showcase HTML page from a DESIGN.md so the user can see what each
* design system looks like *before* generating anything. We don't try to
* render a unique product mockup — we extract the palette, typography, and
* a couple of component conventions, then drop them into one fixed
* template. The full DESIGN.md is rendered below as prose for reference.
*
* Parsing is deliberately permissive: imported systems vary in section
* naming and bullet style, so we use loose regexes and fall back to sane
* defaults when a token isn't found.
*/
export function renderDesignSystemPreview(id, raw) {
const titleMatch = /^#\s+(.+?)\s*$/m.exec(raw);
const title = cleanTitle(titleMatch?.[1] ?? id);
const subtitle = extractSubtitle(raw);
const colors = extractColors(raw);
const fonts = extractFonts(raw);
const bg =
pickColor(colors, ['page background', 'background', 'canvas', 'paper', 'bg ', 'page bg'])
?? pickColor(colors, ['white'])
?? '#ffffff';
const fg =
pickColor(colors, ['heading', 'foreground', 'ink', 'fg', 'text', 'navy', 'graphite'])
?? '#111111';
// Accent: brand/primary names first, then fall back to the first color
// that doesn't look like a neutral white/black/grey so we always show
// something punchy in the showcase header.
const accent =
pickColor(colors, ['primary brand', 'brand primary', 'primary', 'brand', 'accent'])
?? firstNonNeutral(colors)
?? '#2f6feb';
const muted = pickColor(colors, ['muted', 'secondary', 'neutral', 'subtle', 'caption']) ?? '#777777';
const border = pickColor(colors, ['border', 'divider', 'rule', 'stroke']) ?? '#e5e5e5';
const surface =
pickColor(colors, ['surface', 'card', 'background-secondary', 'panel', 'elevated'])
?? '#ffffff';
const display = fonts.display
?? fonts.heading
?? "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
const body = fonts.body ?? display;
const mono = fonts.mono ?? "ui-monospace, 'JetBrains Mono', monospace";
const renderedMarkdown = renderMarkdownLite(raw);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(title)} — design system preview</title>
<style>
:root {
--bg: ${bg};
--fg: ${fg};
--accent: ${accent};
--muted: ${muted};
--border: ${border};
--surface: ${surface};
--display: ${display};
--body: ${body};
--mono: ${mono};
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--body);
line-height: 1.55;
font-size: 16px;
}
.wrap { max-width: 960px; margin: 0 auto; padding: 56px 32px 96px; }
.badge {
display: inline-block;
font-family: var(--mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 4px 10px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--muted);
margin-bottom: 24px;
}
h1 {
font-family: var(--display);
font-size: clamp(40px, 6vw, 72px);
line-height: 1.05;
letter-spacing: -0.02em;
margin: 0 0 16px;
}
.lede {
max-width: 60ch;
font-size: 18px;
color: var(--muted);
margin: 0 0 56px;
}
section { margin-bottom: 72px; }
.section-title {
font-family: var(--display);
font-size: 22px;
font-weight: 600;
margin: 0 0 16px;
letter-spacing: -0.01em;
}
.palette {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px;
}
.swatch {
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
background: var(--surface);
}
.swatch .chip {
height: 96px;
}
.swatch .meta {
padding: 10px 12px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.swatch .name { font-size: 13px; font-weight: 500; }
.swatch .hex { font-family: var(--mono); font-size: 11px; color: var(--muted); }
.typo-row {
display: grid;
grid-template-columns: 88px 1fr;
gap: 24px;
padding: 18px 0;
border-top: 1px solid var(--border);
}
.typo-row:first-child { border-top: none; padding-top: 0; }
.typo-row .label {
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
padding-top: 4px;
}
.typo-display { font-family: var(--display); font-size: 40px; line-height: 1.1; letter-spacing: -0.02em; }
.typo-body { font-family: var(--body); font-size: 16px; }
.typo-mono { font-family: var(--mono); font-size: 14px; color: var(--muted); }
.components {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 640px) { .components { grid-template-columns: 1fr; } }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 24px;
}
.card .eyebrow {
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--accent);
margin-bottom: 8px;
}
.card h3 {
font-family: var(--display);
font-size: 20px;
margin: 0 0 8px;
letter-spacing: -0.01em;
}
.card p { margin: 0; color: var(--muted); }
.btn-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
button {
font: inherit;
cursor: pointer;
border-radius: 8px;
padding: 10px 18px;
}
.btn-primary {
background: var(--accent);
color: ${pickReadableForeground(accent)};
border: 1px solid var(--accent);
}
.btn-secondary {
background: transparent;
color: var(--fg);
border: 1px solid var(--border);
}
.btn-link {
background: transparent;
border: none;
color: var(--accent);
padding: 10px 0;
font-weight: 500;
}
.prose {
border-top: 1px solid var(--border);
padding-top: 32px;
color: var(--fg);
}
.prose h1, .prose h2, .prose h3 { font-family: var(--display); letter-spacing: -0.01em; }
.prose h1 { font-size: 28px; margin-top: 0; }
.prose h2 { font-size: 20px; margin-top: 32px; }
.prose h3 { font-size: 16px; margin-top: 24px; }
.prose p, .prose ul, .prose ol { margin: 12px 0; }
.prose code { font-family: var(--mono); background: var(--surface); border: 1px solid var(--border); padding: 1px 5px; border-radius: 4px; font-size: 0.92em; }
.prose blockquote { margin: 16px 0; padding: 8px 16px; border-left: 3px solid var(--accent); color: var(--muted); }
.prose ul, .prose ol { padding-left: 22px; }
.prose pre { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; overflow: auto; font-family: var(--mono); font-size: 12.5px; line-height: 1.55; }
.prose pre code { background: transparent; border: none; padding: 0; font-size: inherit; }
.prose hr { border: none; border-top: 1px solid var(--border); margin: 28px 0; }
.prose a { color: var(--accent); text-decoration: none; border-bottom: 1px solid transparent; }
.prose a:hover { border-bottom-color: var(--accent); }
.prose img { max-width: 100%; height: auto; border-radius: 6px; }
.prose .table-wrap { overflow-x: auto; margin: 18px 0; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); }
.prose table { width: 100%; border-collapse: collapse; font-size: 13.5px; line-height: 1.5; }
.prose th, .prose td { padding: 9px 14px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--border); }
.prose th { background: var(--bg); font-weight: 600; font-size: 12px; letter-spacing: 0.02em; text-transform: uppercase; color: var(--muted); }
.prose tr:last-child td { border-bottom: none; }
.prose td code, .prose th code { white-space: nowrap; }
.prose td[align="right"], .prose th[align="right"] { text-align: right; }
.prose td[align="center"], .prose th[align="center"] { text-align: center; }
</style>
</head>
<body>
<main class="wrap">
<span class="badge">Design system preview · ${escapeHtml(id)}</span>
<h1>${escapeHtml(title)}</h1>
${subtitle ? `<p class="lede">${escapeHtml(subtitle)}</p>` : ''}
<section>
<h2 class="section-title">Palette</h2>
<div class="palette">
${colors
.slice(0, 12)
.map(
(c) => `<div class="swatch">
<div class="chip" style="background:${c.value};"></div>
<div class="meta">
<span class="name">${escapeHtml(c.name)}</span>
<span class="hex">${escapeHtml(c.value)}</span>
</div>
</div>`,
)
.join('')}
</div>
</section>
<section>
<h2 class="section-title">Typography</h2>
<div class="typo-row">
<span class="label">Display</span>
<div class="typo-display">The grid carries weight; the line carries pace.</div>
</div>
<div class="typo-row">
<span class="label">Body</span>
<div class="typo-body">Body copy reads at sixteen pixels with a 1.55 leading. Restraint and rhythm matter more than novelty — pick a stack that earns the page.</div>
</div>
<div class="typo-row">
<span class="label">Mono</span>
<div class="typo-mono">/* monospace · ${escapeHtml(mono.split(',')[0]?.replace(/['"]/g, '').trim() ?? 'mono')} */</div>
</div>
</section>
<section>
<h2 class="section-title">Components</h2>
<div class="components">
<div class="card">
<div class="eyebrow">Card</div>
<h3>Production-quality artifact</h3>
<p>Sample card showing how surfaces, borders, and accent text behave in this system.</p>
</div>
<div class="card">
<div class="eyebrow">Buttons</div>
<h3>Three weights, one accent</h3>
<div class="btn-row" style="margin-top: 12px;">
<button class="btn-primary">Primary</button>
<button class="btn-secondary">Secondary</button>
<button class="btn-link">Link →</button>
</div>
</div>
</div>
</section>
<section class="prose">
${renderedMarkdown}
</section>
</main>
</body>
</html>`;
}
function extractSubtitle(raw) {
const lines = raw.split(/\r?\n/);
const h1 = lines.findIndex((l) => /^#\s+/.test(l));
if (h1 === -1) return '';
const after = lines.slice(h1 + 1);
const nextHeading = after.findIndex((l) => /^#{1,6}\s+/.test(l));
const window = (nextHeading === -1 ? after : after.slice(0, nextHeading))
.join('\n')
.replace(/^>\s*Category:.*$/gim, '')
.replace(/^>\s*/gm, '')
.trim();
return window.split(/\n\n/)[0]?.slice(0, 240) ?? '';
}
function extractColors(raw) {
const colors = [];
const seen = new Set();
function push(name, value) {
const cleanName = name.replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim();
if (!cleanName || cleanName.length > 60) return;
const v = normalizeHex(value);
const key = `${cleanName.toLowerCase()}|${v}`;
if (seen.has(key)) return;
seen.add(key);
colors.push({ name: cleanName, value: v });
}
// Form A: "- **Background:** `#FAFAFA`" / "- Background: #FAFAFA"
const reA = /^[\s>*-]*\**\s*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\s*\**\s*[:]\s*`?(#[0-9a-fA-F]{3,8})/gm;
let m;
while ((m = reA.exec(raw)) !== null) push(m[1], m[2]);
// Form B: "**Stripe Purple** (`#533afd`)" — common in awesome-design-md.
// Token name is whatever's bolded; the hex follows in parens/backticks.
const reB = /\*\*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\*\*\s*\(?\s*`?(#[0-9a-fA-F]{3,8})/g;
while ((m = reB.exec(raw)) !== null) push(m[1], m[2]);
return colors;
}
function extractFonts(raw) {
const out = {};
// "- **Display / headings:** `'GT Sectra', ...`"
// We want the backticked stack OR the rest of the line.
const re = /^[\s>*-]*\**\s*([A-Za-z][A-Za-z /]{1,30}?)\s*\**\s*[:]\s*`?([^`\n]+?)`?$/gm;
let m;
while ((m = re.exec(raw)) !== null) {
const label = m[1].toLowerCase();
const value = m[2].trim().replace(/[*_`]+$/g, '').trim();
if (!/[a-zA-Z]/.test(value)) continue;
if (value.startsWith('#')) continue;
if (/display|heading|h1|title/.test(label) && !out.display) out.display = value;
else if (/body|text|paragraph|copy/.test(label) && !out.body) out.body = value;
else if (/mono|code/.test(label) && !out.mono) out.mono = value;
}
return out;
}
function pickColor(colors, hints) {
for (const hint of hints) {
const needle = hint.toLowerCase();
const found = colors.find((c) => c.name.toLowerCase().includes(needle));
if (found) return found.value;
}
return null;
}
function firstNonNeutral(colors) {
for (const c of colors) {
const v = c.value.replace('#', '').toLowerCase();
if (v.length !== 6) continue;
const r = parseInt(v.slice(0, 2), 16);
const g = parseInt(v.slice(2, 4), 16);
const b = parseInt(v.slice(4, 6), 16);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const sat = max === 0 ? 0 : (max - min) / max;
if (sat > 0.25) return c.value;
}
return null;
}
function pickReadableForeground(hex) {
const n = normalizeHex(hex);
if (n.length !== 7) return '#ffffff';
const r = parseInt(n.slice(1, 3), 16);
const g = parseInt(n.slice(3, 5), 16);
const b = parseInt(n.slice(5, 7), 16);
// Standard luminance check.
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum > 0.6 ? '#0a0a0a' : '#ffffff';
}
function normalizeHex(hex) {
let h = hex.toLowerCase();
if (h.length === 4) {
h = '#' + h.slice(1).split('').map((c) => c + c).join('');
}
return h;
}
function cleanTitle(raw) {
return String(raw).replace(/^Design System (Inspired by|for)\s+/i, '').trim();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '"' ? '&quot;' : '&#39;',
);
}
// Tiny markdown renderer — enough for our DESIGN.md prose: H1H4, paragraphs,
// bullet/ordered lists, blockquotes, fenced code, GFM pipe tables, horizontal
// rules, inline `code` / **bold** / *italic* / [link](url). Not a full markdown
// implementation but covers everything the DESIGN.md files actually use.
function renderMarkdownLite(src) {
const lines = src.split(/\r?\n/);
const out = [];
let inList = null;
let inBlockquote = false;
let inCode = false;
let i = 0;
function closeList() {
if (inList) {
out.push(`</${inList}>`);
inList = null;
}
}
function closeBlockquote() {
if (inBlockquote) {
out.push('</blockquote>');
inBlockquote = false;
}
}
while (i < lines.length) {
const raw = lines[i] ?? '';
const line = raw.trimEnd();
if (line.startsWith('```')) {
closeList();
closeBlockquote();
if (!inCode) {
out.push('<pre><code>');
inCode = true;
} else {
out.push('</code></pre>');
inCode = false;
}
i++;
continue;
}
if (inCode) {
out.push(escapeHtml(raw));
i++;
continue;
}
if (!line.trim()) {
closeList();
closeBlockquote();
i++;
continue;
}
// GFM pipe table — at least a header row, a separator row of dashes,
// and one body row. Look ahead from `i` so we can consume the whole
// block in one step.
if (looksLikeTableHeader(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1] ?? '')) {
closeList();
closeBlockquote();
const headerCells = splitTableRow(line);
const aligns = parseAlignments(lines[i + 1] ?? '', headerCells.length);
const bodyRows = [];
let j = i + 2;
while (j < lines.length) {
const next = (lines[j] ?? '').trimEnd();
if (!next.trim() || !next.includes('|')) break;
bodyRows.push(splitTableRow(next));
j++;
}
out.push(renderTable(headerCells, bodyRows, aligns));
i = j;
continue;
}
// ATX headings #..####
const h = /^(#{1,4})\s+(.+)$/.exec(line);
if (h) {
closeList();
closeBlockquote();
const level = h[1].length;
out.push(`<h${level}>${inline(h[2])}</h${level}>`);
i++;
continue;
}
// Horizontal rule.
if (/^([-*_])\1{2,}\s*$/.test(line)) {
closeList();
closeBlockquote();
out.push('<hr />');
i++;
continue;
}
const bq = /^>\s?(.*)$/.exec(line);
if (bq) {
closeList();
if (!inBlockquote) {
out.push('<blockquote>');
inBlockquote = true;
}
out.push(`<p>${inline(bq[1] || '')}</p>`);
i++;
continue;
}
closeBlockquote();
const li = /^([-*])\s+(.+)$/.exec(line);
if (li) {
if (inList !== 'ul') {
closeList();
out.push('<ul>');
inList = 'ul';
}
out.push(`<li>${inline(li[2])}</li>`);
i++;
continue;
}
const oli = /^\d+\.\s+(.+)$/.exec(line);
if (oli) {
if (inList !== 'ol') {
closeList();
out.push('<ol>');
inList = 'ol';
}
out.push(`<li>${inline(oli[1])}</li>`);
i++;
continue;
}
closeList();
out.push(`<p>${inline(line)}</p>`);
i++;
}
closeList();
closeBlockquote();
if (inCode) out.push('</code></pre>');
return out.join('\n');
}
function looksLikeTableHeader(line) {
const trimmed = line.trim();
if (!trimmed.includes('|')) return false;
// At least one pipe between non-pipe content.
return /\|/.test(trimmed.replace(/^\||\|$/g, ''));
}
function isTableSeparator(line) {
const trimmed = line.trim();
if (!trimmed.includes('|')) return false;
// Each cell must be only dashes / colons / whitespace.
return splitTableRow(trimmed).every((cell) => /^:?-{1,}:?$/.test(cell.trim()));
}
function splitTableRow(line) {
let s = line.trim();
if (s.startsWith('|')) s = s.slice(1);
if (s.endsWith('|')) s = s.slice(0, -1);
return s.split('|').map((c) => c.trim());
}
function parseAlignments(separatorLine, count) {
const cells = splitTableRow(separatorLine);
const aligns = [];
for (let k = 0; k < count; k++) {
const cell = (cells[k] ?? '').trim();
const left = cell.startsWith(':');
const right = cell.endsWith(':');
if (left && right) aligns.push('center');
else if (right) aligns.push('right');
else aligns.push(null);
}
return aligns;
}
function renderTable(header, rows, aligns) {
const th = header
.map((cell, k) => {
const align = aligns[k];
const attr = align ? ` align="${align}"` : '';
return `<th${attr}>${inline(cell)}</th>`;
})
.join('');
const body = rows
.map((row) => {
const tds = row
.map((cell, k) => {
const align = aligns[k];
const attr = align ? ` align="${align}"` : '';
return `<td${attr}>${inline(cell)}</td>`;
})
.join('');
return `<tr>${tds}</tr>`;
})
.join('');
return `<div class="table-wrap"><table><thead><tr>${th}</tr></thead><tbody>${body}</tbody></table></div>`;
}
function inline(s) {
// Process inline tokens. Order matters: code spans first so their content
// isn't further parsed; then bold/italic; then links; finally bare URLs.
const escaped = escapeHtml(s);
return escaped
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>')
.replace(/(^|[\s(])_([^_\n]+)_(?=[\s).,;:!?]|$)/g, '$1<em>$2</em>')
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer noopener">$1</a>');
}