improved a lot of things
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export * from './schema.js';
|
||||
export * from './validator.js';
|
||||
export * from './tokens.js';
|
||||
@@ -0,0 +1,111 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Token schemas ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const ColorTokenSchema = z.object({
|
||||
value: z.string().regex(/^#[0-9A-Fa-f]{3,8}$|^rgba?\(|^hsl/, 'Must be a valid CSS color'),
|
||||
/** Fluent 2 token name this maps to, e.g. "colorBrandBackground" */
|
||||
fluentToken: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const TypographyTokenSchema = z.object({
|
||||
fontFamily: z.string().optional(),
|
||||
fontSize: z.union([z.string(), z.number()]).optional(),
|
||||
fontWeight: z.union([z.string(), z.number()]).optional(),
|
||||
lineHeight: z.union([z.string(), z.number()]).optional(),
|
||||
letterSpacing: z.union([z.string(), z.number()]).optional(),
|
||||
fluentToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export const SpacingTokenSchema = z.object({
|
||||
value: z.union([z.string(), z.number()]),
|
||||
fluentToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export const MotionTokenSchema = z.object({
|
||||
duration: z.string().optional(),
|
||||
easing: z.string().optional(),
|
||||
fluentToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export const TokensSchema = z.object({
|
||||
colors: z.record(ColorTokenSchema).optional(),
|
||||
typography: z.record(TypographyTokenSchema).optional(),
|
||||
spacing: z.record(SpacingTokenSchema).optional(),
|
||||
motion: z.record(MotionTokenSchema).optional(),
|
||||
});
|
||||
|
||||
// ── Component rules ───────────────────────────────────────────────────────────
|
||||
|
||||
export const PropRuleSchema = z.object({
|
||||
allowed: z.array(z.unknown()).optional(),
|
||||
forbidden: z.array(z.unknown()).optional(),
|
||||
required: z.boolean().optional(),
|
||||
type: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ComponentRuleSchema = z.object({
|
||||
/** Allowed prop values, keyed by prop name */
|
||||
props: z.record(PropRuleSchema).optional(),
|
||||
/** Fluent 2 variants that are explicitly forbidden */
|
||||
forbiddenVariants: z.array(z.string()).optional(),
|
||||
/** ARIA attributes that are required on this component */
|
||||
requiredAria: z.array(z.string()).optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
// ── Screen rules ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const ScreenRuleSchema = z.object({
|
||||
/** Components that are allowed to appear on this screen */
|
||||
allowedComponents: z.array(z.string()).optional(),
|
||||
/** Components that must be present on this screen */
|
||||
requiredSections: z.array(z.string()).optional(),
|
||||
layout: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
// ── Voice / tone ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const VoiceRuleSchema = z.object({
|
||||
tone: z.string().optional(),
|
||||
maxSentenceLength: z.number().optional(),
|
||||
avoidWords: z.array(z.string()).optional(),
|
||||
preferWords: z.array(z.string()).optional(),
|
||||
examples: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// ── Accessibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const AccessibilitySchema = z.object({
|
||||
wcagLevel: z.enum(['A', 'AA', 'AAA']).optional(),
|
||||
contrastRatio: z.number().optional(),
|
||||
customRules: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// ── Design Language File ──────────────────────────────────────────────────────
|
||||
|
||||
export const DesignLanguageFileBodySchema = z.object({
|
||||
/** Semantic version, e.g. "1.0.0" */
|
||||
version: z.string().optional(),
|
||||
/** Human-readable name, e.g. "Acme Design System" */
|
||||
name: z.string().optional(),
|
||||
|
||||
tokens: TokensSchema.optional(),
|
||||
|
||||
/** Per-component rules, keyed by component display name */
|
||||
components: z.record(ComponentRuleSchema).optional(),
|
||||
|
||||
/** Per-screen rules, keyed by screen name or route pattern */
|
||||
screens: z.record(ScreenRuleSchema).optional(),
|
||||
|
||||
voice: VoiceRuleSchema.optional(),
|
||||
|
||||
accessibility: AccessibilitySchema.optional(),
|
||||
});
|
||||
|
||||
export type DesignLanguageFileBody = z.infer<typeof DesignLanguageFileBodySchema>;
|
||||
export type Tokens = z.infer<typeof TokensSchema>;
|
||||
export type ComponentRule = z.infer<typeof ComponentRuleSchema>;
|
||||
export type ScreenRule = z.infer<typeof ScreenRuleSchema>;
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { DesignLanguageFileBody } from './schema.js';
|
||||
|
||||
// ── Fluent 2 token mapping ────────────────────────────────────────────────────
|
||||
// Maps DLF token names to Fluent 2 (Griffel) CSS custom property names.
|
||||
// A token entry with a `fluentToken` field overrides the default Fluent 2 value.
|
||||
|
||||
export type FluentTokenMap = Record<string, string>;
|
||||
|
||||
/** Extract color token overrides from a DLF as a Fluent 2 token map. */
|
||||
export function extractColorTokens(dlf: DesignLanguageFileBody): FluentTokenMap {
|
||||
const out: FluentTokenMap = {};
|
||||
const colors = dlf.tokens?.colors ?? {};
|
||||
for (const [, token] of Object.entries(colors)) {
|
||||
if (!token) continue;
|
||||
if (token.fluentToken) {
|
||||
out[token.fluentToken] = token.value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Convert a Fluent 2 token map to CSS custom properties for injection. */
|
||||
export function tokensToCssVars(tokens: FluentTokenMap): string {
|
||||
const entries = Object.entries(tokens)
|
||||
.map(([name, value]) => ` --${camelToKebab(name)}: ${value};`)
|
||||
.join('\n');
|
||||
return `:root {\n${entries}\n}`;
|
||||
}
|
||||
|
||||
/** Convert a DLF to a CSS var block suitable for injection into the renderer iframe. */
|
||||
export function dlfToCssVars(dlf: DesignLanguageFileBody): string {
|
||||
return tokensToCssVars(extractColorTokens(dlf));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function camelToKebab(str: string): string {
|
||||
return str.replace(/([A-Z])/g, m => `-${m.toLowerCase()}`);
|
||||
}
|
||||
|
||||
/** Build a short human-readable summary of the DLF's token count for logging. */
|
||||
export function dlfSummary(dlf: DesignLanguageFileBody): string {
|
||||
const tokenCounts = {
|
||||
colors: Object.keys(dlf.tokens?.colors ?? {}).length,
|
||||
typography: Object.keys(dlf.tokens?.typography ?? {}).length,
|
||||
spacing: Object.keys(dlf.tokens?.spacing ?? {}).length,
|
||||
components: Object.keys(dlf.components ?? {}).length,
|
||||
screens: Object.keys(dlf.screens ?? {}).length,
|
||||
};
|
||||
return Object.entries(tokenCounts)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([k, v]) => `${v} ${k}`)
|
||||
.join(', ');
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { DesignLanguageFileBodySchema, type DesignLanguageFileBody } from './schema.js';
|
||||
import type { ZodError } from 'zod';
|
||||
|
||||
// ── Validation result ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface ValidationSuccess {
|
||||
valid: true;
|
||||
dlf: DesignLanguageFileBody;
|
||||
}
|
||||
|
||||
export interface ValidationFailure {
|
||||
valid: false;
|
||||
errors: ValidationError[];
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type ValidationResult = ValidationSuccess | ValidationFailure;
|
||||
|
||||
// ── Parse & validate ──────────────────────────────────────────────────────────
|
||||
|
||||
export function validateDesignLanguageFile(input: unknown): ValidationResult {
|
||||
const result = DesignLanguageFileBodySchema.safeParse(input);
|
||||
if (result.success) {
|
||||
return { valid: true, dlf: result.data };
|
||||
}
|
||||
return { valid: false, errors: formatZodErrors(result.error) };
|
||||
}
|
||||
|
||||
function formatZodErrors(error: ZodError): ValidationError[] {
|
||||
return error.errors.map(e => ({
|
||||
path: e.path.join('.') || '(root)',
|
||||
message: e.message,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Runtime constraint checks ─────────────────────────────────────────────────
|
||||
// These run during visual edits and AI completions to catch violations
|
||||
// against the active DLF before they're shown to the user.
|
||||
|
||||
export interface ViolationCheck {
|
||||
/** Name of the component being checked */
|
||||
componentName: string;
|
||||
/** Props being applied */
|
||||
props: Record<string, unknown>;
|
||||
/** The active DLF */
|
||||
dlf: DesignLanguageFileBody;
|
||||
}
|
||||
|
||||
export interface Violation {
|
||||
prop: string;
|
||||
value: unknown;
|
||||
message: string;
|
||||
severity: 'error' | 'warning';
|
||||
}
|
||||
|
||||
export function checkComponentConstraints(check: ViolationCheck): Violation[] {
|
||||
const { componentName, props, dlf } = check;
|
||||
const violations: Violation[] = [];
|
||||
|
||||
const componentRule = dlf.components?.[componentName];
|
||||
if (!componentRule) return violations;
|
||||
|
||||
const { props: propRules } = componentRule;
|
||||
if (!propRules) return violations;
|
||||
|
||||
for (const [propKey, rule] of Object.entries(propRules)) {
|
||||
const value = props[propKey];
|
||||
|
||||
// Use hasOwnProperty to distinguish "key absent" from "key set to undefined".
|
||||
// Under exactOptionalPropertyTypes these are semantically different.
|
||||
if (rule.required && !Object.prototype.hasOwnProperty.call(props, propKey)) {
|
||||
violations.push({ prop: propKey, value, message: `"${propKey}" is required by design system rules`, severity: 'error' });
|
||||
}
|
||||
|
||||
if (rule.forbidden && value !== undefined && rule.forbidden.includes(value)) {
|
||||
violations.push({ prop: propKey, value, message: `"${propKey}=${String(value)}" is forbidden by design system rules`, severity: 'error' });
|
||||
}
|
||||
|
||||
if (rule.allowed && value !== undefined && !rule.allowed.includes(value)) {
|
||||
violations.push({ prop: propKey, value, message: `"${propKey}=${String(value)}" is not in the allowed values list`, severity: 'warning' });
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
Reference in New Issue
Block a user