feat(design-systems): import design system projects (#2112)

* feat(design-systems): define project manifest contract

* feat(design-systems): add default project manifest

* feat(daemon): consume design system manifests

* feat(design-systems): import local project systems

* feat(design-systems): import from github repositories

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>
This commit is contained in:
chaoxiaoche 2026-05-18 20:20:38 +08:00 committed by GitHub
parent 51c51035b8
commit f7eb82d7a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 2012 additions and 25 deletions

View file

@ -0,0 +1,161 @@
import { execFile } from 'node:child_process';
import { mkdir, rm } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import {
LocalDesignSystemImportError,
type LocalDesignSystemImportOptions,
type LocalDesignSystemImportResult,
importLocalDesignSystemProject,
} from './design-system-import.js';
const execFileAsync = promisify(execFile);
export type GitHubDesignSystemImportOptions = Pick<
LocalDesignSystemImportOptions,
'name' | 'now' | 'reservedIds'
> & {
branch?: string;
gitBin?: string;
};
export type ParsedGitHubRepoUrl = {
cloneUrl: string;
owner: string;
repo: string;
};
type ExecGitResult = {
stdout: string | Buffer;
stderr: string | Buffer;
};
export async function importGitHubDesignSystemProject(
githubUrl: string,
tmpRoot: string,
userDesignSystemsRoot: string,
options: GitHubDesignSystemImportOptions = {},
): Promise<LocalDesignSystemImportResult> {
const parsed = parseGitHubRepoUrl(githubUrl);
const importedAt = (options.now ?? new Date()).toISOString();
const cloneRoot = path.join(tmpRoot, 'github-design-system-imports');
await mkdir(cloneRoot, { recursive: true });
const cloneDir = path.join(
cloneRoot,
`${parsed.owner}-${parsed.repo}-${importedAt.replace(/[^0-9a-z]/gi, '')}`,
);
const gitBin = options.gitBin ?? 'git';
const cloneArgs = ['clone', '--depth', '1'];
const branch = cleanBranch(options.branch);
if (branch) cloneArgs.push('--branch', branch);
cloneArgs.push(parsed.cloneUrl, cloneDir);
try {
await execGit(gitBin, cloneArgs, undefined, 120_000);
const [detectedBranch, commit] = await Promise.all([
readGitStdout(gitBin, ['-C', cloneDir, 'rev-parse', '--abbrev-ref', 'HEAD']),
readGitStdout(gitBin, ['-C', cloneDir, 'rev-parse', 'HEAD']),
]);
const sourceBranch = branch ?? normalizeDetachedBranch(detectedBranch);
return await importLocalDesignSystemProject(cloneDir, userDesignSystemsRoot, {
now: new Date(importedAt),
fallbackName: parsed.repo,
...(options.name ? { name: options.name } : {}),
...(options.reservedIds ? { reservedIds: options.reservedIds } : {}),
source: {
type: 'github',
url: parsed.cloneUrl,
commit,
importedAt,
...(sourceBranch ? { branch: sourceBranch } : {}),
},
});
} catch (err) {
await rm(cloneDir, { recursive: true, force: true });
if (err instanceof LocalDesignSystemImportError) throw err;
throw new LocalDesignSystemImportError(
'BAD_REQUEST',
`could not import public GitHub repository: ${formatGitError(err)}`,
);
}
}
export function parseGitHubRepoUrl(input: string): ParsedGitHubRepoUrl {
let url: URL;
try {
url = new URL(input);
} catch {
throw new LocalDesignSystemImportError('BAD_REQUEST', 'GitHub URL must be a valid https://github.com URL');
}
if (url.protocol !== 'https:' || url.hostname.toLowerCase() !== 'github.com') {
throw new LocalDesignSystemImportError('BAD_REQUEST', 'only public https://github.com repositories are supported');
}
const parts = url.pathname.split('/').filter(Boolean);
const owner = parts[0];
const rawRepo = parts[1];
if (!owner || !rawRepo || parts.length > 2) {
throw new LocalDesignSystemImportError('BAD_REQUEST', 'GitHub URL must point to a repository root');
}
const repo = rawRepo.replace(/\.git$/i, '');
if (!isGitHubPathSegment(owner) || !isGitHubPathSegment(repo)) {
throw new LocalDesignSystemImportError('BAD_REQUEST', 'GitHub repository owner/name contains unsupported characters');
}
return {
owner,
repo,
cloneUrl: `https://github.com/${owner}/${repo}.git`,
};
}
function cleanBranch(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
if (trimmed.length === 0) return undefined;
if (!/^[A-Za-z0-9._/-]+$/.test(trimmed) || trimmed.includes('..') || trimmed.startsWith('/')) {
throw new LocalDesignSystemImportError('BAD_REQUEST', 'GitHub branch contains unsupported characters');
}
return trimmed;
}
function normalizeDetachedBranch(value: string): string | undefined {
const trimmed = value.trim();
if (!trimmed || trimmed === 'HEAD') return undefined;
return trimmed;
}
function isGitHubPathSegment(value: string): boolean {
return /^[A-Za-z0-9_.-]+$/.test(value) && !value.startsWith('.') && !value.endsWith('.');
}
async function readGitStdout(gitBin: string, args: string[]): Promise<string> {
const result = await execGit(gitBin, args, undefined, 20_000);
return String(result.stdout).trim();
}
async function execGit(
gitBin: string,
args: string[],
cwd: string | undefined,
timeout: number,
): Promise<ExecGitResult> {
return await execFileAsync(gitBin, args, {
cwd,
timeout,
maxBuffer: 1024 * 1024,
});
}
function formatGitError(err: unknown): string {
if (typeof err === 'object' && err !== null) {
const stderr = (err as { stderr?: unknown }).stderr;
if (typeof stderr === 'string' && stderr.trim()) return stderr.trim().split('\n').slice(-1)[0] ?? stderr.trim();
const message = (err as { message?: unknown }).message;
if (typeof message === 'string' && message.trim()) return message.trim();
}
return String(err);
}

View file

@ -0,0 +1,595 @@
import { copyFile, mkdir, readFile, readdir, realpath, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
export type LocalDesignSystemImportResult = {
id: string;
dir: string;
files: string[];
};
export type LocalDesignSystemImportOptions = {
now?: Date;
name?: string;
fallbackName?: string;
reservedIds?: Iterable<string>;
source?: DesignSystemProjectSource;
};
export type DesignSystemProjectSource =
| {
type: 'local';
path: string;
importedAt?: string;
}
| {
type: 'github';
url: string;
branch?: string;
commit?: string;
importedAt?: string;
};
type ProjectScan = {
sourceRoot: string;
packageName: string | undefined;
packageDescription: string | undefined;
packageTech: string[];
readmeExcerpt: string | undefined;
cssVariables: CssVariable[];
tailwindSignals: string[];
assets: AssetCandidate[];
components: ComponentSignal[];
};
type CssVariable = {
name: string;
value: string;
source: string;
};
type AssetCandidate = {
absPath: string;
relPath: string;
size: number;
};
type ComponentSignal = {
name: string;
relPath: string;
};
const IGNORED_DIRS = new Set([
'.git',
'.hg',
'.next',
'.nuxt',
'.od',
'.tmp',
'build',
'coverage',
'dist',
'node_modules',
'out',
'target',
]);
const STYLE_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']);
const COMPONENT_EXTENSIONS = new Set(['.tsx', '.jsx', '.vue', '.svelte']);
const ASSET_EXTENSIONS = new Set(['.svg', '.png', '.jpg', '.jpeg', '.webp', '.ico']);
const COMPONENT_NAMES = ['Button', 'Input', 'Card', 'Nav', 'Navbar', 'Sidebar'];
const TOKEN_FALLBACKS = {
bg: '#f8fafc',
surface: '#ffffff',
surfaceWarm: '#f3f4f6',
fg: '#111827',
fg2: '#374151',
muted: '#6b7280',
meta: '#9ca3af',
border: '#d1d5db',
borderSoft: '#e5e7eb',
accent: '#2563eb',
accentOn: '#ffffff',
accentHover: '#1d4ed8',
accentActive: '#1e40af',
success: '#16a34a',
warn: '#d97706',
danger: '#dc2626',
fontSans: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontSerif: 'Georgia, "Times New Roman", serif',
fontMono: '"SFMono-Regular", Consolas, "Liberation Mono", monospace',
radius: '10px',
};
export async function importLocalDesignSystemProject(
sourceRootInput: string,
userDesignSystemsRoot: string,
options: LocalDesignSystemImportOptions = {},
): Promise<LocalDesignSystemImportResult> {
const sourceRoot = await realpath(sourceRootInput);
const sourceStats = await stat(sourceRoot);
if (!sourceStats.isDirectory()) {
throw new LocalDesignSystemImportError('BAD_REQUEST', 'local project path must be a directory');
}
const scan = await scanProject(sourceRoot);
const displayName = cleanDisplayName(options.name ?? scan.packageName ?? options.fallbackName ?? path.basename(sourceRoot));
const id = await nextAvailableSlug(userDesignSystemsRoot, slugify(displayName), options.reservedIds);
const outDir = path.join(userDesignSystemsRoot, id);
await mkdir(outDir, { recursive: true });
const files = ['DESIGN.md', 'tokens.css', 'components.html', 'manifest.json'];
await writeFile(path.join(outDir, 'DESIGN.md'), renderDesignMd(id, displayName, scan), 'utf8');
await writeFile(path.join(outDir, 'tokens.css'), renderTokensCss(scan), 'utf8');
await writeFile(path.join(outDir, 'components.html'), renderComponentsHtml(displayName), 'utf8');
await writeFile(
path.join(outDir, 'manifest.json'),
`${JSON.stringify(renderManifest(id, displayName, scan, options.now ?? new Date(), options.source), null, 2)}\n`,
'utf8',
);
const copiedAssets = await copyAssets(scan.assets, outDir);
files.push(...copiedAssets);
return { id, dir: outDir, files };
}
export class LocalDesignSystemImportError extends Error {
constructor(
readonly code: 'BAD_REQUEST' | 'INTERNAL_ERROR',
message: string,
) {
super(message);
this.name = 'LocalDesignSystemImportError';
}
}
async function scanProject(sourceRoot: string): Promise<ProjectScan> {
const [packageJson, readmeExcerpt, files] = await Promise.all([
readPackageJson(sourceRoot),
readReadme(sourceRoot),
walkProject(sourceRoot),
]);
const styleFiles = files
.filter((file) => STYLE_EXTENSIONS.has(path.extname(file.absPath).toLowerCase()))
.slice(0, 80);
const cssVariables = (await Promise.all(styleFiles.map((file) => readCssVariables(file.absPath, file.relPath))))
.flat()
.slice(0, 80);
return {
sourceRoot,
packageName: packageJson.name,
packageDescription: packageJson.description,
packageTech: packageJson.tech,
readmeExcerpt,
cssVariables,
tailwindSignals: await readTailwindSignals(sourceRoot),
assets: await findAssets(sourceRoot, files),
components: findComponentSignals(files),
};
}
async function readPackageJson(
sourceRoot: string,
): Promise<{ name: string | undefined; description: string | undefined; tech: string[] }> {
try {
const parsed = JSON.parse(await readFile(path.join(sourceRoot, 'package.json'), 'utf8')) as Record<string, unknown>;
const deps = {
...(isRecord(parsed.dependencies) ? parsed.dependencies : {}),
...(isRecord(parsed.devDependencies) ? parsed.devDependencies : {}),
};
return {
name: typeof parsed.name === 'string' ? parsed.name : undefined,
description: typeof parsed.description === 'string' ? parsed.description : undefined,
tech: Object.keys(deps).filter((name) =>
['@tailwindcss', 'tailwindcss', 'react', 'vue', 'svelte', 'next', 'vite', 'framer-motion'].some((needle) =>
name.includes(needle),
),
),
};
} catch {
return { name: undefined, description: undefined, tech: [] };
}
}
async function readReadme(sourceRoot: string): Promise<string | undefined> {
for (const name of ['README.md', 'README.zh-CN.md', 'readme.md']) {
try {
const raw = await readFile(path.join(sourceRoot, name), 'utf8');
return compactMarkdown(raw).slice(0, 1400);
} catch {
// Try the next common readme name.
}
}
return undefined;
}
async function walkProject(sourceRoot: string): Promise<Array<{ absPath: string; relPath: string; size: number }>> {
const out: Array<{ absPath: string; relPath: string; size: number }> = [];
const queue = [sourceRoot];
while (queue.length > 0 && out.length < 900) {
const current = queue.shift()!;
let entries = [];
try {
entries = await readdir(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name.startsWith('.') && entry.name !== '.storybook') continue;
const absPath = path.join(current, entry.name);
const relPath = path.relative(sourceRoot, absPath);
if (entry.isDirectory()) {
if (!IGNORED_DIRS.has(entry.name)) queue.push(absPath);
continue;
}
if (!entry.isFile()) continue;
try {
const info = await stat(absPath);
if (info.size > 512 * 1024) continue;
out.push({ absPath, relPath, size: info.size });
} catch {
// Skip files that disappear during the scan.
}
}
}
return out;
}
async function readCssVariables(absPath: string, relPath: string): Promise<CssVariable[]> {
try {
const raw = await readFile(absPath, 'utf8');
const vars: CssVariable[] = [];
for (const match of raw.matchAll(/(--[a-zA-Z0-9-_]+)\s*:\s*([^;{}]+);/g)) {
const name = match[1];
const value = match[2]?.trim();
if (name === undefined || value === undefined) continue;
if (value.length === 0 || value.length > 120) continue;
vars.push({ name, value, source: relPath });
}
return vars;
} catch {
return [];
}
}
async function readTailwindSignals(sourceRoot: string): Promise<string[]> {
const candidates = [
'tailwind.config.ts',
'tailwind.config.js',
'tailwind.config.mjs',
'tailwind.config.cjs',
];
for (const candidate of candidates) {
try {
const raw = await readFile(path.join(sourceRoot, candidate), 'utf8');
const signals = new Set<string>();
for (const key of ['colors', 'fontFamily', 'borderRadius', 'spacing', 'boxShadow']) {
if (new RegExp(`\\b${key}\\s*:`).test(raw)) signals.add(key);
}
return Array.from(signals);
} catch {
// Try the next config name.
}
}
return [];
}
async function findAssets(
sourceRoot: string,
files: Array<{ absPath: string; relPath: string; size: number }>,
): Promise<AssetCandidate[]> {
return files
.filter((file) => {
const ext = path.extname(file.relPath).toLowerCase();
const rel = normalizeRel(file.relPath);
if (!ASSET_EXTENSIONS.has(ext) || file.size > 2 * 1024 * 1024) return false;
const isAssetRoot =
rel.startsWith('assets/') || rel.startsWith('public/') || rel.startsWith('src/assets/');
return isAssetRoot && /(logo|icon|favicon|mark|brand|avatar)/i.test(path.basename(file.relPath));
})
.slice(0, 12)
.map((file) => ({
absPath: file.absPath,
relPath: normalizeRel(path.relative(sourceRoot, file.absPath)),
size: file.size,
}));
}
function findComponentSignals(files: Array<{ absPath: string; relPath: string }>): ComponentSignal[] {
const found = new Map<string, ComponentSignal>();
for (const file of files) {
if (!COMPONENT_EXTENSIONS.has(path.extname(file.relPath).toLowerCase())) continue;
const basename = path.basename(file.relPath).replace(/\.[^.]+$/, '');
const component = COMPONENT_NAMES.find((name) => basename.toLowerCase().includes(name.toLowerCase()));
if (component && !found.has(component)) {
found.set(component, { name: component, relPath: normalizeRel(file.relPath) });
}
}
return Array.from(found.values()).slice(0, 10);
}
async function copyAssets(assets: AssetCandidate[], outDir: string): Promise<string[]> {
if (assets.length === 0) return [];
const assetsDir = path.join(outDir, 'assets');
await mkdir(assetsDir, { recursive: true });
const copied: string[] = [];
for (const asset of assets) {
const targetName = slugify(path.basename(asset.relPath, path.extname(asset.relPath))) + path.extname(asset.relPath).toLowerCase();
await copyFile(asset.absPath, path.join(assetsDir, targetName));
copied.push(`assets/${targetName}`);
}
return copied;
}
async function nextAvailableSlug(
root: string,
preferred: string,
reservedIds: Iterable<string> = [],
): Promise<string> {
await mkdir(root, { recursive: true });
const base = preferred || 'imported-design-system';
const reserved = new Set(reservedIds);
for (let index = 1; index < 1000; index += 1) {
const id = index === 1 ? base : `${base}-${index}`;
if (reserved.has(id)) continue;
try {
await stat(path.join(root, id));
} catch {
return id;
}
}
throw new LocalDesignSystemImportError('INTERNAL_ERROR', 'could not allocate design system id');
}
function renderManifest(
id: string,
name: string,
scan: ProjectScan,
now: Date,
sourceOverride: DesignSystemProjectSource | undefined,
) {
const importedAt = now.toISOString();
const source = sourceOverride ?? {
type: 'local',
path: scan.sourceRoot,
importedAt,
};
return {
schemaVersion: 'od-design-system-project/v1',
id,
name,
category: 'Imported',
description: scan.packageDescription ?? `Extracted from local project ${path.basename(scan.sourceRoot)}.`,
source: {
...source,
importedAt: source.importedAt ?? importedAt,
},
files: {
design: 'DESIGN.md',
tokens: 'tokens.css',
components: 'components.html',
},
...(scan.assets.length > 0 ? { assetsDir: 'assets' } : {}),
};
}
function renderDesignMd(id: string, name: string, scan: ProjectScan): string {
const colors = tokenCandidates(scan.cssVariables, ['color', 'accent', 'primary', 'background', 'surface', 'border'])
.slice(0, 16)
.map((token) => `- \`${token.name}: ${token.value}\` from \`${token.source}\``);
const components = scan.components.map((component) => `- ${component.name}: \`${component.relPath}\``);
const assets = scan.assets.map((asset) => `- \`${asset.relPath}\``);
return [
`# ${name}`,
'',
'> Category: Imported',
'> Surface: web',
'',
scan.packageDescription ?? `Imported design system extracted from \`${scan.sourceRoot}\`.`,
'',
'## Source',
'',
`- Project path: \`${scan.sourceRoot}\``,
`- Design system id: \`${id}\``,
scan.packageTech.length > 0 ? `- Detected stack: ${scan.packageTech.map((item) => `\`${item}\``).join(', ')}` : '- Detected stack: not declared',
scan.tailwindSignals.length > 0 ? `- Tailwind signals: ${scan.tailwindSignals.join(', ')}` : '- Tailwind signals: none detected',
'',
'## Product Notes',
'',
scan.readmeExcerpt ?? 'No README summary was found. Preserve the imported tokens and component proportions when generating new work.',
'',
'## Visual Tokens',
'',
colors.length > 0 ? colors.join('\n') : '- No CSS custom properties were found; tokens.css uses a neutral fallback palette.',
'',
'## Component Signals',
'',
components.length > 0 ? components.join('\n') : '- No common Button/Input/Card/Nav/Sidebar component files were detected.',
'',
'## Assets',
'',
assets.length > 0 ? assets.join('\n') : '- No logo/icon assets were copied for this import.',
'',
'## Agent Guidance',
'',
'- Use `tokens.css` as the first source of truth for color, radius, spacing, and type.',
'- Treat `components.html` as a compact fixture for proportions and state styling.',
'- When a token is a direct extraction from the source project, preserve its semantic role before inventing new values.',
'',
].join('\n');
}
function renderTokensCss(scan: ProjectScan): string {
const picked = pickDesignTokens(scan.cssVariables);
const cssVarLines = scan.cssVariables
.slice(0, 40)
.map((token) => ` ${token.name}: ${token.value};`)
.join('\n');
return `:root {
--bg: ${picked.bg};
--surface: ${picked.surface};
--surface-warm: ${picked.surfaceWarm};
--fg: ${picked.fg};
--fg-2: ${picked.fg2};
--muted: ${picked.muted};
--meta: ${picked.meta};
--border: ${picked.border};
--border-soft: ${picked.borderSoft};
--accent: ${picked.accent};
--accent-on: ${picked.accentOn};
--accent-hover: ${picked.accentHover};
--accent-active: ${picked.accentActive};
--success: ${picked.success};
--warn: ${picked.warn};
--danger: ${picked.danger};
--font-sans: ${picked.fontSans};
--font-serif: ${picked.fontSerif};
--font-mono: ${picked.fontMono};
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-md: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.375rem;
--text-2xl: 1.75rem;
--text-3xl: 2.25rem;
--leading-tight: 1.15;
--leading-body: 1.55;
--tracking-tight: 0;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.5rem;
--space-6: 2rem;
--space-8: 3rem;
--section-y: clamp(3rem, 7vw, 6rem);
--radius-sm: 6px;
--radius-md: ${picked.radius};
--radius-lg: 14px;
--elev-1: 0 1px 2px rgb(15 23 42 / 8%);
--elev-2: 0 18px 45px rgb(15 23 42 / 14%);
--focus-ring: 0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent);
--motion-fast: 140ms ease;
--motion-med: 220ms ease;
--container: 1120px;
--grid-gap: var(--space-5);
${cssVarLines ? `\n /* Extracted source variables */\n${cssVarLines}` : ''}
}
`;
}
function renderComponentsHtml(name: string): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(name)} components</title>
<link rel="stylesheet" href="./tokens.css" />
<style>
body { margin: 0; font-family: var(--font-sans); color: var(--fg); background: var(--bg); }
main { max-width: 960px; margin: 0 auto; padding: var(--space-8) var(--space-5); }
nav { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); border-bottom: 1px solid var(--border-soft); padding-bottom: var(--space-4); }
.brand { font-weight: 700; font-size: var(--text-lg); }
.card { margin-top: var(--space-6); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--elev-1); padding: var(--space-6); }
.row { display: flex; flex-wrap: wrap; gap: var(--space-3); align-items: center; }
button { border: 1px solid transparent; border-radius: var(--radius-md); padding: 0.7rem 1rem; font: inherit; cursor: pointer; transition: background var(--motion-fast), border-color var(--motion-fast); }
.primary { background: var(--accent); color: var(--accent-on); }
.secondary { background: var(--surface-warm); border-color: var(--border); color: var(--fg); }
input { min-width: 240px; border: 1px solid var(--border); border-radius: var(--radius-md); padding: 0.7rem 0.85rem; font: inherit; color: var(--fg); background: var(--surface); }
h1 { margin: var(--space-6) 0 var(--space-2); font-size: var(--text-3xl); line-height: var(--leading-tight); }
p { color: var(--fg-2); line-height: var(--leading-body); }
</style>
</head>
<body>
<main>
<nav>
<div class="brand">${escapeHtml(name)}</div>
<div class="row"><button class="secondary">Preview</button><button class="primary">Create</button></div>
</nav>
<section class="card">
<h1>Component fixture</h1>
<p>This fixture gives agents a compact reference for imported tokens, common controls, and card proportions.</p>
<div class="row">
<button class="primary">Primary action</button>
<button class="secondary">Secondary</button>
<input value="Input value" aria-label="Example input" />
</div>
</section>
</main>
</body>
</html>
`;
}
function pickDesignTokens(tokens: CssVariable[]): typeof TOKEN_FALLBACKS {
const valueFor = (needles: string[], fallback: string, validator: (value: string) => boolean = Boolean) =>
tokenCandidates(tokens, needles).find((token) => validator(token.value))?.value ?? fallback;
return {
...TOKEN_FALLBACKS,
bg: valueFor(['background', 'bg'], TOKEN_FALLBACKS.bg, isColorValue),
surface: valueFor(['surface', 'card', 'popover'], TOKEN_FALLBACKS.surface, isColorValue),
surfaceWarm: valueFor(['muted', 'subtle', 'secondary'], TOKEN_FALLBACKS.surfaceWarm, isColorValue),
fg: valueFor(['foreground', 'text', 'fg'], TOKEN_FALLBACKS.fg, isColorValue),
fg2: valueFor(['text-secondary', 'secondary-foreground'], TOKEN_FALLBACKS.fg2, isColorValue),
muted: valueFor(['muted', 'placeholder'], TOKEN_FALLBACKS.muted, isColorValue),
border: valueFor(['border'], TOKEN_FALLBACKS.border, isColorValue),
accent: valueFor(['accent', 'primary', 'brand'], TOKEN_FALLBACKS.accent, isColorValue),
success: valueFor(['success', 'positive'], TOKEN_FALLBACKS.success, isColorValue),
warn: valueFor(['warning', 'warn'], TOKEN_FALLBACKS.warn, isColorValue),
danger: valueFor(['danger', 'error', 'destructive'], TOKEN_FALLBACKS.danger, isColorValue),
radius: valueFor(['radius'], TOKEN_FALLBACKS.radius, (value) => /^\d/.test(value)),
fontSans: valueFor(['font-sans', 'font-family', 'font'], TOKEN_FALLBACKS.fontSans),
};
}
function tokenCandidates(tokens: CssVariable[], needles: string[]): CssVariable[] {
return tokens.filter((token) => needles.some((needle) => token.name.toLowerCase().includes(needle)));
}
function isColorValue(value: string): boolean {
return /^(#(?:[0-9a-f]{3,8})|rgb[a]?\(|hsl[a]?\(|oklch\(|color-mix\(|var\()/i.test(value.trim());
}
function compactMarkdown(raw: string): string {
return raw
.replace(/```[\s\S]*?```/g, '')
.replace(/!\[[^\]]*]\([^)]*\)/g, '')
.replace(/\[[^\]]+]\([^)]*\)/g, (match) => match.replace(/^\[([^\]]+)].*$/, '$1'))
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.slice(0, 16)
.join('\n');
}
function cleanDisplayName(value: string): string {
return value.replace(/^@[^/]+\//, '').replace(/[-_]+/g, ' ').trim() || 'Imported Design System';
}
function slugify(value: string): string {
const slug = value
.toLowerCase()
.replace(/^@[^/]+\//, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return slug || 'imported-design-system';
}
function normalizeRel(value: string): string {
return value.split(path.sep).join('/');
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View file

@ -1,7 +1,8 @@
// Design-system registry. Scans <projectRoot>/design-systems/* for DESIGN.md
// files. Title comes from the first H1. Category comes from a
// `> Category: <name>` blockquote line beneath the H1. Summary is the first
// paragraph between the H1 and the next heading (Category line stripped).
// Design-system registry. Scans <projectRoot>/design-systems/* for design
// system projects. Project folders may opt into manifest.json; legacy folders
// with only DESIGN.md remain valid. Without a manifest, title comes from the
// first H1, category from a `> Category: <name>` blockquote line beneath the
// H1, and summary from the first paragraph between the H1 and next heading.
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
@ -24,6 +25,18 @@ export type DesignSystemSummary = {
};
type ColorToken = { name: string; value: string };
type DesignSystemProjectManifest = {
schemaVersion: 'od-design-system-project/v1';
id: string;
name: string;
category: string;
description?: string;
files: {
design: 'DESIGN.md';
tokens: 'tokens.css';
components?: 'components.html';
};
};
export async function listDesignSystems(root: string): Promise<DesignSystemSummary[]> {
const out: DesignSystemSummary[] = [];
@ -35,18 +48,20 @@ export async function listDesignSystems(root: string): Promise<DesignSystemSumma
}
for (const entry of entries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
const designPath = path.join(root, entry.name, 'DESIGN.md');
const brandRoot = path.join(root, entry.name);
const manifest = await readProjectManifest(brandRoot, entry.name);
const designPath = path.join(brandRoot, manifest?.files.design ?? 'DESIGN.md');
try {
const stats = await stat(designPath);
if (!stats.isFile()) continue;
const raw = await readFile(designPath, 'utf8');
const titleMatch = /^#\s+(.+?)\s*$/m.exec(raw);
const title = cleanTitle(titleMatch?.[1] ?? entry.name);
const title = manifest?.name ?? cleanTitle(titleMatch?.[1] ?? entry.name);
out.push({
id: entry.name,
title,
category: extractCategory(raw) ?? 'Uncategorized',
summary: summarize(raw),
category: manifest?.category ?? extractCategory(raw) ?? 'Uncategorized',
summary: manifest?.description?.trim() || summarize(raw),
swatches: extractSwatches(raw),
surface: extractSurface(raw),
body: raw,
@ -59,7 +74,9 @@ export async function listDesignSystems(root: string): Promise<DesignSystemSumma
}
export async function readDesignSystem(root: string, id: string): Promise<string | null> {
const file = path.join(root, id, 'DESIGN.md');
const brandRoot = path.join(root, id);
const manifest = await readProjectManifest(brandRoot, id);
const file = path.join(brandRoot, manifest?.files.design ?? 'DESIGN.md');
try {
return await readFile(file, 'utf8');
} catch {
@ -92,9 +109,13 @@ export async function readDesignSystemAssets(
root: string,
id: string,
): Promise<DesignSystemAssets> {
const brandRoot = path.join(root, id);
const manifest = await readProjectManifest(brandRoot, id);
const [tokensCss, fixtureHtml] = await Promise.all([
readFileOptional(path.join(root, id, 'tokens.css')),
readFileOptional(path.join(root, id, 'components.html')),
readFileOptional(path.join(brandRoot, manifest?.files.tokens ?? 'tokens.css')),
manifest?.files.components === undefined && manifest !== null
? Promise.resolve(undefined)
: readFileOptional(path.join(brandRoot, manifest?.files.components ?? 'components.html')),
]);
return withComponentsManifest(id, { tokensCss, fixtureHtml });
}
@ -227,6 +248,46 @@ function isAbsenceError(err: unknown): boolean {
return code === 'ENOENT' || code === 'ENOTDIR';
}
async function readProjectManifest(
brandRoot: string,
expectedId: string,
): Promise<DesignSystemProjectManifest | null> {
let raw: string | undefined;
try {
raw = await readFileOptional(path.join(brandRoot, 'manifest.json'));
} catch {
return null;
}
if (raw === undefined) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (!isProjectManifest(parsed, expectedId)) return null;
return parsed;
} catch {
return null;
}
}
function isProjectManifest(value: unknown, expectedId: string): value is DesignSystemProjectManifest {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
if (record.schemaVersion !== 'od-design-system-project/v1') return false;
if (record.id !== expectedId) return false;
if (typeof record.name !== 'string' || record.name.trim().length === 0) return false;
if (typeof record.category !== 'string' || record.category.trim().length === 0) return false;
if (record.description !== undefined && typeof record.description !== 'string') return false;
const files = record.files;
if (typeof files !== 'object' || files === null || Array.isArray(files)) return false;
const fileRecord = files as Record<string, unknown>;
return (
fileRecord.design === 'DESIGN.md' &&
fileRecord.tokens === 'tokens.css' &&
(fileRecord.components === undefined || fileRecord.components === 'components.html')
);
}
function summarize(raw: string): string {
const lines = raw.split(/\r?\n/);
const firstH1 = lines.findIndex((l) => /^#\s+/.test(l));

View file

@ -14,6 +14,11 @@ import {
import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js';
import { syncCommunityPets } from './community-pets-sync.js';
import { readDesignSystem } from './design-systems.js';
import {
LocalDesignSystemImportError,
importLocalDesignSystemProject,
} from './design-system-import.js';
import { importGitHubDesignSystemProject } from './design-system-github-import.js';
import { renderDesignSystemPreview } from './design-system-preview.js';
import { renderDesignSystemShowcase } from './design-system-showcase.js';
import { listPromptTemplates, readPromptTemplate } from './prompt-templates.js';
@ -26,6 +31,8 @@ export interface RegisterStaticResourceRoutesDeps extends RouteDeps<'http' | 'pa
export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticResourceRoutesDeps) {
const {
RUNTIME_DATA_DIR,
RUNTIME_DATA_DIR_CANONICAL,
PROJECT_ROOT,
DESIGN_SYSTEMS_DIR,
USER_DESIGN_SYSTEMS_DIR,
DESIGN_TEMPLATES_DIR,
@ -609,6 +616,109 @@ export function registerStaticResourceRoutes(app: Express, ctx: RegisterStaticRe
}
});
app.post('/api/design-systems/import/local', async (req, res) => {
if (!requireLocalOrigin(req, res)) return;
try {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const inputPath =
typeof body.baseDir === 'string'
? body.baseDir
: typeof body.path === 'string'
? body.path
: typeof body.localPath === 'string'
? body.localPath
: '';
if (!path.isAbsolute(inputPath)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'local project path must be absolute');
}
let sourceRoot: string;
let sourceStats: fs.Stats;
try {
sourceRoot = fs.realpathSync.native(inputPath);
sourceStats = fs.statSync(sourceRoot);
} catch {
return sendApiError(res, 400, 'BAD_REQUEST', 'local project path was not found');
}
if (!sourceStats.isDirectory()) {
return sendApiError(res, 400, 'BAD_REQUEST', 'local project path must be a directory');
}
const sourceParent = path.dirname(sourceRoot);
if (sourceRoot === sourceParent) {
return sendApiError(res, 400, 'BAD_REQUEST', 'local project path cannot be a filesystem root');
}
try {
const runtimeRoot = fs.realpathSync.native(RUNTIME_DATA_DIR_CANONICAL);
if (sourceRoot === runtimeRoot || sourceRoot.startsWith(`${runtimeRoot}${path.sep}`)) {
return sendApiError(res, 400, 'BAD_REQUEST', 'cannot import Open Design runtime data');
}
} catch {
// The runtime data directory may not exist yet in first-run tests.
}
const before = await listAllDesignSystems();
const result = await importLocalDesignSystemProject(sourceRoot, USER_DESIGN_SYSTEMS_DIR, {
name: typeof body.name === 'string' ? body.name : undefined,
reservedIds: before.map((system) => system.id),
});
const systems = await listAllDesignSystems();
const designSystem = systems.find((system) => system.id === result.id);
if (!designSystem) {
return sendApiError(
res,
500,
'INTERNAL_ERROR',
`imported design system was not found in catalog: ${result.dir}`,
);
}
res.status(201).json({ designSystem });
} catch (err: any) {
if (err instanceof LocalDesignSystemImportError) {
return sendApiError(res, err.code === 'BAD_REQUEST' ? 400 : 500, err.code, err.message);
}
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
}
});
app.post('/api/design-systems/import/github', async (req, res) => {
if (!requireLocalOrigin(req, res)) return;
try {
const body = req.body && typeof req.body === 'object' ? req.body : {};
const githubUrl =
typeof body.githubUrl === 'string'
? body.githubUrl
: typeof body.url === 'string'
? body.url
: '';
const before = await listAllDesignSystems();
const result = await importGitHubDesignSystemProject(
githubUrl,
path.join(PROJECT_ROOT, '.tmp'),
USER_DESIGN_SYSTEMS_DIR,
{
name: typeof body.name === 'string' ? body.name : undefined,
branch: typeof body.branch === 'string' ? body.branch : undefined,
reservedIds: before.map((system) => system.id),
},
);
const systems = await listAllDesignSystems();
const designSystem = systems.find((system) => system.id === result.id);
if (!designSystem) {
return sendApiError(
res,
500,
'INTERNAL_ERROR',
`imported GitHub design system was not found in catalog: ${result.dir}`,
);
}
res.status(201).json({ designSystem });
} catch (err: any) {
if (err instanceof LocalDesignSystemImportError) {
return sendApiError(res, err.code === 'BAD_REQUEST' ? 400 : 500, err.code, err.message);
}
sendApiError(res, 500, 'INTERNAL_ERROR', String(err));
}
});
app.delete('/api/design-systems/:id', async (req, res) => {
if (!requireLocalOrigin(req, res)) return;
try {

View file

@ -16,6 +16,8 @@ import { describe, expect, it } from 'vitest';
import {
isDesignTokenChannelEnabled,
listDesignSystems,
readDesignSystem,
readDesignSystemAssets,
resolveDesignSystemAssets,
} from '../src/design-systems.js';
@ -30,6 +32,29 @@ function brandDir(root: string, id: string): string {
return dir;
}
function writeDesignSystemProject(
root: string,
id: string,
{
manifest,
design = '# Markdown Title\n\n> Category: Markdown Category\n> Markdown summary.\n',
tokens = ':root { --bg: #fff; }',
components = '<button>fixture</button>',
}: {
manifest?: Record<string, unknown>;
design?: string;
tokens?: string;
components?: string | null;
} = {},
): string {
const dir = brandDir(root, id);
writeFileSync(path.join(dir, 'DESIGN.md'), design);
writeFileSync(path.join(dir, 'tokens.css'), tokens);
if (components !== null) writeFileSync(path.join(dir, 'components.html'), components);
if (manifest) writeFileSync(path.join(dir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`);
return dir;
}
describe('readDesignSystemAssets', () => {
it('returns both fields when tokens.css and components.html are both present', async () => {
const root = fresh();
@ -119,6 +144,99 @@ describe('readDesignSystemAssets', () => {
});
});
describe('Design System Project manifest runtime consumption', () => {
it('uses manifest name/category/description for listings while still reading DESIGN.md body', async () => {
const root = fresh();
writeDesignSystemProject(root, 'project-system', {
manifest: {
schemaVersion: 'od-design-system-project/v1',
id: 'project-system',
name: 'Project System',
category: 'Imported',
description: 'Description from manifest.',
source: { type: 'local', path: '/tmp/project' },
files: {
design: 'DESIGN.md',
tokens: 'tokens.css',
components: 'components.html',
},
},
design: '# Markdown Title\n\n> Category: Markdown Category\n> Markdown summary.\n\nBody.\n',
});
const systems = await listDesignSystems(root);
expect(systems).toHaveLength(1);
expect(systems[0]).toMatchObject({
id: 'project-system',
title: 'Project System',
category: 'Imported',
summary: 'Description from manifest.',
body: '# Markdown Title\n\n> Category: Markdown Category\n> Markdown summary.\n\nBody.\n',
});
await expect(readDesignSystem(root, 'project-system')).resolves.toContain('# Markdown Title');
});
it('keeps DESIGN.md-only systems working next to project manifests', async () => {
const root = fresh();
writeDesignSystemProject(root, 'project-system', {
manifest: {
schemaVersion: 'od-design-system-project/v1',
id: 'project-system',
name: 'Project System',
category: 'Imported',
description: 'Description from manifest.',
source: { type: 'bundled' },
files: {
design: 'DESIGN.md',
tokens: 'tokens.css',
},
},
components: null,
});
writeDesignSystemProject(root, 'legacy-system', {
design: '# Legacy System\n\n> Category: Legacy\n> Legacy summary.\n\nBody.\n',
components: null,
});
const systems = await listDesignSystems(root);
expect(systems.map((s) => s.id).sort()).toEqual(['legacy-system', 'project-system']);
expect(systems.find((s) => s.id === 'legacy-system')).toMatchObject({
title: 'Legacy System',
category: 'Legacy',
summary: 'Legacy summary.',
});
expect(systems.find((s) => s.id === 'project-system')).toMatchObject({
title: 'Project System',
category: 'Imported',
summary: 'Description from manifest.',
});
});
it('reads manifest-declared tokens and skips missing optional components.html', async () => {
const root = fresh();
writeDesignSystemProject(root, 'tokens-only-project', {
manifest: {
schemaVersion: 'od-design-system-project/v1',
id: 'tokens-only-project',
name: 'Tokens Only Project',
category: 'Imported',
source: { type: 'bundled' },
files: {
design: 'DESIGN.md',
tokens: 'tokens.css',
},
},
tokens: ':root { --accent: #2F6FEB; }',
components: null,
});
const assets = await readDesignSystemAssets(root, 'tokens-only-project');
expect(assets.tokensCss).toBe(':root { --accent: #2F6FEB; }');
expect(assets.fixtureHtml).toBeUndefined();
});
});
// Reviewer feedback (nettee, PR-D #1544): the parity guard at
// `scripts/check-design-system-flag-parity.ts` exercises the prompt
// composer directly and therefore does NOT cover the server-layer env

View file

@ -0,0 +1,127 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
importGitHubDesignSystemProject,
parseGitHubRepoUrl,
} from '../src/design-system-github-import.js';
describe('parseGitHubRepoUrl', () => {
it('normalizes public GitHub repository URLs to clone URLs', () => {
expect(parseGitHubRepoUrl('https://github.com/acme/design-system')).toEqual({
owner: 'acme',
repo: 'design-system',
cloneUrl: 'https://github.com/acme/design-system.git',
});
expect(parseGitHubRepoUrl('https://github.com/acme/design-system.git')).toEqual({
owner: 'acme',
repo: 'design-system',
cloneUrl: 'https://github.com/acme/design-system.git',
});
});
it('rejects non-root or non-GitHub URLs', () => {
expect(() => parseGitHubRepoUrl('https://example.com/acme/design-system')).toThrow(
/github\.com/,
);
expect(() => parseGitHubRepoUrl('https://github.com/acme/design-system/tree/main')).toThrow(
/repository root/,
);
});
});
describe('importGitHubDesignSystemProject', () => {
let tempRoot: string;
let fixtureRoot: string;
let tmpRoot: string;
let userDesignSystemsRoot: string;
let fakeGit: string;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'od-ds-github-import-'));
fixtureRoot = path.join(tempRoot, 'fixture-repo');
tmpRoot = path.join(tempRoot, '.tmp');
userDesignSystemsRoot = path.join(tempRoot, 'user-design-systems');
fs.mkdirSync(path.join(fixtureRoot, 'src', 'components'), { recursive: true });
fs.mkdirSync(path.join(fixtureRoot, 'src', 'styles'), { recursive: true });
fs.writeFileSync(
path.join(fixtureRoot, 'package.json'),
JSON.stringify({
name: 'github-design-kit',
description: 'A GitHub-hosted design kit.',
dependencies: { react: '^18.0.0' },
}),
);
fs.writeFileSync(path.join(fixtureRoot, 'README.md'), '# GitHub Design Kit\n\nRemote style source.\n');
fs.writeFileSync(path.join(fixtureRoot, 'src', 'styles', 'tokens.css'), ':root { --primary: #22c55e; }');
fs.writeFileSync(path.join(fixtureRoot, 'src', 'components', 'Card.tsx'), 'export function Card() {}');
fakeGit = path.join(tempRoot, 'fake-git.sh');
fs.writeFileSync(
fakeGit,
`#!/bin/sh
set -eu
if [ "$1" = "clone" ]; then
target=""
for arg in "$@"; do target="$arg"; done
mkdir -p "$target"
cp -R "$FAKE_GIT_SOURCE"/. "$target"/
exit 0
fi
if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "--abbrev-ref" ]; then
printf 'main\\n'
exit 0
fi
if [ "$1" = "-C" ] && [ "$3" = "rev-parse" ] && [ "$4" = "HEAD" ]; then
printf 'abc123def456\\n'
exit 0
fi
echo "unexpected git args: $*" >&2
exit 1
`,
);
fs.chmodSync(fakeGit, 0o755);
process.env.FAKE_GIT_SOURCE = fixtureRoot;
});
afterEach(() => {
delete process.env.FAKE_GIT_SOURCE;
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('clones a public GitHub URL and imports through the local project format', async () => {
const result = await importGitHubDesignSystemProject(
'https://github.com/acme/design-kit',
tmpRoot,
userDesignSystemsRoot,
{
gitBin: fakeGit,
now: new Date('2026-05-18T10:00:00.000Z'),
},
);
expect(result.id).toBe('github-design-kit');
const manifest = JSON.parse(fs.readFileSync(path.join(result.dir, 'manifest.json'), 'utf8')) as Record<string, unknown>;
expect(manifest).toMatchObject({
schemaVersion: 'od-design-system-project/v1',
id: 'github-design-kit',
source: {
type: 'github',
url: 'https://github.com/acme/design-kit.git',
branch: 'main',
commit: 'abc123def456',
importedAt: '2026-05-18T10:00:00.000Z',
},
files: {
design: 'DESIGN.md',
tokens: 'tokens.css',
components: 'components.html',
},
});
expect(fs.readFileSync(path.join(result.dir, 'DESIGN.md'), 'utf8')).toContain(
'A GitHub-hosted design kit.',
);
});
});

View file

@ -0,0 +1,110 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { importLocalDesignSystemProject } from '../src/design-system-import.js';
import { listDesignSystems, readDesignSystemAssets } from '../src/design-systems.js';
describe('importLocalDesignSystemProject', () => {
let tempRoot: string;
let sourceRoot: string;
let userDesignSystemsRoot: string;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'od-ds-import-'));
sourceRoot = path.join(tempRoot, 'source-app');
userDesignSystemsRoot = path.join(tempRoot, 'user-design-systems');
fs.mkdirSync(path.join(sourceRoot, 'src', 'components'), { recursive: true });
fs.mkdirSync(path.join(sourceRoot, 'src', 'styles'), { recursive: true });
fs.mkdirSync(path.join(sourceRoot, 'public'), { recursive: true });
fs.writeFileSync(
path.join(sourceRoot, 'package.json'),
JSON.stringify({
name: '@acme/kami-app',
description: 'A focused workspace for AI design reviews.',
dependencies: { react: '^18.0.0', tailwindcss: '^3.0.0' },
}),
);
fs.writeFileSync(
path.join(sourceRoot, 'README.md'),
'# Kami App\n\nA calm review surface with crisp cards and bright primary actions.\n',
);
fs.writeFileSync(
path.join(sourceRoot, 'src', 'styles', 'tokens.css'),
':root { --color-primary: #ff3366; --color-background: #101014; --radius-card: 12px; }',
);
fs.writeFileSync(
path.join(sourceRoot, 'tailwind.config.ts'),
'export default { theme: { extend: { colors: {}, fontFamily: {}, borderRadius: {} } } }',
);
fs.writeFileSync(path.join(sourceRoot, 'src', 'components', 'Button.tsx'), 'export function Button() {}');
fs.writeFileSync(path.join(sourceRoot, 'public', 'logo.svg'), '<svg xmlns="http://www.w3.org/2000/svg" />');
});
afterEach(() => {
fs.rmSync(tempRoot, { recursive: true, force: true });
});
it('generates a design-system project from a local app directory', async () => {
const result = await importLocalDesignSystemProject(sourceRoot, userDesignSystemsRoot, {
now: new Date('2026-05-18T09:00:00.000Z'),
});
expect(result.id).toBe('kami-app');
expect(result.files).toEqual(
expect.arrayContaining(['DESIGN.md', 'tokens.css', 'components.html', 'manifest.json', 'assets/logo.svg']),
);
const manifest = JSON.parse(fs.readFileSync(path.join(result.dir, 'manifest.json'), 'utf8')) as Record<string, unknown>;
expect(manifest).toMatchObject({
schemaVersion: 'od-design-system-project/v1',
id: 'kami-app',
name: 'kami app',
category: 'Imported',
source: {
type: 'local',
path: fs.realpathSync.native(sourceRoot),
importedAt: '2026-05-18T09:00:00.000Z',
},
files: {
design: 'DESIGN.md',
tokens: 'tokens.css',
components: 'components.html',
},
assetsDir: 'assets',
});
const design = fs.readFileSync(path.join(result.dir, 'DESIGN.md'), 'utf8');
expect(design).toContain('A focused workspace for AI design reviews.');
expect(design).toContain('Button: `src/components/Button.tsx`');
expect(design).toContain('`--color-primary: #ff3366`');
const assets = await readDesignSystemAssets(userDesignSystemsRoot, 'kami-app');
expect(assets.tokensCss).toContain('--accent: #ff3366;');
expect(assets.tokensCss).toContain('--bg: #101014;');
expect(assets.fixtureHtml).toContain('Component fixture');
const systems = await listDesignSystems(userDesignSystemsRoot);
expect(systems).toMatchObject([
{
id: 'kami-app',
title: 'kami app',
category: 'Imported',
summary: 'A focused workspace for AI design reviews.',
},
]);
});
it('allocates a new slug instead of colliding with existing systems', async () => {
const first = await importLocalDesignSystemProject(sourceRoot, userDesignSystemsRoot, {
reservedIds: ['kami-app'],
});
const second = await importLocalDesignSystemProject(sourceRoot, userDesignSystemsRoot, {
reservedIds: ['kami-app'],
});
expect(first.id).toBe('kami-app-2');
expect(second.id).toBe('kami-app-3');
});
});

View file

@ -89,6 +89,8 @@ describe('static resource mutation routes', () => {
['POST', '/api/skills/install'],
['DELETE', '/api/skills/demo-skill'],
['POST', '/api/design-systems/install'],
['POST', '/api/design-systems/import/local'],
['POST', '/api/design-systems/import/github'],
['DELETE', '/api/design-systems/demo-system'],
])('rejects cross-origin %s %s before catalog or filesystem work', async (method, route) => {
catalogReadCount = 0;
@ -100,7 +102,12 @@ describe('static resource mutation routes', () => {
},
};
if (method === 'POST') {
init.body = JSON.stringify({ source: 'local', path: tempRoot });
init.body = JSON.stringify({
source: 'local',
path: tempRoot,
baseDir: tempRoot,
githubUrl: 'https://github.com/example/repo',
});
}
const res = await fetch(`${baseUrl}${route}`, init);
@ -108,4 +115,20 @@ describe('static resource mutation routes', () => {
expect(await res.json()).toMatchObject({ code: 'FORBIDDEN' });
expect(catalogReadCount).toBe(0);
});
it('returns a bad request for a missing local design-system import path', async () => {
catalogReadCount = 0;
const res = await fetch(`${baseUrl}/api/design-systems/import/local`, {
method: 'POST',
headers: {
Origin: baseUrl,
'Content-Type': 'application/json',
},
body: JSON.stringify({ baseDir: path.join(tempRoot, 'missing-project') }),
});
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ code: 'BAD_REQUEST' });
expect(catalogReadCount).toBe(0);
});
});

View file

@ -1,11 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import type { Dispatch, FormEvent, SetStateAction } from 'react';
import { useT } from '../i18n';
import type { AppConfig } from '../types';
import type { DesignSystemSummary } from '@open-design/contracts';
import {
fetchDesignSystem,
fetchDesignSystems,
importGitHubDesignSystem,
importLocalDesignSystem,
} from '../providers/registry';
// Sibling Settings section that hosts the design-systems registry.
@ -26,6 +28,11 @@ export function DesignSystemsSection({ cfg, setCfg }: Props) {
const [previewId, setPreviewId] = useState<string | null>(null);
const [previewBody, setPreviewBody] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [importPath, setImportPath] = useState('');
const [importMode, setImportMode] = useState<'local' | 'github'>('local');
const [importing, setImporting] = useState(false);
const [importMessage, setImportMessage] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(null);
useEffect(() => {
fetchDesignSystems().then(setDesignSystems);
@ -105,8 +112,72 @@ export function DesignSystemsSection({ cfg, setCfg }: Props) {
});
}
async function handleLocalImport(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const importTarget = importPath.trim();
if (!importTarget || importing) return;
setImporting(true);
setImportError(null);
setImportMessage(null);
const result =
importMode === 'github'
? await importGitHubDesignSystem({ githubUrl: importTarget })
: await importLocalDesignSystem({ baseDir: importTarget });
setImporting(false);
if ('error' in result) {
setImportError(result.error.message);
return;
}
setDesignSystems((current) => {
const withoutDuplicate = current.filter((system) => system.id !== result.designSystem.id);
return [...withoutDuplicate, result.designSystem].sort((a, b) => a.title.localeCompare(b.title));
});
setCategoryFilter(result.designSystem.category);
setPreviewId(null);
setPreviewBody(null);
setImportPath('');
setImportMessage(`Imported ${result.designSystem.title}`);
}
return (
<section className="settings-section settings-design-systems">
<form className="library-install-form" onSubmit={handleLocalImport}>
<div className="seg-control">
<button
type="button"
className={importMode === 'local' ? 'active' : ''}
onClick={() => setImportMode('local')}
>
Local
</button>
<button
type="button"
className={importMode === 'github' ? 'active' : ''}
onClick={() => setImportMode('github')}
>
GitHub
</button>
</div>
<div className="library-install-row">
<input
type="text"
className="library-search"
placeholder={importMode === 'github' ? 'https://github.com/owner/repo' : '/path/to/project'}
value={importPath}
onChange={(e) => setImportPath(e.target.value)}
/>
<button
type="submit"
className="library-install-submit"
disabled={importing || importPath.trim().length === 0}
>
{importing ? t('settings.libraryLoading') : 'Import from project'}
</button>
</div>
{importError ? <p className="library-install-error">{importError}</p> : null}
{importMessage ? <p className="library-install-status">{importMessage}</p> : null}
</form>
<div className="library-toolbar">
<input
type="search"

View file

@ -19177,6 +19177,12 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
margin: 0;
}
.library-install-status {
color: var(--text-secondary);
font-size: 12px;
margin: 0;
}
.library-source-badge {
display: inline-block;
font-size: 10px;

View file

@ -6,6 +6,10 @@ import type {
ConnectorDetailResponse,
ConnectorListResponse,
ConnectorStatusResponse,
ImportGitHubDesignSystemRequest,
ImportGitHubDesignSystemResponse,
ImportLocalDesignSystemRequest,
ImportLocalDesignSystemResponse,
} from '@open-design/contracts';
import type {
AgentInfo,
@ -350,6 +354,62 @@ export async function fetchDesignSystem(id: string): Promise<DesignSystemDetail
}
}
export async function importLocalDesignSystem(
input: ImportLocalDesignSystemRequest,
): Promise<ImportLocalDesignSystemResponse | { error: SkillImportError }> {
try {
const resp = await fetch('/api/design-systems/import/local', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!resp.ok) {
return { error: await readImportError(resp) };
}
return (await resp.json()) as ImportLocalDesignSystemResponse;
} catch (err) {
return {
error: {
message: err instanceof Error ? err.message : 'Import request failed.',
},
};
}
}
export async function importGitHubDesignSystem(
input: ImportGitHubDesignSystemRequest,
): Promise<ImportGitHubDesignSystemResponse | { error: SkillImportError }> {
try {
const resp = await fetch('/api/design-systems/import/github', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!resp.ok) return { error: await readImportError(resp) };
return (await resp.json()) as ImportGitHubDesignSystemResponse;
} catch (err) {
return {
error: {
message: err instanceof Error ? err.message : 'Import request failed.',
},
};
}
}
async function readImportError(resp: Response): Promise<SkillImportError> {
const payload = (await resp.json().catch(() => null)) as
| { error?: SkillImportError | string; message?: string }
| null;
const error = payload?.error;
if (typeof error === 'object' && error !== null) return error;
return {
message:
typeof error === 'string'
? error
: payload?.message ?? `Import failed (${resp.status}).`,
};
}
export async function fetchPromptTemplates(): Promise<PromptTemplateSummary[]> {
try {
const resp = await fetch('/api/prompt-templates');

View file

@ -46,7 +46,62 @@ will read it as part of its system prompt.
Folders use ASCII slugs — dotted brands are normalized (`linear.app` →
`linear-app`, `x.ai``x-ai`, etc.).
## File shape
## Design System Project Shape
The current runtime still supports legacy folders that contain only
`DESIGN.md`. New imported or packaged systems should use the project shape
below so picker, daemon, agents, validators, and future importers can all
discover the same files without guessing.
```text
design-systems/<slug>/
├── manifest.json ← machine-readable project entry
├── DESIGN.md ← canonical design prose for agents
├── tokens.css ← canonical compiled CSS custom properties
├── components.html ← optional standalone component fixture
├── assets/ ← optional brand assets
└── preview/ ← optional static preview pages
```
`manifest.json` is validated by `pnpm guard` when present. PR1 does not
require every bundled system to ship a manifest; old `DESIGN.md` systems are
skipped by the manifest guard and continue to work.
Minimum v1 manifest:
```json
{
"schemaVersion": "od-design-system-project/v1",
"id": "default",
"name": "Neutral Modern",
"category": "Starter",
"description": "A clean, product-oriented default.",
"source": {
"type": "bundled",
"origin": "hand-authored"
},
"files": {
"design": "DESIGN.md",
"tokens": "tokens.css",
"components": "components.html"
}
}
```
For v1, file locations are intentionally fixed:
- `files.design` must be `DESIGN.md`.
- `files.tokens` must be `tokens.css`.
- `files.components` is optional and, when declared, must be
`components.html`.
- `assetsDir` is optional and, when declared, must be `assets`.
- `previewDir` is optional and, when declared, must be `preview`.
The schema source of truth lives in
[`_schema/manifest.schema.ts`](_schema/manifest.schema.ts). The guard lives in
[`../scripts/check-design-system-manifests.ts`](../scripts/check-design-system-manifests.ts).
## Legacy File Shape
The first H1 is the title shown in the picker. The line immediately after
the H1 is parsed for `> Category: <name>` and used to group the dropdown:

View file

@ -1,22 +1,41 @@
# `_schema/`the shared token contract
# `_schema/`design-system contracts
This directory codifies the structural contract that every brand under
`design-systems/<brand>/` must satisfy. It is the input to the drift
guard (`scripts/check-tokens-fixture-sync.ts`) and the future derive
script that will bulk-generate `tokens.css` for the ~140 brands that do
not yet have hand-authored tokens.
This directory codifies the structural contracts for design systems.
`tokens.schema.ts` is the token contract that every tokenized brand under
`design-systems/<brand>/` must satisfy. `manifest.schema.ts` is the
project contract for folders that opt into the Design System Project
shape by adding `manifest.json`; legacy `DESIGN.md`-only folders remain
valid until they are migrated.
```
_schema/
├── tokens.schema.ts ← canonical schema (TS, machine-enforced)
├── manifest.schema.ts ← project manifest schema (TS, machine-enforced when present)
├── tokens.schema.ts ← canonical token schema (TS, machine-enforced)
├── defaults.css ← A2 fallback values (CSS, human reference)
└── AGENTS.md ← this file
```
The TypeScript schema is the source of truth. `defaults.css` is a
human-readable mirror of the A2 `fallback` fields and exists so that
reviewers can scan real CSS without parsing a TS array — drift between
the two is enforced by the `design-system: A2 defaults parity` guard.
The TypeScript schemas are the source of truth. `defaults.css` is a
human-readable mirror of the A2 `fallback` fields in `tokens.schema.ts`
and exists so that reviewers can scan real CSS without parsing a TS
array — drift between the two is enforced by the `design-system: A2
defaults parity` guard. Manifest shape is enforced by
`scripts/check-design-system-manifests.ts` for any
`design-systems/<brand>/manifest.json` that exists.
## Project manifest contract
Design System Project folders use fixed v1 file names:
- `manifest.json` — machine-readable project entry.
- `DESIGN.md` — canonical design prose.
- `tokens.css` — canonical compiled tokens.
- `components.html` — optional standalone component fixture.
- `assets/` — optional brand assets.
- `preview/` — optional static preview pages.
The manifest guard validates only folders that ship `manifest.json`; it
does not require the bundled catalog to migrate all at once.
## Four layers, two questions

View file

@ -0,0 +1,224 @@
/*
* design-systems/_schema/manifest.schema.ts
*
* Canonical contract for an Open Design Design System Project.
*
* `DESIGN.md` remains the prose source that agents read. The project
* manifest is the stable discovery layer around it: picker / daemon /
* importer code can find the canonical design prose, compiled tokens,
* optional component fixtures, and optional preview/assets directories
* without guessing from folder contents.
*
* PR1 deliberately defines the contract without changing runtime
* discovery. Existing DESIGN.md-only systems stay valid; this schema is
* enforced only for folders that choose to ship `manifest.json`.
* */
export const DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION = "od-design-system-project/v1" as const;
export type DesignSystemProjectSource =
| {
readonly type: "bundled";
/** Human-readable origin, e.g. upstream repo/package, when known. */
readonly origin?: string;
}
| {
readonly type: "local";
/** Absolute path selected by the user at import time. */
readonly path: string;
readonly importedAt?: string;
}
| {
readonly type: "github";
readonly url: string;
readonly branch?: string;
readonly commit?: string;
readonly importedAt?: string;
};
export type DesignSystemProjectFiles = {
/**
* Canonical design prose for agent prompts. V1 keeps this fixed so
* DESIGN.md-only fallback and project manifests share the same source.
*/
readonly design: "DESIGN.md";
/**
* Canonical compiled token stylesheet. New project manifests require
* it; legacy folders without a manifest may still be DESIGN.md-only.
*/
readonly tokens: "tokens.css";
/**
* Optional standalone component fixture. First-class in the contract,
* but optional for MVP imports and prose-only brands.
*/
readonly components?: "components.html";
};
export type DesignSystemProjectManifest = {
readonly schemaVersion: typeof DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION;
/** Folder slug and stable picker id. Must match /^[a-z0-9-]+$/. */
readonly id: string;
readonly name: string;
readonly category: string;
readonly description?: string;
readonly source: DesignSystemProjectSource;
readonly files: DesignSystemProjectFiles;
/** Optional static assets root. V1 fixes the directory name. */
readonly assetsDir?: "assets";
/** Optional preview root. V1 fixes the directory name. */
readonly previewDir?: "preview";
};
export type DesignSystemManifestValidationResult =
| { readonly ok: true; readonly manifest: DesignSystemProjectManifest }
| { readonly ok: false; readonly errors: readonly string[] };
const ALLOWED_TOP_LEVEL_KEYS = new Set([
"schemaVersion",
"id",
"name",
"category",
"description",
"source",
"files",
"assetsDir",
"previewDir",
]);
const ALLOWED_SOURCE_KEYS: Record<DesignSystemProjectSource["type"], ReadonlySet<string>> = {
bundled: new Set(["type", "origin"]),
local: new Set(["type", "path", "importedAt"]),
github: new Set(["type", "url", "branch", "commit", "importedAt"]),
};
const ALLOWED_FILES_KEYS = new Set(["design", "tokens", "components"]);
export function parseDesignSystemProjectManifest(
raw: string,
): DesignSystemManifestValidationResult {
let value: unknown;
try {
value = JSON.parse(raw);
} catch (error) {
return {
ok: false,
errors: [`manifest.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`],
};
}
return validateDesignSystemProjectManifest(value);
}
export function validateDesignSystemProjectManifest(
value: unknown,
): DesignSystemManifestValidationResult {
const errors: string[] = [];
if (!isRecord(value)) {
return { ok: false, errors: ["manifest must be a JSON object"] };
}
rejectUnknownKeys(errors, "$", value, ALLOWED_TOP_LEVEL_KEYS);
expectLiteral(errors, "$.schemaVersion", value.schemaVersion, DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION);
expectSlug(errors, "$.id", value.id);
expectNonEmptyString(errors, "$.name", value.name);
expectNonEmptyString(errors, "$.category", value.category);
if (value.description !== undefined) expectNonEmptyString(errors, "$.description", value.description);
validateSource(errors, value.source);
validateFiles(errors, value.files);
if (value.assetsDir !== undefined) expectLiteral(errors, "$.assetsDir", value.assetsDir, "assets");
if (value.previewDir !== undefined) expectLiteral(errors, "$.previewDir", value.previewDir, "preview");
if (errors.length > 0) return { ok: false, errors };
return { ok: true, manifest: value as DesignSystemProjectManifest };
}
function validateSource(errors: string[], value: unknown): void {
if (!isRecord(value)) {
errors.push("$.source must be an object");
return;
}
const type = value.type;
if (type !== "bundled" && type !== "local" && type !== "github") {
errors.push("$.source.type must be one of bundled, local, github");
return;
}
rejectUnknownKeys(errors, "$.source", value, ALLOWED_SOURCE_KEYS[type]);
if (type === "bundled") {
if (value.origin !== undefined) expectNonEmptyString(errors, "$.source.origin", value.origin);
return;
}
if (type === "local") {
expectNonEmptyString(errors, "$.source.path", value.path);
if (value.importedAt !== undefined) expectIsoDateTime(errors, "$.source.importedAt", value.importedAt);
return;
}
expectNonEmptyString(errors, "$.source.url", value.url);
if (value.branch !== undefined) expectNonEmptyString(errors, "$.source.branch", value.branch);
if (value.commit !== undefined) expectNonEmptyString(errors, "$.source.commit", value.commit);
if (value.importedAt !== undefined) expectIsoDateTime(errors, "$.source.importedAt", value.importedAt);
}
function validateFiles(errors: string[], value: unknown): void {
if (!isRecord(value)) {
errors.push("$.files must be an object");
return;
}
rejectUnknownKeys(errors, "$.files", value, ALLOWED_FILES_KEYS);
expectLiteral(errors, "$.files.design", value.design, "DESIGN.md");
expectLiteral(errors, "$.files.tokens", value.tokens, "tokens.css");
if (value.components !== undefined) {
expectLiteral(errors, "$.files.components", value.components, "components.html");
}
}
function rejectUnknownKeys(
errors: string[],
pathLabel: string,
value: Record<string, unknown>,
allowed: ReadonlySet<string>,
): void {
for (const key of Object.keys(value)) {
if (!allowed.has(key)) errors.push(`${pathLabel}.${key} is not part of the v1 design-system project schema`);
}
}
function expectLiteral(
errors: string[],
pathLabel: string,
value: unknown,
expected: string,
): void {
if (value !== expected) errors.push(`${pathLabel} must be ${JSON.stringify(expected)}`);
}
function expectNonEmptyString(errors: string[], pathLabel: string, value: unknown): void {
if (typeof value !== "string" || value.trim().length === 0) {
errors.push(`${pathLabel} must be a non-empty string`);
}
}
function expectSlug(errors: string[], pathLabel: string, value: unknown): void {
if (typeof value !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) {
errors.push(`${pathLabel} must be a lowercase slug matching /^[a-z0-9]+(?:-[a-z0-9]+)*$/`);
}
}
function expectIsoDateTime(errors: string[], pathLabel: string, value: unknown): void {
if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
errors.push(`${pathLabel} must be an ISO-like datetime string`);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -0,0 +1,16 @@
{
"schemaVersion": "od-design-system-project/v1",
"id": "default",
"name": "Neutral Modern",
"category": "Starter",
"description": "A clean, product-oriented default for B2B tools, dashboards, and utility pages.",
"source": {
"type": "bundled",
"origin": "hand-authored"
},
"files": {
"design": "DESIGN.md",
"tokens": "tokens.css",
"components": "components.html"
}
}

View file

@ -164,6 +164,30 @@ export interface DesignSystemResponse {
designSystem: DesignSystemDetail;
}
export interface ImportLocalDesignSystemRequest {
/** Absolute local project directory selected by the user. */
baseDir: string;
/** Optional display name override for the generated design-system project. */
name?: string;
}
export interface ImportLocalDesignSystemResponse {
designSystem: DesignSystemSummary;
}
export interface ImportGitHubDesignSystemRequest {
/** Public GitHub repository URL, e.g. https://github.com/owner/repo. */
githubUrl: string;
/** Optional branch to clone. Defaults to the repository default branch. */
branch?: string;
/** Optional display name override for the generated design-system project. */
name?: string;
}
export interface ImportGitHubDesignSystemResponse {
designSystem: DesignSystemSummary;
}
export interface HealthResponse {
ok: true;
service?: 'daemon';

View file

@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION,
validateDesignSystemProjectManifest,
} from "../design-systems/_schema/manifest.schema.ts";
test("design-system project manifest schema accepts the v1 minimum shape", () => {
const result = validateDesignSystemProjectManifest({
schemaVersion: DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION,
id: "cherry-studio",
name: "Cherry Studio",
category: "Imported",
description: "Extracted from an existing project.",
source: {
type: "github",
url: "https://github.com/cherryhq/cherry-studio",
branch: "main",
commit: "abc123",
importedAt: "2026-05-18T00:00:00.000Z",
},
files: {
design: "DESIGN.md",
tokens: "tokens.css",
},
});
assert.equal(result.ok, true);
if (result.ok) {
assert.equal(result.manifest.files.design, "DESIGN.md");
assert.equal(result.manifest.files.tokens, "tokens.css");
assert.equal(result.manifest.files.components, undefined);
}
});
test("design-system project manifest schema keeps components.html optional but fixed when declared", () => {
const accepted = validateDesignSystemProjectManifest({
schemaVersion: DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION,
id: "default",
name: "Neutral Modern",
category: "Starter",
source: { type: "bundled", origin: "hand-authored" },
files: {
design: "DESIGN.md",
tokens: "tokens.css",
components: "components.html",
},
});
assert.equal(accepted.ok, true);
const rejected = validateDesignSystemProjectManifest({
schemaVersion: DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION,
id: "default",
name: "Neutral Modern",
category: "Starter",
source: { type: "bundled" },
files: {
design: "DESIGN.md",
tokens: "tokens.css",
components: "preview/components.html",
},
});
assert.equal(rejected.ok, false);
if (!rejected.ok) {
assert.match(rejected.errors.join("\n"), /\$\.files\.components/);
}
});
test("design-system project manifest schema rejects path drift and unknown keys", () => {
const result = validateDesignSystemProjectManifest({
schemaVersion: DESIGN_SYSTEM_PROJECT_SCHEMA_VERSION,
id: "Bad Slug",
name: "Bad",
category: "Imported",
source: {
type: "local",
path: "/tmp/project",
unexpected: true,
},
files: {
design: "design.md",
tokens: "colors.css",
},
extra: "field",
});
assert.equal(result.ok, false);
if (!result.ok) {
const errors = result.errors.join("\n");
assert.match(errors, /\$\.id/);
assert.match(errors, /\$\.source\.unexpected/);
assert.match(errors, /\$\.files\.design/);
assert.match(errors, /\$\.files\.tokens/);
assert.match(errors, /\$\.extra/);
}
});

View file

@ -0,0 +1,108 @@
/*
* scripts/check-design-system-manifests.ts
*
* Guard for the Design System Project contract. PR1 only validates folders
* that opt into the project shape by shipping `manifest.json`; legacy
* DESIGN.md-only systems remain valid and are intentionally skipped.
*
* Run standalone: `pnpm exec tsx scripts/check-design-system-manifests.ts`
* Or as part of `pnpm guard` (registered in scripts/guard.ts).
* */
import { access, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseDesignSystemProjectManifest } from "../design-systems/_schema/manifest.schema.ts";
const repoRoot = path.resolve(import.meta.dirname, "..");
const designSystemsRoot = path.join(repoRoot, "design-systems");
const SKIPPED_DIRECTORIES = new Set(["_schema"]);
function toRepositoryPath(filePath: string): string {
return path.relative(repoRoot, filePath).split(path.sep).join("/");
}
async function exists(filePath: string): Promise<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
async function discoverManifestPaths(): Promise<string[]> {
let entries;
try {
entries = await readdir(designSystemsRoot, { withFileTypes: true });
} catch {
return [];
}
const manifestPaths: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || SKIPPED_DIRECTORIES.has(entry.name)) continue;
const manifestPath = path.join(designSystemsRoot, entry.name, "manifest.json");
if (await exists(manifestPath)) manifestPaths.push(manifestPath);
}
manifestPaths.sort((a, b) => a.localeCompare(b));
return manifestPaths;
}
export async function checkDesignSystemManifests(): Promise<boolean> {
const manifestPaths = await discoverManifestPaths();
const violations: string[] = [];
for (const manifestPath of manifestPaths) {
const brandRoot = path.dirname(manifestPath);
const folderSlug = path.basename(brandRoot);
const repositoryManifestPath = toRepositoryPath(manifestPath);
const parsed = parseDesignSystemProjectManifest(await readFile(manifestPath, "utf8"));
if (!parsed.ok) {
for (const error of parsed.errors) violations.push(`${repositoryManifestPath}: ${error}`);
continue;
}
const manifest = parsed.manifest;
if (manifest.id !== folderSlug) {
violations.push(`${repositoryManifestPath}: $.id must match folder slug "${folderSlug}"`);
}
const requiredFiles = [
manifest.files.design,
manifest.files.tokens,
...(manifest.files.components === undefined ? [] : [manifest.files.components]),
];
for (const fileName of requiredFiles) {
const target = path.join(brandRoot, fileName);
if (!(await exists(target))) {
violations.push(`${repositoryManifestPath}: ${fileName} is declared but ${toRepositoryPath(target)} does not exist`);
}
}
if (manifest.assetsDir !== undefined && !(await exists(path.join(brandRoot, manifest.assetsDir)))) {
violations.push(`${repositoryManifestPath}: assetsDir is declared but ${manifest.assetsDir}/ does not exist`);
}
if (manifest.previewDir !== undefined && !(await exists(path.join(brandRoot, manifest.previewDir)))) {
violations.push(`${repositoryManifestPath}: previewDir is declared but ${manifest.previewDir}/ does not exist`);
}
}
if (violations.length > 0) {
console.error("Design system manifest violations:");
for (const violation of violations) console.error(`- ${violation}`);
return false;
}
console.log(
`Design system manifest check passed: ${manifestPaths.length} project manifest${manifestPaths.length === 1 ? "" : "s"} valid; DESIGN.md-only systems skipped.`,
);
return true;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const ok = await checkDesignSystemManifests();
if (!ok) process.exitCode = 1;
}

View file

@ -1,6 +1,7 @@
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { checkDesignSystemManifests } from "./check-design-system-manifests.ts";
import { checkDesignSystemComponentFixtureReport } from "./check-components-fixtures.ts";
import { checkDesignSystemFlagParity } from "./check-design-system-flag-parity.ts";
import { checkComponentsManifestExtraction } from "./check-components-manifest-extraction.ts";
@ -706,6 +707,7 @@ const checks: GuardCheck[] = [
{ name: "web test layout", run: checkWebTestLayout },
{ name: "tools layout", run: checkToolsLayout },
{ name: "style policy", run: checkStylePolicy },
{ name: "design system manifests", run: checkDesignSystemManifests },
{ name: "design system component fixture report", run: checkDesignSystemComponentFixtureReport },
{ name: "design system token-fixture sync", run: checkDesignSystemTokenFixtureSync },
{ name: "design system A1 required tokens", run: checkDesignSystemA1RequiredTokens },