UNPKG

@terrazzo/parser

Version:

Parser/validator for the Design Tokens Community Group (DTCG) standard.

122 lines (110 loc) 4.05 kB
import { type ColorValueNormalized, isTokenMatch, tokenToCulori } from '@terrazzo/token-tools'; import { type Color, clampChroma } from 'culori'; import type { LintRule } from '../../../types.js'; import { docsLink } from '../lib/docs.js'; export const MAX_GAMUT = 'core/max-gamut'; export interface RuleMaxGamutOptions { /** Gamut to constrain color tokens to. */ gamut: 'srgb' | 'p3' | 'rec2020'; /** (optional) Token IDs to ignore. Supports globs (`*`). */ ignore?: string[]; } const TOLERANCE = 0.000001; // threshold above which it counts as an error (take rounding errors into account) /** is a Culori-parseable color within the specified gamut? */ function isWithinGamut(color: ColorValueNormalized, gamut: RuleMaxGamutOptions['gamut']): boolean { const parsed = tokenToCulori(color); if (!parsed) { return false; } if (['rgb', 'hsl', 'hsv', 'hwb'].includes(parsed.mode)) { return true; } const clamped = clampChroma(parsed, parsed.mode, gamut === 'srgb' ? 'rgb' : gamut); return isWithinThreshold(parsed, clamped); } /** is Color A close enough to Color B? */ function isWithinThreshold(a: Color, b: Color, tolerance = TOLERANCE) { for (const k in a) { if (k === 'mode' || k === 'alpha') { continue; } if (!(k in b)) { throw new Error(`Can’t compare ${a.mode} to ${b.mode}`); } if (Math.abs((a as any)[k] - (b as any)[k]) > tolerance) { return false; } } return true; } const ERROR_COLOR = 'COLOR'; const ERROR_BORDER = 'BORDER'; const ERROR_GRADIENT = 'GRADIENT'; const ERROR_SHADOW = 'SHADOW'; const rule: LintRule< typeof ERROR_COLOR | typeof ERROR_BORDER | typeof ERROR_GRADIENT | typeof ERROR_SHADOW, RuleMaxGamutOptions > = { meta: { messages: { [ERROR_COLOR]: 'Color {{ id }} is outside {{ gamut }} gamut', [ERROR_BORDER]: 'Border {{ id }} is outside {{ gamut }} gamut', [ERROR_GRADIENT]: 'Gradient {{ id }} is outside {{ gamut }} gamut', [ERROR_SHADOW]: 'Shadow {{ id }} is outside {{ gamut }} gamut', }, docs: { description: 'Enforce colors are within the specified gamut.', url: docsLink(MAX_GAMUT), }, }, defaultOptions: { gamut: 'rec2020' }, create({ tokens, options, report }) { if (!options?.gamut) { return; } if (options.gamut !== 'srgb' && options.gamut !== 'p3' && options.gamut !== 'rec2020') { throw new Error(`Unknown gamut "${options.gamut}". Options are "srgb", "p3", or "rec2020"`); } for (const t of Object.values(tokens)) { // skip ignored tokens if (options.ignore && isTokenMatch(t.id, options.ignore)) { continue; } // skip aliases if (t.aliasOf) { continue; } switch (t.$type) { case 'color': { if (!isWithinGamut(t.$value, options.gamut)) { report({ messageId: ERROR_COLOR, data: { id: t.id, gamut: options.gamut }, node: t.source.node }); } break; } case 'border': { if (!t.partialAliasOf?.color && !isWithinGamut(t.$value.color, options.gamut)) { report({ messageId: ERROR_BORDER, data: { id: t.id, gamut: options.gamut }, node: t.source.node }); } break; } case 'gradient': { for (let stopI = 0; stopI < t.$value.length; stopI++) { if (!t.partialAliasOf?.[stopI]?.color && !isWithinGamut(t.$value[stopI]!.color, options.gamut)) { report({ messageId: ERROR_GRADIENT, data: { id: t.id, gamut: options.gamut }, node: t.source.node }); } } break; } case 'shadow': { for (let shadowI = 0; shadowI < t.$value.length; shadowI++) { if (!t.partialAliasOf?.[shadowI]?.color && !isWithinGamut(t.$value[shadowI]!.color, options.gamut)) { report({ messageId: ERROR_SHADOW, data: { id: t.id, gamut: options.gamut }, node: t.source.node }); } } break; } } } }, }; export default rule;