@terrazzo/parser
Version:
Parser/validator for the Design Tokens Community Group (DTCG) standard.
122 lines (110 loc) • 4.05 kB
text/typescript
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;