mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(plugins): token-map atom impl (Phase 6/7 entry slice)
Plan P1 / spec §10 / §21.3.1.
apps/daemon/src/plugins/atoms/token-map.ts ships the daemon-side
crosswalk behind the SKILL.md fragment landed in §3.M3. Given a
project cwd that already has a token bag (`code/tokens.json` from
design-extract OR `figma/tokens.json` from figma-extract) plus the
active design system's token vocabulary, the runner produces:
<cwd>/token-map/colors.json
<cwd>/token-map/typography.json
<cwd>/token-map/spacing.json
<cwd>/token-map/radius.json
<cwd>/token-map/shadow.json
<cwd>/token-map/unmatched.json [{ source, sourceName?, kind, reason, hint? }]
<cwd>/token-map/meta.json { sourceKind, generatedAt,
atomDigest, designSystemId?,
targetTokenCount,
sourceTokenCount,
matchedTokenCount }
Match strategies (deterministic, first-win):
1. Exact value match (#5b8def === #5b8def).
2. Normalised hex (#abc → #aabbcc, ignores case).
3. Fuzzy name (--color-primary → --ds-color-primary; strips
leading -- / ds- / odds- / theme- prefixes
and lower-cases).
Anything else lands unmatched[] with one of:
- 'no-target-equivalent' no target with same value/name
- 'target-collision' multiple sources mapped to one target
(first wins; subsequent listed unmatched
with hint pointing at the first claimant)
- 'invalid-source' malformed source value
Strict mode aborts on any unmatched; soft mode (default) populates
unmatched.json + continues so the human can audit.
Helper:
parseDesignSystemTokens(body) → DesignSystemToken[]
Heuristic DESIGN.md token extraction. Lifts CSS custom properties
(`--ds-color-fg: #111`) and markdown table rows that look like
token declarations. Daemon callers may pass a hand-curated bag
instead of relying on this parser.
Existing patch-edit typecheck noise fixed in passing:
- PatchStepRecord now extends Omit<RewriteStep, 'rationale'> so
optional override is sound under exactOptionalPropertyTypes.
- rationale assignment guarded against undefined.
Daemon tests: 1588 → 1600 (+12 cases on plugins-token-map: exact
match, normalised hex, fuzzy name, no-target-equivalent, target-
collision, strict mode abort, multi-kind crosswalk, code+figma
disk inputs round-trip + bucket persistence + missing-input error,
DESIGN.md CSS custom property + markdown table parsers).
Co-authored-by: Tom Huang <1043269994@qq.com>
This commit is contained in:
parent
01e94a92be
commit
bda5c0e3ed
4 changed files with 610 additions and 3 deletions
|
|
@ -46,10 +46,10 @@ import type { OwnershipEntry, RewriteStep } from './rewrite-plan.js';
|
|||
|
||||
export type PatchStepStatus = 'pending' | 'completed' | 'skipped' | 'failed';
|
||||
|
||||
export interface PatchStepRecord extends RewriteStep {
|
||||
export interface PatchStepRecord extends Omit<RewriteStep, 'rationale'> {
|
||||
rationale?: string;
|
||||
status?: PatchStepStatus;
|
||||
completedAt?: string;
|
||||
rationale?: string;
|
||||
}
|
||||
|
||||
export interface PatchReceiptEntry {
|
||||
|
|
@ -160,7 +160,7 @@ export async function applyPatchForStep(input: ApplyPatchInput): Promise<ApplyPa
|
|||
// Mark the step completed in plan/steps.json + write a receipt.
|
||||
step.status = 'completed';
|
||||
step.completedAt = new Date().toISOString();
|
||||
step.rationale = input.rationale ?? step.rationale;
|
||||
if (input.rationale) step.rationale = input.rationale;
|
||||
await fsp.writeFile(stepsPath, JSON.stringify(steps, null, 2) + '\n', 'utf8');
|
||||
const receiptDir = path.join(cwd, 'plan', 'receipts');
|
||||
await fsp.mkdir(receiptDir, { recursive: true });
|
||||
|
|
|
|||
412
apps/daemon/src/plugins/atoms/token-map.ts
Normal file
412
apps/daemon/src/plugins/atoms/token-map.ts
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
// Phase 6/7 entry slice / spec §10 / §21.3.1 — token-map atom.
|
||||
//
|
||||
// SKILL.md fragment ships at plugins/_official/atoms/token-map/.
|
||||
// The runner crosswalks an extracted token bag (output of
|
||||
// `design-extract` for code-migration, or `figma-extract` for
|
||||
// figma-migration) onto the active OD design system's token
|
||||
// vocabulary and writes the canonical mapping the SKILL.md fragment
|
||||
// promises:
|
||||
//
|
||||
// <cwd>/token-map/colors.json
|
||||
// <cwd>/token-map/typography.json
|
||||
// <cwd>/token-map/spacing.json
|
||||
// <cwd>/token-map/radius.json
|
||||
// <cwd>/token-map/shadow.json
|
||||
// <cwd>/token-map/unmatched.json — { source, reason }[]
|
||||
// <cwd>/token-map/meta.json — { sourceKind, generatedAt,
|
||||
// atomDigest, designSystemId? }
|
||||
//
|
||||
// Match strategy (deterministic, in this order):
|
||||
// 1. Exact value match (#abc === #abc).
|
||||
// 2. Normalised hex (#abc → #aabbcc, ignore case).
|
||||
// 3. Named source token AND a target with a matching name (e.g.
|
||||
// --primary-500 → ds-primary-500). Fuzzy match strips '--' /
|
||||
// 'ds-' prefixes and lower-cases.
|
||||
//
|
||||
// Anything else lands in unmatched[] with one of the reasons:
|
||||
// 'no-target-equivalent' — no target with the same value/name.
|
||||
// 'target-collision' — multiple sources map to the same target
|
||||
// (kept for the first source; subsequent
|
||||
// are listed unmatched with a hint).
|
||||
// 'invalid-source' — source token value is malformed.
|
||||
//
|
||||
// The atom is intentionally conservative: it never invents targets,
|
||||
// never relies on perceptual proximity (the SKILL.md fragment routes
|
||||
// that to the visual-diff evaluator). False negatives are preferable
|
||||
// to false positives — `unmatched.json` is the audit list the user
|
||||
// reviews.
|
||||
|
||||
import path from 'node:path';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { DesignExtractReport, DesignTokenEntry, DesignTokenKind } from './design-extract.js';
|
||||
|
||||
export interface DesignSystemToken {
|
||||
// Canonical token name (e.g. 'ds-primary-500', '--ds-color-fg').
|
||||
name: string;
|
||||
// Token kind. Loose because design systems vary (e.g. some collapse
|
||||
// 'spacing' + 'radius' into one scale); we mirror the same five
|
||||
// kinds design-extract emits.
|
||||
kind: DesignTokenKind;
|
||||
value: string;
|
||||
// Optional human description — surfaced in unmatched.json hints.
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface DesignSystemTokenBag {
|
||||
// Daemon-side caller fills this from the active design system's
|
||||
// DESIGN.md / tokens.json.
|
||||
id?: string;
|
||||
tokens: DesignSystemToken[];
|
||||
}
|
||||
|
||||
export interface TokenMapMatch {
|
||||
source: string; // raw input value (or token name when present)
|
||||
sourceName?: string;
|
||||
target: string; // matched target token name
|
||||
targetValue: string;
|
||||
via: 'exact' | 'normalised-hex' | 'name';
|
||||
kind: DesignTokenKind;
|
||||
// The source token's audit trail (file:line entries) so a reviewer
|
||||
// can audit "which target was chosen for which call site".
|
||||
sources: string[];
|
||||
}
|
||||
|
||||
export interface TokenMapUnmatched {
|
||||
source: string;
|
||||
sourceName?: string;
|
||||
kind: DesignTokenKind;
|
||||
reason: 'no-target-equivalent' | 'target-collision' | 'invalid-source';
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface TokenMapReport {
|
||||
colors: TokenMapMatch[];
|
||||
typography: TokenMapMatch[];
|
||||
spacing: TokenMapMatch[];
|
||||
radius: TokenMapMatch[];
|
||||
shadow: TokenMapMatch[];
|
||||
unmatched: TokenMapUnmatched[];
|
||||
meta: {
|
||||
sourceKind: 'figma' | 'code';
|
||||
generatedAt: string;
|
||||
atomDigest: string;
|
||||
designSystemId?: string;
|
||||
targetTokenCount: number;
|
||||
sourceTokenCount: number;
|
||||
matchedTokenCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TokenMapOptions {
|
||||
cwd: string;
|
||||
// Source bag. When omitted, the runner reads <cwd>/code/tokens.json
|
||||
// (preferred for code-migration) or falls back to
|
||||
// <cwd>/figma/tokens.json (figma-migration).
|
||||
source?: { kind: 'figma' | 'code'; report: DesignExtractReport };
|
||||
// The active design system's tokens. Caller supplies this; the atom
|
||||
// never reads filesystem directly so it stays unit-testable.
|
||||
designSystem: DesignSystemTokenBag;
|
||||
// Strict mode aborts when ANY source token can't be mapped.
|
||||
// Default 'soft' — populates unmatched[] and continues.
|
||||
strict?: boolean;
|
||||
}
|
||||
|
||||
const HEX_RE = /^#([0-9a-fA-F]{3,8})$/;
|
||||
|
||||
export async function runTokenMap(opts: TokenMapOptions): Promise<TokenMapReport> {
|
||||
const cwd = path.resolve(opts.cwd);
|
||||
|
||||
// Resolve source.
|
||||
let sourceKind: 'figma' | 'code';
|
||||
let source: DesignExtractReport;
|
||||
if (opts.source) {
|
||||
sourceKind = opts.source.kind;
|
||||
source = opts.source.report;
|
||||
} else {
|
||||
const codePath = path.join(cwd, 'code', 'tokens.json');
|
||||
const figmaPath = path.join(cwd, 'figma', 'tokens.json');
|
||||
if (await pathExists(codePath)) {
|
||||
sourceKind = 'code';
|
||||
source = JSON.parse(await fsp.readFile(codePath, 'utf8')) as DesignExtractReport;
|
||||
} else if (await pathExists(figmaPath)) {
|
||||
sourceKind = 'figma';
|
||||
source = JSON.parse(await fsp.readFile(figmaPath, 'utf8')) as DesignExtractReport;
|
||||
} else {
|
||||
throw new Error(`token-map: missing both code/tokens.json and figma/tokens.json (run design-extract or figma-extract first)`);
|
||||
}
|
||||
}
|
||||
|
||||
const targets = indexDesignSystem(opts.designSystem.tokens);
|
||||
const unmatched: TokenMapUnmatched[] = [];
|
||||
const claimed: Map<string, TokenMapMatch> = new Map();
|
||||
const buckets = {
|
||||
colors: [] as TokenMapMatch[],
|
||||
typography: [] as TokenMapMatch[],
|
||||
spacing: [] as TokenMapMatch[],
|
||||
radius: [] as TokenMapMatch[],
|
||||
shadow: [] as TokenMapMatch[],
|
||||
};
|
||||
|
||||
let sourceTokenCount = 0;
|
||||
let matchedTokenCount = 0;
|
||||
|
||||
for (const kind of ['colors', 'typography', 'spacing', 'radius', 'shadow'] as const) {
|
||||
for (const entry of source[kind]) {
|
||||
sourceTokenCount++;
|
||||
const result = matchOne(kind, entry, targets);
|
||||
if (!result.match) {
|
||||
unmatched.push(result.unmatched);
|
||||
continue;
|
||||
}
|
||||
const claimKey = result.match.target;
|
||||
if (claimed.has(claimKey)) {
|
||||
// Spec §21.3.1 target-collision: the second source claiming
|
||||
// the same target lands unmatched with a hint pointing at
|
||||
// the first claimant.
|
||||
const first = claimed.get(claimKey)!;
|
||||
unmatched.push({
|
||||
source: result.match.source,
|
||||
...(result.match.sourceName ? { sourceName: result.match.sourceName } : {}),
|
||||
kind: result.match.kind,
|
||||
reason: 'target-collision',
|
||||
hint: `target ${claimKey} already mapped from ${first.source}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
claimed.set(claimKey, result.match);
|
||||
buckets[kind as keyof typeof buckets].push(result.match);
|
||||
matchedTokenCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.strict && unmatched.length > 0) {
|
||||
throw new Error(`token-map (strict): ${unmatched.length} source tokens unmatched`);
|
||||
}
|
||||
|
||||
// Stable sort each bucket: by source value first, then by target.
|
||||
for (const k of Object.keys(buckets) as (keyof typeof buckets)[]) {
|
||||
buckets[k].sort((a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target));
|
||||
}
|
||||
unmatched.sort((a, b) => a.kind.localeCompare(b.kind) || a.source.localeCompare(b.source));
|
||||
|
||||
const meta: TokenMapReport['meta'] = {
|
||||
sourceKind,
|
||||
generatedAt: new Date().toISOString(),
|
||||
atomDigest: digestObject({ buckets, unmatched }),
|
||||
targetTokenCount: opts.designSystem.tokens.length,
|
||||
sourceTokenCount,
|
||||
matchedTokenCount,
|
||||
};
|
||||
if (opts.designSystem.id) meta.designSystemId = opts.designSystem.id;
|
||||
|
||||
const report: TokenMapReport = { ...buckets, unmatched, meta };
|
||||
|
||||
await fsp.mkdir(path.join(cwd, 'token-map'), { recursive: true });
|
||||
for (const k of Object.keys(buckets) as (keyof typeof buckets)[]) {
|
||||
await fsp.writeFile(
|
||||
path.join(cwd, 'token-map', `${k}.json`),
|
||||
JSON.stringify(buckets[k], null, 2) + '\n',
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
await fsp.writeFile(path.join(cwd, 'token-map', 'unmatched.json'), JSON.stringify(unmatched, null, 2) + '\n', 'utf8');
|
||||
await fsp.writeFile(path.join(cwd, 'token-map', 'meta.json'), JSON.stringify(meta, null, 2) + '\n', 'utf8');
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
// --- DESIGN.md token extraction (heuristic, used by daemon callers) ---
|
||||
|
||||
// Parse a DESIGN.md body for token declarations. Lifts:
|
||||
// - CSS custom property declarations (`--ds-color-fg: #111`)
|
||||
// - Markdown table rows of shape `| name | value | …`
|
||||
// The result is best-effort; daemon callers may pass a hand-curated
|
||||
// token list instead.
|
||||
export function parseDesignSystemTokens(body: string): DesignSystemToken[] {
|
||||
const out: DesignSystemToken[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// CSS custom properties.
|
||||
const cssRe = /--([a-z][a-z0-9-]*)\s*:\s*([^;\n]+)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = cssRe.exec(body)) !== null) {
|
||||
const name = `--${m[1]}`;
|
||||
const value = (m[2] ?? '').trim();
|
||||
if (!value) continue;
|
||||
const key = `${name}=${value}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({ name, value, kind: classifyKind(name, value) });
|
||||
}
|
||||
|
||||
// Markdown table rows. We require at least three pipes per line and
|
||||
// a value cell that looks like a hex / px / rem / shadow.
|
||||
for (const line of body.split('\n')) {
|
||||
if (!/\|/.test(line)) continue;
|
||||
const cells = line.split('|').map((c) => c.trim()).filter((c) => c.length > 0);
|
||||
if (cells.length < 2) continue;
|
||||
const [name, value] = cells;
|
||||
if (!name || !value) continue;
|
||||
if (/[A-Z]/.test(name) || /\s/.test(name)) continue; // skip header rows / human-prose
|
||||
const key = `${name}=${value}`;
|
||||
if (seen.has(key)) continue;
|
||||
if (!/^[#0-9.a-z(),%\s\-]+$/i.test(value)) continue;
|
||||
seen.add(key);
|
||||
out.push({ name, value, kind: classifyKind(name, value) });
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function classifyKind(name: string, value: string): DesignTokenKind {
|
||||
if (HEX_RE.test(value) || /^rgb|^hsl/.test(value)) return 'color';
|
||||
if (/font/i.test(name)) return 'typography';
|
||||
if (/(radius|rounded)/i.test(name)) return 'radius';
|
||||
if (/(shadow|elevation)/i.test(name)) return 'shadow';
|
||||
if (/(space|gap|padding|margin)/i.test(name) || /^(\d+(?:\.\d+)?)(?:px|rem|em)$/.test(value)) return 'spacing';
|
||||
return 'color';
|
||||
}
|
||||
|
||||
// --- internals -----------------------------------------------------
|
||||
|
||||
interface IndexedDesignSystem {
|
||||
byValue: Map<string, DesignSystemToken[]>; // exact value (case-preserving)
|
||||
byNormalisedHex: Map<string, DesignSystemToken[]>; // #aabbcc lowercase
|
||||
byFuzzyName: Map<string, DesignSystemToken[]>; // strip prefix + lowercase
|
||||
}
|
||||
|
||||
function indexDesignSystem(tokens: DesignSystemToken[]): IndexedDesignSystem {
|
||||
const byValue = new Map<string, DesignSystemToken[]>();
|
||||
const byNormalisedHex = new Map<string, DesignSystemToken[]>();
|
||||
const byFuzzyName = new Map<string, DesignSystemToken[]>();
|
||||
for (const t of tokens) {
|
||||
push(byValue, t.value, t);
|
||||
const norm = normaliseHex(t.value);
|
||||
if (norm) push(byNormalisedHex, norm, t);
|
||||
push(byFuzzyName, fuzzyName(t.name), t);
|
||||
}
|
||||
return { byValue, byNormalisedHex, byFuzzyName };
|
||||
}
|
||||
|
||||
function push<K, V>(map: Map<K, V[]>, key: K, value: V): void {
|
||||
const arr = map.get(key);
|
||||
if (arr) arr.push(value); else map.set(key, [value]);
|
||||
}
|
||||
|
||||
function fuzzyName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/^-+/, '')
|
||||
.replace(/^(?:ds-|odds-|theme-)/, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function normaliseHex(value: string): string | null {
|
||||
const m = HEX_RE.exec(value.trim());
|
||||
if (!m) return null;
|
||||
let hex = m[1]!.toLowerCase();
|
||||
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
|
||||
if (hex.length === 4) hex = hex.split('').map((c) => c + c).join('');
|
||||
return `#${hex}`;
|
||||
}
|
||||
|
||||
interface MatchOutcome {
|
||||
match?: TokenMapMatch;
|
||||
unmatched: TokenMapUnmatched;
|
||||
}
|
||||
|
||||
function matchOne(
|
||||
kind: keyof Pick<DesignExtractReport, 'colors' | 'typography' | 'spacing' | 'radius' | 'shadow'>,
|
||||
entry: DesignTokenEntry,
|
||||
index: IndexedDesignSystem,
|
||||
): MatchOutcome {
|
||||
const tokenKind: DesignTokenKind = kindOf(kind);
|
||||
const sourceValue = entry.value;
|
||||
const sourceName = entry.name;
|
||||
|
||||
// 1. Exact value match.
|
||||
const exact = index.byValue.get(sourceValue);
|
||||
if (exact && exact.length > 0) {
|
||||
const target = pickKindMatch(exact, tokenKind);
|
||||
if (target) return wrapMatch(target, sourceValue, sourceName, 'exact', tokenKind, entry.sources);
|
||||
}
|
||||
|
||||
// 2. Normalised hex.
|
||||
if (tokenKind === 'color') {
|
||||
const norm = normaliseHex(sourceValue);
|
||||
if (norm) {
|
||||
const hits = index.byNormalisedHex.get(norm);
|
||||
if (hits && hits.length > 0) {
|
||||
const target = pickKindMatch(hits, tokenKind);
|
||||
if (target) return wrapMatch(target, sourceValue, sourceName, 'normalised-hex', tokenKind, entry.sources);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fuzzy name match (only when source token has a name).
|
||||
if (sourceName) {
|
||||
const hits = index.byFuzzyName.get(fuzzyName(sourceName));
|
||||
if (hits && hits.length > 0) {
|
||||
const target = pickKindMatch(hits, tokenKind);
|
||||
if (target) return wrapMatch(target, sourceValue, sourceName, 'name', tokenKind, entry.sources);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
unmatched: {
|
||||
source: sourceValue,
|
||||
...(sourceName ? { sourceName } : {}),
|
||||
kind: tokenKind,
|
||||
reason: 'no-target-equivalent',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function kindOf(bucket: keyof Pick<DesignExtractReport, 'colors' | 'typography' | 'spacing' | 'radius' | 'shadow'>): DesignTokenKind {
|
||||
switch (bucket) {
|
||||
case 'colors': return 'color';
|
||||
case 'typography': return 'typography';
|
||||
case 'spacing': return 'spacing';
|
||||
case 'radius': return 'radius';
|
||||
case 'shadow': return 'shadow';
|
||||
}
|
||||
}
|
||||
|
||||
function pickKindMatch(candidates: DesignSystemToken[], kind: DesignTokenKind): DesignSystemToken | undefined {
|
||||
const sameKind = candidates.find((c) => c.kind === kind);
|
||||
if (sameKind) return sameKind;
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function wrapMatch(
|
||||
target: DesignSystemToken,
|
||||
sourceValue: string,
|
||||
sourceName: string | undefined,
|
||||
via: TokenMapMatch['via'],
|
||||
kind: DesignTokenKind,
|
||||
sources: string[],
|
||||
): MatchOutcome {
|
||||
const match: TokenMapMatch = {
|
||||
source: sourceValue,
|
||||
target: target.name,
|
||||
targetValue: target.value,
|
||||
via,
|
||||
kind,
|
||||
sources: sources.slice(),
|
||||
} as TokenMapMatch;
|
||||
if (sourceName) match.sourceName = sourceName;
|
||||
return {
|
||||
match,
|
||||
unmatched: { source: sourceValue, kind, reason: 'no-target-equivalent' },
|
||||
};
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
try { await fsp.access(p); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
function digestObject(obj: unknown): string {
|
||||
return createHash('sha1').update(JSON.stringify(obj)).digest('hex');
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ export * from './atoms/diff-review.js';
|
|||
export * from './atoms/handoff.js';
|
||||
export * from './atoms/patch-edit.js';
|
||||
export * from './atoms/rewrite-plan.js';
|
||||
export * from './atoms/token-map.js';
|
||||
export * from './bundled.js';
|
||||
export * from './connector-gate.js';
|
||||
export * from './export.js';
|
||||
|
|
|
|||
194
apps/daemon/tests/plugins-token-map.test.ts
Normal file
194
apps/daemon/tests/plugins-token-map.test.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
// Phase 6/7 entry slice — token-map atom impl.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
parseDesignSystemTokens,
|
||||
runTokenMap,
|
||||
type DesignSystemTokenBag,
|
||||
} from '../src/plugins/atoms/token-map.js';
|
||||
import type { DesignExtractReport } from '../src/plugins/atoms/design-extract.js';
|
||||
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
cwd = await mkdtemp(path.join(os.tmpdir(), 'od-token-map-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const ds: DesignSystemTokenBag = {
|
||||
id: 'fixture-ds',
|
||||
tokens: [
|
||||
{ name: '--ds-color-primary', value: '#5b8def', kind: 'color' },
|
||||
{ name: '--ds-color-fg', value: '#111111', kind: 'color' },
|
||||
{ name: '--ds-radius-md', value: '12px', kind: 'radius' },
|
||||
{ name: 'ds-spacing-2', value: '8px', kind: 'spacing' },
|
||||
{ name: 'ds-font-body', value: 'Inter', kind: 'typography' },
|
||||
],
|
||||
};
|
||||
|
||||
const codeTokens = (over: Partial<DesignExtractReport> = {}): DesignExtractReport => ({
|
||||
colors: [],
|
||||
typography: [],
|
||||
spacing: [],
|
||||
radius: [],
|
||||
shadow: [],
|
||||
scannedFiles: [],
|
||||
warnings: [],
|
||||
endedAt: new Date().toISOString(),
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('runTokenMap — match strategies', () => {
|
||||
it('matches by exact value', async () => {
|
||||
const source = codeTokens({
|
||||
colors: [{ kind: 'color', value: '#5b8def', sources: ['Button.tsx:3'], usage: ['Button.tsx'] }],
|
||||
});
|
||||
const report = await runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: ds });
|
||||
expect(report.colors).toHaveLength(1);
|
||||
expect(report.colors[0]?.target).toBe('--ds-color-primary');
|
||||
expect(report.colors[0]?.via).toBe('exact');
|
||||
expect(report.colors[0]?.sources).toEqual(['Button.tsx:3']);
|
||||
expect(report.unmatched).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches by normalised hex (#abc → #aabbcc)', async () => {
|
||||
const dsShort: DesignSystemTokenBag = {
|
||||
tokens: [{ name: '--ds-fg', value: '#abc', kind: 'color' }],
|
||||
};
|
||||
const source = codeTokens({
|
||||
colors: [{ kind: 'color', value: '#aabbcc', sources: [], usage: [] }],
|
||||
});
|
||||
const report = await runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: dsShort });
|
||||
expect(report.colors[0]?.via).toBe('normalised-hex');
|
||||
expect(report.colors[0]?.target).toBe('--ds-fg');
|
||||
});
|
||||
|
||||
it('matches by fuzzy name when source carries a name', async () => {
|
||||
const source = codeTokens({
|
||||
colors: [{ kind: 'color', name: '--color-primary', value: '#999999', sources: [], usage: [] }],
|
||||
});
|
||||
const report = await runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: ds });
|
||||
// Falls back to name match because the value doesn't match exact / normalised.
|
||||
expect(report.colors[0]?.via).toBe('name');
|
||||
expect(report.colors[0]?.target).toBe('--ds-color-primary');
|
||||
});
|
||||
|
||||
it("records unmatched tokens with reason='no-target-equivalent'", async () => {
|
||||
const source = codeTokens({
|
||||
colors: [{ kind: 'color', value: '#abc999', sources: ['x.css:1'], usage: ['x.css'] }],
|
||||
});
|
||||
const report = await runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: ds });
|
||||
expect(report.colors).toEqual([]);
|
||||
expect(report.unmatched).toHaveLength(1);
|
||||
expect(report.unmatched[0]).toMatchObject({
|
||||
source: '#abc999',
|
||||
kind: 'color',
|
||||
reason: 'no-target-equivalent',
|
||||
});
|
||||
});
|
||||
|
||||
it("records target collisions when multiple sources map to the same target", async () => {
|
||||
const source = codeTokens({
|
||||
colors: [
|
||||
{ kind: 'color', value: '#5B8DEF', sources: ['a.css:1'], usage: ['a.css'] },
|
||||
{ kind: 'color', value: '#5b8def', sources: ['b.css:1'], usage: ['b.css'] },
|
||||
],
|
||||
});
|
||||
const report = await runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: ds });
|
||||
expect(report.colors).toHaveLength(1);
|
||||
expect(report.unmatched).toHaveLength(1);
|
||||
expect(report.unmatched[0]).toMatchObject({
|
||||
reason: 'target-collision',
|
||||
hint: expect.stringMatching(/already mapped/),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws under strict mode when any source is unmatched', async () => {
|
||||
const source = codeTokens({
|
||||
colors: [{ kind: 'color', value: '#deadbe', sources: [], usage: [] }],
|
||||
});
|
||||
await expect(runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: ds, strict: true }))
|
||||
.rejects.toThrow(/strict/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runTokenMap — multi-kind matching', () => {
|
||||
it('crosswalks spacing / radius / typography buckets', async () => {
|
||||
const source = codeTokens({
|
||||
spacing: [{ kind: 'spacing', value: '8px', sources: [], usage: [] }],
|
||||
radius: [{ kind: 'radius', value: '12px', sources: [], usage: [] }],
|
||||
typography: [{ kind: 'typography', value: 'Inter', sources: [], usage: [] }],
|
||||
});
|
||||
const report = await runTokenMap({ cwd, source: { kind: 'code', report: source }, designSystem: ds });
|
||||
expect(report.spacing[0]?.target).toBe('ds-spacing-2');
|
||||
expect(report.radius[0]?.target).toBe('--ds-radius-md');
|
||||
expect(report.typography[0]?.target).toBe('ds-font-body');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runTokenMap — disk inputs + outputs', () => {
|
||||
it('reads code/tokens.json when source is omitted + persists every bucket file', async () => {
|
||||
await mkdir(path.join(cwd, 'code'), { recursive: true });
|
||||
const onDisk = codeTokens({
|
||||
colors: [{ kind: 'color', value: '#111111', sources: ['x:1'], usage: ['x'] }],
|
||||
});
|
||||
await writeFile(path.join(cwd, 'code', 'tokens.json'), JSON.stringify(onDisk));
|
||||
await runTokenMap({ cwd, designSystem: ds });
|
||||
const colors = JSON.parse(await readFile(path.join(cwd, 'token-map', 'colors.json'), 'utf8'));
|
||||
const meta = JSON.parse(await readFile(path.join(cwd, 'token-map', 'meta.json'), 'utf8'));
|
||||
const unmatched = JSON.parse(await readFile(path.join(cwd, 'token-map', 'unmatched.json'), 'utf8'));
|
||||
expect(colors[0]?.target).toBe('--ds-color-fg');
|
||||
expect(meta.sourceKind).toBe('code');
|
||||
expect(meta.designSystemId).toBe('fixture-ds');
|
||||
expect(meta.atomDigest.length).toBe(40);
|
||||
expect(unmatched).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back to figma/tokens.json when code/tokens.json is missing', async () => {
|
||||
await mkdir(path.join(cwd, 'figma'), { recursive: true });
|
||||
const onDisk = codeTokens({
|
||||
colors: [{ kind: 'color', value: '#111111', sources: [], usage: [] }],
|
||||
});
|
||||
await writeFile(path.join(cwd, 'figma', 'tokens.json'), JSON.stringify(onDisk));
|
||||
const report = await runTokenMap({ cwd, designSystem: ds });
|
||||
expect(report.meta.sourceKind).toBe('figma');
|
||||
});
|
||||
|
||||
it('throws a clear error when neither input exists', async () => {
|
||||
await expect(runTokenMap({ cwd, designSystem: ds }))
|
||||
.rejects.toThrow(/run design-extract or figma-extract first/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDesignSystemTokens', () => {
|
||||
it('extracts CSS custom properties from a DESIGN.md body', () => {
|
||||
const body = `
|
||||
# Design system
|
||||
|
||||
\`\`\`css
|
||||
:root {
|
||||
--ds-color-primary: #5b8def;
|
||||
--ds-radius-md: 12px;
|
||||
}
|
||||
\`\`\`
|
||||
`;
|
||||
const out = parseDesignSystemTokens(body);
|
||||
expect(out.find((t) => t.name === '--ds-color-primary')?.value).toBe('#5b8def');
|
||||
expect(out.find((t) => t.name === '--ds-radius-md')?.kind).toBe('radius');
|
||||
});
|
||||
|
||||
it('extracts markdown table rows', () => {
|
||||
const body = `
|
||||
| ds-bg | #ffffff |
|
||||
| ds-fg | #111111 |
|
||||
`;
|
||||
const out = parseDesignSystemTokens(body);
|
||||
expect(out.map((t) => t.name)).toEqual(expect.arrayContaining(['ds-bg', 'ds-fg']));
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue