mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
51c51035b8
commit
f7eb82d7a5
19 changed files with 2012 additions and 25 deletions
161
apps/daemon/src/design-system-github-import.ts
Normal file
161
apps/daemon/src/design-system-github-import.ts
Normal 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);
|
||||
}
|
||||
595
apps/daemon/src/design-system-import.ts
Normal file
595
apps/daemon/src/design-system-import.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
127
apps/daemon/tests/design-system-github-import.test.ts
Normal file
127
apps/daemon/tests/design-system-github-import.test.ts
Normal 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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
110
apps/daemon/tests/design-system-import.test.ts
Normal file
110
apps/daemon/tests/design-system-import.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
224
design-systems/_schema/manifest.schema.ts
Normal file
224
design-systems/_schema/manifest.schema.ts
Normal 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);
|
||||
}
|
||||
16
design-systems/default/manifest.json
Normal file
16
design-systems/default/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
97
scripts/check-design-system-manifests.test.ts
Normal file
97
scripts/check-design-system-manifests.test.ts
Normal 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/);
|
||||
}
|
||||
});
|
||||
108
scripts/check-design-system-manifests.ts
Normal file
108
scripts/check-design-system-manifests.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Reference in a new issue