239 lines
9.2 KiB
TypeScript
239 lines
9.2 KiB
TypeScript
import type { DesignDiagnostic, DesignSystem, DesignToken, TokenType } from "./types.ts";
|
|
import { validateRecipes } from "./recipes.ts";
|
|
|
|
export const DESIGN_SYSTEM_SCHEMA_VERSION = 1;
|
|
export const TOKEN_TYPES: readonly TokenType[] = [
|
|
"color",
|
|
"dimension",
|
|
"number",
|
|
"fontFamily",
|
|
"duration",
|
|
"shadow",
|
|
"fontWeight",
|
|
"colorScheme",
|
|
];
|
|
const forbidden = new Set(["__proto__", "prototype", "constructor"]);
|
|
export const validName = (value: string): boolean =>
|
|
/^[a-zA-Z][\w.-]*$/.test(value) && !value.split(/[.-]/).some((part) => forbidden.has(part));
|
|
export const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
typeof v === "object" &&
|
|
v !== null &&
|
|
!Array.isArray(v) &&
|
|
[Object.prototype, null].includes(Object.getPrototypeOf(v));
|
|
const length = /^-?(?:\d*\.)?\d+(?:px|rem|em|%|vw|vh|dvh|svh|ch|ex|vmin|vmax)$/;
|
|
|
|
/** Deliberately bounded CSS subset. References use { ref }, never arbitrary var()/url(). */
|
|
export function validTokenValue(type: TokenType, value: unknown): boolean {
|
|
if (isRecord(value))
|
|
return Object.keys(value).length === 1 && typeof value.ref === "string" && validName(value.ref);
|
|
if (typeof value === "number")
|
|
return (
|
|
Number.isFinite(value) &&
|
|
(type === "number" || (type === "fontWeight" && value >= 1 && value <= 1000))
|
|
);
|
|
if (
|
|
typeof value !== "string" ||
|
|
!value.trim() ||
|
|
value.length > 2048 ||
|
|
/[;{}<>\\]|\/\*|\*\//.test(value) ||
|
|
[...value].some((character) => character.charCodeAt(0) < 32)
|
|
)
|
|
return false;
|
|
if (type === "colorScheme") return value === "light" || value === "dark";
|
|
if (type === "dimension") return value === "0" || length.test(value);
|
|
if (type === "duration") return /^(?:\d*\.)?\d+(ms|s)$/.test(value);
|
|
if (type === "fontWeight") return /^(normal|bold|[1-9]\d{0,2}|1000)$/.test(value);
|
|
if (type === "number") return /^-?(?:\d*\.)?\d+$/.test(value) && Number.isFinite(Number(value));
|
|
if (type === "fontFamily")
|
|
return /^(?:"[\w -]+"|'[\w -]+'|[a-zA-Z][\w -]*)(?:\s*,\s*(?:"[\w -]+"|'[\w -]+'|[a-zA-Z][\w -]*))*$/.test(
|
|
value,
|
|
);
|
|
if (type === "color") {
|
|
if (
|
|
/^(?:#[\da-f]{3}|#[\da-f]{4}|#[\da-f]{6}|#[\da-f]{8})$/i.test(value) ||
|
|
/^(?:transparent|currentColor|black|white|red|green|blue|gray|grey|orange|purple|yellow|pink|rebeccapurple)$/i.test(
|
|
value,
|
|
)
|
|
)
|
|
return true;
|
|
const call = /^(rgb|rgba|hsl|hsla|oklch|oklab|lab|lch)\(([^()]+)\)$/i.exec(value);
|
|
if (!call) return false;
|
|
const args = call[2]!.trim();
|
|
const kind = call[1]!.toLowerCase();
|
|
let channels: string[];
|
|
if (args.includes(",")) {
|
|
if (args.includes("/") || !["rgb", "rgba", "hsl", "hsla"].includes(kind)) return false;
|
|
channels = args.split(/\s*,\s*/);
|
|
} else {
|
|
const sections = args.split(/\s*\/\s*/);
|
|
if (sections.length > 2) return false;
|
|
channels = sections[0]!.trim().split(/\s+/);
|
|
if (channels.length !== 3) return false;
|
|
if (sections.length === 2) channels.push(sections[1]!);
|
|
}
|
|
return (
|
|
(channels.length === 3 || channels.length === 4) &&
|
|
channels.every((channel, index) => {
|
|
const hue =
|
|
(["hsl", "hsla"].includes(kind) && index === 0) ||
|
|
(["lch", "oklch"].includes(kind) && index === 2);
|
|
const pattern = hue ? /^[+-]?(?:\d*\.)?\d+(?:deg)?$/ : /^[+-]?(?:\d*\.)?\d+%?$/;
|
|
if (!pattern.test(channel) || !Number.isFinite(parseFloat(channel))) return false;
|
|
return (
|
|
!(["hsl", "hsla"].includes(kind) && (index === 1 || index === 2)) || channel.endsWith("%")
|
|
);
|
|
})
|
|
);
|
|
}
|
|
if (type === "shadow") {
|
|
if (value === "none") return true;
|
|
const shadows = value.split(/,(?![^()]*\))/);
|
|
return shadows.every((shadow) => {
|
|
let colors = 0;
|
|
const dimensions = shadow
|
|
.replace(
|
|
/(?:rgba?|hsla?|oklch|oklab|lab|lch)\([^()]*\)|#[\da-f]+|\b(?:black|white|transparent|currentColor)\b/gi,
|
|
(color) => {
|
|
if (!validTokenValue("color", color)) return "INVALID";
|
|
colors++;
|
|
return "";
|
|
},
|
|
)
|
|
.trim()
|
|
.split(/\s+/);
|
|
const inset = dimensions.indexOf("inset");
|
|
if (inset !== -1) dimensions.splice(inset, 1);
|
|
return (
|
|
colors <= 1 &&
|
|
dimensions.length >= 2 &&
|
|
dimensions.length <= 4 &&
|
|
dimensions.every((part) => part === "0" || (length.test(part) && !part.endsWith("%"))) &&
|
|
(dimensions.length < 3 || parseFloat(dimensions[2]!) >= 0)
|
|
);
|
|
});
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function validateDesignSystem(input: unknown): DesignDiagnostic[] {
|
|
const issues: DesignDiagnostic[] = [];
|
|
const error = (code: string, path: string, message: string) =>
|
|
issues.push({ code, path, message });
|
|
if (!isRecord(input))
|
|
return [{ code: "WRN-DS-SCHEMA", path: "$", message: "Expected a data-only object." }];
|
|
const fields = new Set([
|
|
"schemaVersion",
|
|
"id",
|
|
"name",
|
|
"version",
|
|
"description",
|
|
"googleFonts",
|
|
"extends",
|
|
"tokens",
|
|
"modes",
|
|
"densities",
|
|
"defaultMode",
|
|
"recipes",
|
|
]);
|
|
for (const key of Object.keys(input).sort())
|
|
if (!fields.has(key)) error("WRN-DS-FIELD", key, "Unknown or unsupported field.");
|
|
if (input.schemaVersion !== 1)
|
|
error("WRN-DS-VERSION", "schemaVersion", "Only schemaVersion 1 is supported.");
|
|
for (const key of ["id", "name", "version"] as const)
|
|
if (typeof input[key] !== "string" || !input[key].trim())
|
|
error("WRN-DS-SCHEMA", key, "Expected a nonempty string.");
|
|
if (typeof input.id === "string" && !validName(input.id))
|
|
error("WRN-DS-NAME", "id", "Invalid system identifier.");
|
|
if (input.description !== undefined && typeof input.description !== "string")
|
|
error("WRN-DS-SCHEMA", "description", "Expected a string.");
|
|
if (
|
|
input.extends !== undefined &&
|
|
(!Array.isArray(input.extends) ||
|
|
input.extends.some((v) => typeof v !== "string" || !validName(v)))
|
|
)
|
|
error("WRN-DS-SCHEMA", "extends", "Expected system identifier array.");
|
|
if (
|
|
input.defaultMode !== undefined &&
|
|
(typeof input.defaultMode !== "string" || !validName(input.defaultMode))
|
|
)
|
|
error("WRN-DS-NAME", "defaultMode", "Invalid mode name.");
|
|
if (
|
|
input.googleFonts !== undefined &&
|
|
(!Array.isArray(input.googleFonts) ||
|
|
input.googleFonts.length > 10 ||
|
|
input.googleFonts.some(
|
|
(name: unknown) =>
|
|
typeof name !== "string" ||
|
|
!/^[A-Za-z][A-Za-z0-9 ]{0,79}$/.test(name) ||
|
|
name.trim() !== name,
|
|
))
|
|
)
|
|
error(
|
|
"WRN-DS-SCHEMA",
|
|
"googleFonts",
|
|
"Choose up to ten Google Font family names using letters, numbers and spaces.",
|
|
);
|
|
const map = (value: unknown, path: string) => {
|
|
if (!isRecord(value)) {
|
|
error("WRN-DS-SCHEMA", path, "Expected a token map.");
|
|
return;
|
|
}
|
|
for (const [key, token] of Object.entries(value).sort()) {
|
|
const at = `${path}.${key}`;
|
|
if (!validName(key)) error("WRN-DS-NAME", at, "Invalid token name.");
|
|
if (!isRecord(token)) {
|
|
error("WRN-DS-SCHEMA", at, "Expected a token object.");
|
|
continue;
|
|
}
|
|
for (const field of Object.keys(token))
|
|
if (!["type", "value", "legacy", "description"].includes(field))
|
|
error("WRN-DS-FIELD", `${at}.${field}`, "Unknown token field.");
|
|
if (!TOKEN_TYPES.includes(token.type as TokenType))
|
|
error("WRN-DS-TYPE", `${at}.type`, "Unsupported token type.");
|
|
else if (!validTokenValue(token.type as TokenType, token.value))
|
|
error("WRN-DS-VALUE", `${at}.value`, "Invalid or unsupported token value.");
|
|
if (
|
|
token.legacy !== undefined &&
|
|
(typeof token.legacy !== "string" ||
|
|
!/^[a-z][a-z0-9-]*$/.test(token.legacy) ||
|
|
!validName(token.legacy))
|
|
)
|
|
error("WRN-DS-NAME", `${at}.legacy`, "Invalid legacy token suffix.");
|
|
if (token.legacy === "color-scheme" && token.type !== "colorScheme")
|
|
error("WRN-DS-TYPE", `${at}.legacy`, "color-scheme requires colorScheme type.");
|
|
if (token.description !== undefined && typeof token.description !== "string")
|
|
error("WRN-DS-SCHEMA", `${at}.description`, "Expected a string.");
|
|
}
|
|
};
|
|
map(input.tokens, "tokens");
|
|
for (const axis of ["modes", "densities"])
|
|
if (input[axis] !== undefined) {
|
|
if (!isRecord(input[axis])) error("WRN-DS-SCHEMA", axis, "Expected a map.");
|
|
else
|
|
for (const [key, tokens] of Object.entries(input[axis]).sort()) {
|
|
if (!validName(key)) error("WRN-DS-NAME", `${axis}.${key}`, "Invalid axis name.");
|
|
map(tokens, `${axis}.${key}`);
|
|
}
|
|
}
|
|
issues.push(...validateRecipes(input.recipes));
|
|
return issues;
|
|
}
|
|
|
|
export class DesignSystemError extends Error {
|
|
constructor(public readonly diagnostics: DesignDiagnostic[]) {
|
|
super(diagnostics.map((d) => `${d.code} ${d.path}: ${d.message}`).join("\n"));
|
|
this.name = "DesignSystemError";
|
|
}
|
|
}
|
|
export function assertDesignSystem(input: unknown): asserts input is DesignSystem {
|
|
const issues = validateDesignSystem(input);
|
|
if (issues.length) throw new DesignSystemError(issues);
|
|
}
|
|
export function defineDesignSystem<T extends DesignSystem>(input: T): T {
|
|
assertDesignSystem(input);
|
|
return input;
|
|
}
|
|
export function isReference(value: DesignToken["value"]): value is { ref: string } {
|
|
return isRecord(value);
|
|
}
|