Skip to content

Commit

Permalink
Add docs, a11y rules
Browse files Browse the repository at this point in the history
  • Loading branch information
drwpow committed Nov 25, 2024
1 parent d4704bb commit 410c279
Show file tree
Hide file tree
Showing 26 changed files with 631 additions and 180 deletions.
2 changes: 1 addition & 1 deletion packages/cli/terrazzo.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ export default defineConfig({
/** @see https://terrazzo.app/docs/cli/integrations */
],
lint: {
/** @see https://terrazzo.app/docs/cli/linting */
/** @see https://terrazzo.app/docs/cli/lint */
},
});
2 changes: 1 addition & 1 deletion packages/cli/test/fixtures/init/want.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@ export default defineConfig({
/** @see https://terrazzo.app/docs/cli/integrations */
],
lint: {
/** @see https://terrazzo.app/docs/cli/linting */
/** @see https://terrazzo.app/docs/cli/lint */
},
});
11 changes: 11 additions & 0 deletions packages/parser/src/lint/plugin-core/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
// Terrazzo internal plugin that powers lint rules. Always enabled (but all
// rules are opt-in).
import type { Plugin } from '../../types.js';

export * from './rules/a11y-min-contrast.js';
export * from './rules/a11y-min-font-size.js';
export * from './rules/colorspace.js';
export * from './rules/consistent-naming.js';
export * from './rules/descriptions.js';
export * from './rules/duplicate-values.js';
export * from './rules/max-gamut.js';
export * from './rules/required-children.js';
export * from './rules/required-modes.js';
export * from './rules/required-typography-properties.js';

import a11yMinContrast, { A11Y_MIN_CONTRAST } from './rules/a11y-min-contrast.js';
import a11yMinFontSize, { A11Y_MIN_FONT_SIZE } from './rules/a11y-min-font-size.js';
import colorspace, { COLORSPACE } from './rules/colorspace.js';
import consistentNaming, { CONSISTENT_NAMING } from './rules/consistent-naming.js';
import descriptions, { DESCRIPTIONS } from './rules/descriptions.js';
import duplicateValues, { DUPLICATE_VALUES } from './rules/duplicate-values.js';
import maxGamut, { MAX_GAMUT } from './rules/max-gamut.js';
import requiredChidlren, { REQUIRED_CHILDREN } from './rules/required-children.js';
Expand All @@ -25,11 +33,14 @@ export default function coreLintPlugin(): Plugin {
return {
[COLORSPACE]: colorspace,
[CONSISTENT_NAMING]: consistentNaming,
[DESCRIPTIONS]: descriptions,
[DUPLICATE_VALUES]: duplicateValues,
[MAX_GAMUT]: maxGamut,
[REQUIRED_CHILDREN]: requiredChidlren,
[REQUIRED_MODES]: requiredModes,
[REQUIRED_TYPOGRAPHY_PROPERTIES]: requiredTypographyProperties,
[A11Y_MIN_CONTRAST]: a11yMinContrast,
[A11Y_MIN_FONT_SIZE]: a11yMinFontSize,
};
},
};
Expand Down
91 changes: 91 additions & 0 deletions packages/parser/src/lint/plugin-core/rules/a11y-min-contrast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { tokenToCulori } from '@terrazzo/token-tools';
import { wcagContrast } from 'culori';
import type { LintRule } from '../../../types.js';
import { docsLink } from '../lib/docs.js';

export const A11Y_MIN_CONTRAST = 'a11y/min-contrast';

export interface RuleA11yMinContrastOptions {
/**
* Whether to adhere to AA (minimum) or AAA (enhanced) contrast levels.
* @default "AA"
*/
level?: 'AA' | 'AAA';
/** Pairs of color tokens (and optionally typography) to test */
pairs: ContrastPair[];
}

export interface ContrastPair {
/** The foreground color token ID */
foreground: string;
/** The background color token ID */
background: string;
/**
* Is this pair for large text? Large text allows a smaller contrast ratio.
*
* Note: while WCAG has _suggested_ sizes and weights, those are merely
* suggestions. It’s always more reliable to determine what constitutes “large
* text” for your designs yourself, based on your typographic stack.
* @see https://www.w3.org/WAI/WCAG22/quickref/#contrast-minimum
*/
largeText?: boolean;
}

export const WCAG2_MIN_CONTRAST = {
AA: { default: 4.5, large: 3 },
AAA: { default: 7, large: 4.5 },
};

export const ERROR_INSUFFICIENT_CONTRAST = 'INSUFFICIENT_CONTRAST';

const rule: LintRule<typeof ERROR_INSUFFICIENT_CONTRAST, RuleA11yMinContrastOptions> = {
meta: {
messages: {
[ERROR_INSUFFICIENT_CONTRAST]: 'Pair {{ index }} failed; expected {{ expected }}, got {{ actual }} ({{ level }})',
},
docs: {
description: 'Enforce colors meet minimum contrast checks for WCAG 2.',
url: docsLink(A11Y_MIN_CONTRAST),
},
},
defaultOptions: { level: 'AA', pairs: [] },
create({ tokens, options, report }) {
for (let i = 0; i < options.pairs.length; i++) {
const { foreground, background, largeText } = options.pairs[i]!;
if (!tokens[foreground]) {
throw new Error(`Token ${foreground} does not exist`);
}
if (tokens[foreground].$type !== 'color') {
throw new Error(`Token ${foreground} isn’t a color`);
}
if (!tokens[background]) {
throw new Error(`Token ${background} does not exist`);
}
if (tokens[background].$type !== 'color') {
throw new Error(`Token ${background} isn’t a color`);
}

// Note: if these culors were unparseable, they would have already thrown an error before the linter
const a = tokenToCulori(tokens[foreground].$value)!;
const b = tokenToCulori(tokens[background].$value)!;

// Note: for the purposes of WCAG 2, foreground and background don’t
// matter. But in other contrast algorithms, they do.
const contrast = wcagContrast(a, b);
const min = WCAG2_MIN_CONTRAST[options.level ?? 'AA'][largeText ? 'large' : 'default'];
if (contrast < min) {
report({
messageId: ERROR_INSUFFICIENT_CONTRAST,
data: {
index: i + 1,
expected: min,
actual: Math.round(contrast * 100) / 100,
level: options.level,
},
});
}
}
},
};

export default rule;
64 changes: 64 additions & 0 deletions packages/parser/src/lint/plugin-core/rules/a11y-min-font-size.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { isTokenMatch } from '@terrazzo/token-tools';
import type { LintRule } from '../../../types.js';
import { docsLink } from '../lib/docs.js';

export const A11Y_MIN_FONT_SIZE = 'a11y/min-font-size';

export interface RuleA11yMinFontSizeOptions {
/** Minimum font size (pixels) */
minSizePx?: number;
/** Minimum font size (rems) */
minSizeRem?: number;
/** Token IDs to ignore. Accepts globs. */
ignore?: string[];
}

export const ERROR_TOO_SMALL = 'TOO_SMALL';

const rule: LintRule<typeof ERROR_TOO_SMALL, RuleA11yMinFontSizeOptions> = {
meta: {
messages: {
[ERROR_TOO_SMALL]: '{{ id }} font size too small. Expected minimum of {{ min }}',
},
docs: {
description: 'Enforce font sizes are no smaller than the given value.',
url: docsLink(A11Y_MIN_FONT_SIZE),
},
},
defaultOptions: {},
create({ tokens, options, report }) {
if (!options.minSizePx && !options.minSizeRem) {
throw new Error('Must specify at least one of minSizePx or minSizeRem');
}

for (const t of Object.values(tokens)) {
if (options.ignore && isTokenMatch(t.id, options.ignore)) {
continue;
}

// skip aliases
if (t.aliasOf) {
continue;
}

if (t.$type === 'typography' && 'fontSize' in t.$value) {
const fontSize = t.$value.fontSize!;

if (
(fontSize.unit === 'px' && options.minSizePx && fontSize.value < options.minSizePx) ||
(fontSize.unit === 'rem' && options.minSizeRem && fontSize.value < options.minSizeRem)
) {
report({
messageId: ERROR_TOO_SMALL,
data: {
id: t.id,
min: options.minSizePx ? `${options.minSizePx}px` : `${options.minSizeRem}rem`,
},
});
}
}
}
},
};

export default rule;
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface RuleConsistentNamingOptions {
| 'PascalCase'
| 'snake_case'
| 'SCREAMING_SNAKE_CASE'
| ((tokenID: string) => string | undefined);
| ((tokenID: string) => boolean);
/** Token IDs to ignore. Supports globs (`*`). */
ignore?: string[];
}
Expand Down
41 changes: 41 additions & 0 deletions packages/parser/src/lint/plugin-core/rules/descriptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { isTokenMatch } from '@terrazzo/token-tools';
import type { LintRule } from '../../../types.js';
import { docsLink } from '../lib/docs.js';

export const DESCRIPTIONS = 'core/descriptions';

export interface RuleDescriptionsOptions {
/** Token IDs to ignore. Supports globs (`*`). */
ignore?: string[];
}

const ERROR_MISSING_DESCRIPTION = 'MISSING_DESCRIPTION';

const rule: LintRule<typeof ERROR_MISSING_DESCRIPTION, RuleDescriptionsOptions> = {
meta: {
messages: {
[ERROR_MISSING_DESCRIPTION]: '{{ id }} missing description',
},
docs: {
description: 'Enforce tokens have descriptions.',
url: docsLink(DESCRIPTIONS),
},
},
defaultOptions: {},
create({ tokens, options, report }) {
for (const t of Object.values(tokens)) {
if (options.ignore && isTokenMatch(t.id, options.ignore)) {
continue;
}
if (!t.$description) {
report({
messageId: ERROR_MISSING_DESCRIPTION,
data: { id: t.id },
node: t.source.node,
});
}
}
},
};

export default rule;
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const rule: LintRule<typeof ERROR_DUPLICATE_VALUE, RuleDuplicateValueOptions> =
[ERROR_DUPLICATE_VALUE]: '{{ id }} declared a duplicate value',
},
docs: {
description: 'Detect duplicate values in tokens.',
description: 'Enforce tokens can’t redeclare the same value (excludes aliases).',
url: docsLink(DUPLICATE_VALUES),
},
},
Expand Down
5 changes: 5 additions & 0 deletions packages/parser/src/lint/plugin-core/rules/max-gamut.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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';

Expand Down Expand Up @@ -58,6 +59,10 @@ const rule: LintRule<
[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 }) {
Expand Down
29 changes: 7 additions & 22 deletions packages/parser/src/lint/plugin-core/rules/required-children.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,14 @@ import { docsLink } from '../lib/docs.js';

export const REQUIRED_CHILDREN = 'core/required-children';

export type RequiredChildrenMatch = {
export interface RequiredChildrenMatch {
/** Glob of tokens/groups to match */
match: string[];
} & ( // type helper (enforces at least 1 exists)
| {
/** Required token IDs to match (this only looks at the very last segment of a token ID!) */
requiredTokens: string[];
/** Required groups to match (this only looks at the beginning/middle segments of a token ID!) */
requiredGroups?: never;
}
| {
/** Required token IDs to match (this only looks at the very last segment of a token ID!) */
requiredTokens?: never;
/** Required groups to match (this only looks at the beginning/middle segments of a token ID!) */
requiredGroups: string[];
}
| {
/** Required token IDs to match (this only looks at the very last segment of a token ID!) */
requiredTokens: string[];
/** Required groups to match (this only looks at the beginning/middle segments of a token ID!) */
requiredGroups: string[];
}
);
/** Required token IDs to match (this only looks at the very last segment of a token ID!) */
requiredTokens?: string[];
/** Required groups to match (this only looks at the beginning/middle segments of a token ID!) */
requiredGroups?: string[];
}

export interface RuleRequiredChildrenOptions {
matches: RequiredChildrenMatch[];
Expand All @@ -47,7 +32,7 @@ const rule: LintRule<
[ERROR_MISSING_REQUIRED_GROUP]: 'Match {{ index }}: some tokens missing required group "{{ group }}"',
},
docs: {
description: 'Enforce groups have required tokens, or tokens have required groups.',
description: 'Enforce token groups have specific children, whether tokens and/or groups.',
url: docsLink(REQUIRED_CHILDREN),
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isTokenMatch } from '@terrazzo/token-tools';
import type { LintRule } from '../../../types.js';
import { docsLink } from '../lib/docs.js';

Expand All @@ -6,6 +7,8 @@ export const REQUIRED_TYPOGRAPHY_PROPERTIES = 'core/required-typography-properti
export interface RuleRequiredTypographyPropertiesOptions {
/** Required typography properties */
properties: string[];
/** Token globs to ignore */
ignore?: string[];
}

const rule: LintRule<never, RuleRequiredTypographyPropertiesOptions> = {
Expand All @@ -26,6 +29,10 @@ const rule: LintRule<never, RuleRequiredTypographyPropertiesOptions> = {
}

for (const t of Object.values(tokens)) {
if (options.ignore && isTokenMatch(t.id, options.ignore)) {
continue;
}

if (t.$type !== 'typography') {
continue;
}
Expand Down
Loading

0 comments on commit 410c279

Please sign in to comment.