Files
workbuddy_skills/open-design/design-systems/_schema/manifest.schema.ts
T
admin 5afeb6b78a ④段整套同步(六节 + P/S/F/J + 模板)+ open-design 移到技能根
一、④段整套同步(用户选候选 A)
- 新增 `references/stage-proto-doc/assets/产品文档模板.html`(16 KB/247 行,从备份拷、转 LF)。
- ④段 SKILL.md 换成备份 15:28 那版,并按模板口径改齐:
  · 「产品文档框架」从「四块+一附录」改成**六节** —— 页面结构/功能清单/使用场景/操作流程/
    跨页与界面状态/待确认与偏离;与模板同构,⛔ 不从空白页重写结构。
  · 编号从 R/S/F 改成 **P 页面/S 场景/F 功能/J 二级功能**,各自唯一、按展示顺序通排。
  · 保留旧版有价值的要求:每项功能补齐 做什么/入口在哪/输入边界/异常与恢复;重点功能三处标红;
    横向流程图每步写清页面/功能/操作/完成判据。
  · 完成标准「七节」→「六节」,自查清单同步换掉「需求」那批条目,补模板与配图两条。
  · 路径 `designs/<项目>/` → `docs/pm/<项目>/ui/`(2 处)。
  · 补回本机那条「每份产出文档开头都要有一张图」的引用(指向主 skill,段内不复述)。
- 主 skill 同步:四段结构表④行、④段沿革、画图小节(④段画页面结构图 + 用户路径)。

二、open-design 移到技能根(用户选候选 A)
- `git mv product-planning/references/stage-delivery/assets/open-design → open-design/`(4178 文件)。
- `.gitignore` 白名单加 `!/open-design/` —— 否则移出去就不再纳入版本控制。
- 引用改 11 处:路径形态 `assets/open-design/...` → `<技能根>/open-design/...`;
  「随段携带的自带件」改称「同根技能」;「路径怎么找」那条改成自带件(段内相对路径)与
  同根技能(`<技能根>/<技能名>/`)两类分开写。
- open-design 自己的 ATTRIBUTION.md 两处同步(它现在与 oil-ui-pro 同层)。
- 副作用(有益):命名闸门读数由 2063 降到 225 —— 此前它把 open-design 里 919 个第三方 md
  当成规则一起扫了;现在只扫本包 23 个 md。已确认④段的 H1 与 4a/4b/4c 子步仍在检查范围内。

三、验收
- check_naming.py --ws E:/ProgramData/AIProject/contentm_agent = 通过 225 / 失败 0 / 提示 27。
- 活文件里 `assets/open-design` 残留 0;工作区 CRLF 文件 0。
2026-10-15 21:17:28 +08:00

444 lines
16 KiB
TypeScript

/* ─────────────────────────────────────────────────────────────────────────
* design-systems/_schema/manifest.schema.ts
*
* Canonical contract for an OpenDesign 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.
*
* PR0 for the import-project structure also defines optional index fields
* for richer imported systems (`USAGE.md`, preview pages, source evidence,
* and a rebuildable component manifest cache). These fields are structural
* only in PR0: guards validate their paths and JSON shape, but runtime
* behavior remains unchanged until later PRs consume them.
*
* 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;
}
| {
readonly type: "shadcn";
/** Original user-supplied reference (CLI shorthand or item URL). */
readonly reference: string;
/** Resolved registry-item JSON URL that was fetched. */
readonly registryUrl?: string;
/** Registry item name within the source registry. */
readonly item?: string;
/** Registry homepage, when advertised by registry.json. */
readonly homepage?: 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 Design Tokens JSON derived from the final TOKEN_SCHEMA contract. */
readonly designTokens?: "design-tokens.json";
/** Optional Tailwind v4 @theme CSS derived from the final TOKEN_SCHEMA contract. */
readonly tailwind?: "tailwind-v4.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 DesignSystemProjectImportMode = "normalized" | "hybrid" | "verbatim";
export type DesignSystemProjectCraft = {
readonly applies: readonly string[];
readonly suggested: readonly string[];
readonly exemptions: readonly string[];
};
export type DesignSystemProjectFont = {
readonly family: string;
readonly file: string;
readonly weight?: number | string;
readonly style?: string;
};
export type DesignSystemProjectPreviewPage = {
readonly path: string;
readonly role?: string;
readonly title?: string;
};
export type DesignSystemProjectPreview = {
readonly dir: string;
readonly pages: readonly DesignSystemProjectPreviewPage[];
};
export type DesignSystemProjectSourceFiles = {
readonly scanned?: string;
readonly evidence?: string;
readonly tokens?: string;
readonly report?: string;
readonly snippets?: string;
};
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 legacy preview root. V1 fixes the directory name. */
readonly previewDir?: "preview";
/** Optional agent-facing router for richer imported packages. */
readonly usage?: string;
/** Optional rebuildable cache derived from components.html + tokens.css. */
readonly componentsManifest?: string;
/** Importer mode metadata. Defaults to hybrid for imported packages. */
readonly importMode?: DesignSystemProjectImportMode;
/** Optional craft metadata consumed by prompt assembly and guard checks. */
readonly craft?: DesignSystemProjectCraft;
/** Optional webfont files copied into the package. */
readonly fonts?: readonly DesignSystemProjectFont[];
/** Optional indexed preview pages for pull-channel and human review. */
readonly preview?: DesignSystemProjectPreview;
/** Optional imported-source evidence indexes. */
readonly sourceFiles?: DesignSystemProjectSourceFiles;
};
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",
"usage",
"componentsManifest",
"importMode",
"craft",
"fonts",
"preview",
"sourceFiles",
]);
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"]),
shadcn: new Set(["type", "reference", "registryUrl", "item", "homepage", "importedAt"]),
};
const ALLOWED_FILES_KEYS = new Set(["design", "tokens", "designTokens", "tailwind", "components"]);
const ALLOWED_CRAFT_KEYS = new Set(["applies", "suggested", "exemptions"]);
const ALLOWED_FONT_KEYS = new Set(["family", "file", "weight", "style"]);
const ALLOWED_PREVIEW_KEYS = new Set(["dir", "pages"]);
const ALLOWED_PREVIEW_PAGE_KEYS = new Set(["path", "role", "title"]);
const ALLOWED_SOURCE_FILES_KEYS = new Set(["scanned", "evidence", "tokens", "report", "snippets"]);
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 (value.usage !== undefined) expectSafeRelativePath(errors, "$.usage", value.usage);
if (value.componentsManifest !== undefined) {
expectSafeRelativePath(errors, "$.componentsManifest", value.componentsManifest);
}
if (value.importMode !== undefined) validateImportMode(errors, value.importMode);
if (value.craft !== undefined) validateCraft(errors, value.craft);
if (value.fonts !== undefined) validateFonts(errors, value.fonts);
if (value.preview !== undefined) validatePreview(errors, value.preview);
if (value.sourceFiles !== undefined) validateSourceFiles(errors, value.sourceFiles);
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" && type !== "shadcn") {
errors.push("$.source.type must be one of bundled, local, github, shadcn");
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;
}
if (type === "github") {
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);
return;
}
// shadcn registry import.
expectNonEmptyString(errors, "$.source.reference", value.reference);
if (value.registryUrl !== undefined) expectNonEmptyString(errors, "$.source.registryUrl", value.registryUrl);
if (value.item !== undefined) expectNonEmptyString(errors, "$.source.item", value.item);
if (value.homepage !== undefined) expectNonEmptyString(errors, "$.source.homepage", value.homepage);
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.designTokens !== undefined) {
expectLiteral(errors, "$.files.designTokens", value.designTokens, "design-tokens.json");
}
if (value.tailwind !== undefined) {
expectLiteral(errors, "$.files.tailwind", value.tailwind, "tailwind-v4.css");
}
if (value.components !== undefined) {
expectLiteral(errors, "$.files.components", value.components, "components.html");
}
}
function validateImportMode(errors: string[], value: unknown): void {
if (value !== "normalized" && value !== "hybrid" && value !== "verbatim") {
errors.push("$.importMode must be one of normalized, hybrid, verbatim");
}
}
function validateCraft(errors: string[], value: unknown): void {
if (!isRecord(value)) {
errors.push("$.craft must be an object");
return;
}
rejectUnknownKeys(errors, "$.craft", value, ALLOWED_CRAFT_KEYS);
expectSlugArray(errors, "$.craft.applies", value.applies);
expectSlugArray(errors, "$.craft.suggested", value.suggested);
expectSlugArray(errors, "$.craft.exemptions", value.exemptions);
}
function validateFonts(errors: string[], value: unknown): void {
if (!Array.isArray(value)) {
errors.push("$.fonts must be an array");
return;
}
value.forEach((font, index) => {
const pathLabel = `$.fonts[${index}]`;
if (!isRecord(font)) {
errors.push(`${pathLabel} must be an object`);
return;
}
rejectUnknownKeys(errors, pathLabel, font, ALLOWED_FONT_KEYS);
expectNonEmptyString(errors, `${pathLabel}.family`, font.family);
expectSafeRelativePath(errors, `${pathLabel}.file`, font.file);
if (font.weight !== undefined && typeof font.weight !== "number" && typeof font.weight !== "string") {
errors.push(`${pathLabel}.weight must be a number or string`);
}
if (font.style !== undefined) expectNonEmptyString(errors, `${pathLabel}.style`, font.style);
});
}
function validatePreview(errors: string[], value: unknown): void {
if (!isRecord(value)) {
errors.push("$.preview must be an object");
return;
}
rejectUnknownKeys(errors, "$.preview", value, ALLOWED_PREVIEW_KEYS);
expectSafeRelativePath(errors, "$.preview.dir", value.dir);
if (!Array.isArray(value.pages)) {
errors.push("$.preview.pages must be an array");
return;
}
value.pages.forEach((page, index) => {
const pathLabel = `$.preview.pages[${index}]`;
if (!isRecord(page)) {
errors.push(`${pathLabel} must be an object`);
return;
}
rejectUnknownKeys(errors, pathLabel, page, ALLOWED_PREVIEW_PAGE_KEYS);
expectSafeRelativePath(errors, `${pathLabel}.path`, page.path);
if (page.role !== undefined) expectNonEmptyString(errors, `${pathLabel}.role`, page.role);
if (page.title !== undefined) expectNonEmptyString(errors, `${pathLabel}.title`, page.title);
});
}
function validateSourceFiles(errors: string[], value: unknown): void {
if (!isRecord(value)) {
errors.push("$.sourceFiles must be an object");
return;
}
rejectUnknownKeys(errors, "$.sourceFiles", value, ALLOWED_SOURCE_FILES_KEYS);
for (const key of ALLOWED_SOURCE_FILES_KEYS) {
const sourcePath = value[key];
if (sourcePath !== undefined) expectSafeRelativePath(errors, `$.sourceFiles.${key}`, sourcePath);
}
}
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 expectSlugArray(errors: string[], pathLabel: string, value: unknown): void {
if (!Array.isArray(value)) {
errors.push(`${pathLabel} must be an array of lowercase slugs`);
return;
}
value.forEach((entry, index) => {
if (typeof entry !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry)) {
errors.push(`${pathLabel}[${index}] must be a lowercase slug matching /^[a-z0-9]+(?:-[a-z0-9]+)*$/`);
}
});
}
function expectSafeRelativePath(errors: string[], pathLabel: string, value: unknown): void {
if (typeof value !== "string" || value.trim().length === 0) {
errors.push(`${pathLabel} must be a non-empty relative path`);
return;
}
if (value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\")) {
errors.push(`${pathLabel} must be a safe relative path`);
return;
}
const segments = value.split("/");
if (segments.some((segment) => segment === "" || segment === "." || segment === "..")) {
errors.push(`${pathLabel} must be a safe relative path without empty, "." or ".." segments`);
}
}
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);
}