Skip to content

Commit

Permalink
Improve lint error outputs
Browse files Browse the repository at this point in the history
  • Loading branch information
drwpow committed Mar 27, 2024
1 parent 514c98e commit a00e3c6
Show file tree
Hide file tree
Showing 10 changed files with 181 additions and 48 deletions.
7 changes: 7 additions & 0 deletions .changeset/cold-dodos-doubt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cobalt-ui/cli": patch
"@cobalt-ui/core": patch
"@cobalt-ui/lint-a11y": patch
---

Improve lint error outputs
5 changes: 5 additions & 0 deletions .changeset/giant-hotels-protect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cobalt-ui/utils": patch
---

Add indentLine and indentBlock helpers
9 changes: 7 additions & 2 deletions packages/cli/src/lint.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Group, LintRule, ParsedToken, ResolvedConfig } from '@cobalt-ui/core';
import { indentLine } from '@cobalt-ui/utils';

export interface LintOptions {
config: ResolvedConfig;
Expand Down Expand Up @@ -69,9 +70,13 @@ export default async function lint({ config, tokens, rawSchema, warnIfNoPlugins
const { severity } = rules.find((rule) => rule.id === notification.id) ?? { severity: 'off' };
// TODO: when node is added, show code line
if (severity === 'error') {
errors.push(`[${plugin.name}] Error ${notification.id}: ${notification.message}`);
errors.push(
`${notification.id}: ERROR
${indentLine(notification.message, 4)}`,
);
} else if (severity === 'warn') {
warnings.push(`[${plugin.name}] Warning ${notification.id}: ${notification.message}`);
warnings.push(`${notification.id}: WARNING
${indentLine(notification.message, 4)}`);
}
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/lint-a11y/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@cobalt-ui/cli": "^1.10.0"
},
"dependencies": {
"@cobalt-ui/utils": "^1.2.4",
"apca-w3": "^0.1.9",
"culori": "^4.0.1"
},
Expand Down
89 changes: 66 additions & 23 deletions packages/lint-a11y/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type Plugin, type ParsedToken, type LintNotice, type ParsedColorToken, type ParsedTypographyToken } from '@cobalt-ui/core';
import { RESET, padStr, BOLD } from '@cobalt-ui/utils';
import { type A98, type P3, type Rgb, rgb, wcagContrast } from 'culori';
import { APCAcontrast, adobeRGBtoY, alphaBlend, displayP3toY, sRGBtoY } from 'apca-w3';
import { isWCAG2LargeText, round } from './lib.js';
Expand Down Expand Up @@ -87,7 +88,17 @@ function evaluateContrast(tokens: ParsedToken[], options: RuleContrastOptions):

// WCAG 2
if (wcag2 !== false || typeof wcag2 === 'string' || (typeof wcag2 === 'number' && wcag2 > 0)) {
const colorPairs: { fg: typeof foreground.$value; bg: typeof background.$value; mode: string }[] = [{ fg: foreground.$value, bg: background.$value, mode: '.' }];
const colorPairs: {
foreground: { id: string; value: string };
background: { id: string; value: string };
mode: string;
}[] = [
{
foreground: { id: foreground.id, value: foreground.$value },
background: { id: foreground.id, value: background.$value },
mode: '.',
},
];
if (modes?.length) {
for (const mode of modes) {
if (!foreground.$extensions?.mode?.[mode]) {
Expand All @@ -96,20 +107,30 @@ function evaluateContrast(tokens: ParsedToken[], options: RuleContrastOptions):
if (!background.$extensions?.mode?.[mode]) {
throw new Error(`foreground ${backgroundID} doesn’t have mode "${mode}"`);
}
colorPairs.push({ fg: foreground.$extensions.mode[mode]!, bg: background.$extensions.mode[mode]!, mode });
colorPairs.push({
foreground: { id: foreground.id, value: foreground.$extensions.mode[mode]! },
background: { id: background.id, value: background.$extensions.mode[mode]! },
mode,
});
}
}
for (const { fg, bg, mode } of colorPairs) {
for (const { foreground: fgMeasured, background: bgMeasured, mode } of colorPairs) {
const isLargeText =
typography?.$value.fontSize && typography?.$value.fontWeight ? isWCAG2LargeText(parseFloat(typography.$value.fontSize), typography.$value.fontWeight) : false;
const minContrast = typeof wcag2 === 'string' ? WCAG2_MIN_CONTRAST[wcag2][isLargeText ? 'large' : 'default'] : wcag2;
const defaultResult = wcagContrast(fg, bg);
const defaultResult = wcagContrast(fgMeasured.value, bgMeasured.value);
if (round(defaultResult, WCAG2_PRECISION) < minContrast) {
const modeText = mode === '.' ? '' : ` (mode: ${mode})`;
const levelText = typeof wcag2 === 'string' ? ` ("${wcag2}")` : '';
notices.push({
id: RULES.contrast,
message: `WCAG 2: Token pair ${fg}, ${bg}${modeText} failed contrast. Expected ${minContrast}:1${levelText}, received ${round(defaultResult, WCAG2_PRECISION)}:1`,
message: formatContrastFailure({
method: 'WCAG2',
foreground: fgMeasured,
background: bgMeasured,
threshold: `${minContrast}:1`,
thresholdName: typeof wcag2 === 'string' ? wcag2 : '',
actual: `${round(defaultResult, WCAG2_PRECISION)}:1`,
mode: mode === '.' ? undefined : mode,
}),
});
}
}
Expand All @@ -128,48 +149,50 @@ function evaluateContrast(tokens: ParsedToken[], options: RuleContrastOptions):
}

const testSets: {
fgRaw: typeof foreground.$value;
bgRaw: typeof background.$value;
fgY: number;
bgY: number;
foreground: { id: string; value: string; y: number };
background: { id: string; value: string; y: number };
fontSize?: string;
fontWeight?: number;
mode: string;
}[] = [];
for (const mode of ['.', ...(modes ?? [])]) {
const fgRaw = foreground.$extensions?.mode?.[mode] ?? foreground.$value;
const bgRaw = background.$extensions?.mode?.[mode] ?? background.$value;
const fgValue = foreground.$extensions?.mode?.[mode] ?? foreground.$value;
const bgValue = background.$extensions?.mode?.[mode] ?? background.$value;
const typographyRaw = typography?.$extensions?.mode?.[mode] ?? typography?.$value;

testSets.push({
fgRaw,
fgY: getY(rgb(fgRaw)!, rgb(bgRaw)),
bgRaw,
bgY: getY(rgb(bgRaw)!),
foreground: { id: foreground.id, value: fgValue, y: getY(rgb(fgValue)!, rgb(bgValue)) },
background: { id: background.id, value: bgValue, y: getY(rgb(bgValue)!) },
fontSize: typographyRaw?.fontSize,
fontWeight: typographyRaw?.fontWeight,
mode,
});
}

for (const { fgY, fgRaw, bgY, bgRaw, mode, fontSize, fontWeight } of testSets) {
for (const { foreground: fgMeasured, background: bgMeasured, mode, fontSize, fontWeight } of testSets) {
if ((apca === 'silver' || apca === 'silver-nonbody') && (!fontSize || !fontWeight)) {
throw new Error(`APCA: "${apca}" compliance requires \`typography\` token. Use manual number if omitted.`);
}
const lc = APCAcontrast(
fgY, // First color MUST be text
bgY, // Second color MUST be the background.
fgMeasured.y, // First color MUST be text
bgMeasured.y, // Second color MUST be the background.
);
if (typeof lc === 'string') {
throw new Error(`Internal error: expected number, APCA returned "${lc}"`); // types are wrong?
}
const minContrast = typeof apca === 'number' ? apca : getMinimumSilverLc(fontSize!, fontWeight!, apca === 'silver');
if (round(Math.abs(lc), APCA_PRECISION) < minContrast) {
const modeText = mode === '.' ? '' : ` (mode: ${mode})`;
const levelText = typeof apca === 'string' ? ` ("${apca}")` : '';
notices.push({
id: RULES.contrast,
message: `APCA: Token pair ${fgRaw}, ${bgRaw}${modeText} failed contrast. Expected ${minContrast}${levelText}, received ${round(Math.abs(lc), APCA_PRECISION)}`,
message: formatContrastFailure({
method: 'APCA',
foreground: fgMeasured,
background: bgMeasured,
threshold: minContrast,
thresholdName: typeof apca === 'string' ? apca : undefined,
actual: round(Math.abs(lc), APCA_PRECISION),
mode: mode === '.' ? undefined : mode,
}),
});
}
}
Expand All @@ -179,6 +202,26 @@ function evaluateContrast(tokens: ParsedToken[], options: RuleContrastOptions):
return notices;
}

export interface FormatContrastFailureOptions {
foreground: { id: string; value: string };
background: { id: string; value: string };
method: 'WCAG2' | 'APCA';
threshold: number | string;
thresholdName?: string;
actual: number | string;
mode?: string;
}

export function formatContrastFailure({ foreground, background, method, threshold, thresholdName, actual, mode }: FormatContrastFailureOptions): string {
const longerID = Math.max(foreground.id.length, background.id.length);
const longerValue = Math.max(foreground.value.length, background.value.length);
return `[${method}] Failed contrast${thresholdName ? ` (${thresholdName})` : ''}
Foreground: ${padStr(foreground.id, longerID)}${padStr(foreground.value, longerValue)}${mode && mode !== '.' ? ` (mode: ${mode})` : ''}
Background: ${padStr(background.id, longerID)}${padStr(background.value, longerValue)}${mode && mode !== '.' ? ` (mode: ${mode})` : ''}
Wanted: ${threshold} / Actual: ${BOLD}${actual}${RESET}`;
}

export default function PluginA11y(): Plugin {
return {
name: '@cobalt-ui/lint-a11y',
Expand Down
8 changes: 0 additions & 8 deletions packages/utils/src/indent.ts

This file was deleted.

3 changes: 1 addition & 2 deletions packages/utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export * from './ansi.js';
export * from './indent.js';
export * from './string.js';
export * from './object.js';
export * from './string.js';
export * from './token.js';
36 changes: 36 additions & 0 deletions packages/utils/src/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const STARTS_WITH_NUMBER_RE = /^[0-9]/;
export const CASECHANGE_RE = /[a-zâ-ž][A-ZÀ-Ž]/g;
export const KEBAB_COVERT_RE = /[_.]/g;
export const CAMEL_CONVERT_RE = /[^-_.\s][-_.\s]+[^-_.\s]/g;
export const LB_RE = /\r?\n\s*/g;

export const VALID_KEY = new RegExp(`^[${CHARACTER_RE.join('')}]+$`);

Expand All @@ -49,3 +50,38 @@ export function objKey(name: string, wrapper = "'"): string {
}
return VALID_KEY.test(name) ? name : `${wrapper}${name}${wrapper}`;
}

/** pad string lengths */
export function padStr(input: string, length: number, alignment: 'left' | 'center' | 'right' = 'left'): string {
const d =
Math.min(length || 0, 1000) - // guard against NaNs and Infinity
input.length;
if (d > 0) {
switch (alignment) {
case 'left': {
return `${input}${' '.repeat(d)}`;
}
case 'right': {
return `${' '.repeat(d)}${input}`;
}
case 'center': {
const left = Math.floor(d / 2);
const right = d - left;
return `${' '.repeat(left)}${input}${' '.repeat(right)}`;
}
}
}
return input;
}

/** indent an individual line */
export function indentLine(input: string, level = 0): string {
return `${' '.repeat(level || 0)}${input.trim()}`;
}

export { indentLine as indent };

/** indent a block of text with spaces */
export function indentBlock(input: string, spaces: number): string {
return `${' '.repeat(spaces)}${input.trim().replace(LB_RE, `\n${' '.repeat(spaces)}`)}`;
}
68 changes: 55 additions & 13 deletions packages/utils/test/string.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,73 @@
import { describe, expect, test } from 'vitest';
import { camelize, kebabinate, objKey } from '../src/string.js';
import { camelize, indentBlock, kebabinate, objKey, padStr } from '../src/string.js';

describe('camelize', () => {
test('basic', () => {
expect(camelize('string-To.Camelize')).toBe('stringToCamelize');
expect(camelize('string-To.Camelize')).toMatchInlineSnapshot(`"stringToCamelize"`);
});
});

describe('kebabinate', () => {
test('basic', () => {
expect(kebabinate('stringToKebabinate')).toBe('string-to-kebabinate');
expect(kebabinate('color.ui.contrast.00')).toBe('color-ui-contrast-00');
expect(kebabinate('color.ui.contrast.05')).toBe('color-ui-contrast-05');
expect(kebabinate('stringToKebabinate')).toMatchInlineSnapshot(`"string-to-kebabinate"`);
expect(kebabinate('color.ui.contrast.00')).toMatchInlineSnapshot(`"color-ui-contrast-00"`);
expect(kebabinate('color.ui.contrast.05')).toMatchInlineSnapshot(`"color-ui-contrast-05"`);
});
});

describe('objKey', () => {
test('basic', () => {
// JS-valid keys
expect(objKey('valid')).toBe('valid');
expect(objKey('$valid')).toBe('$valid');
expect(objKey('_valid')).toBe('_valid');
expect(objKey('valid')).toMatchInlineSnapshot(`"valid"`);
expect(objKey('$valid')).toMatchInlineSnapshot(`"$valid"`);
expect(objKey('_valid')).toMatchInlineSnapshot(`"_valid"`);
});

test('chaotic', () => {
expect(objKey('123')).toMatchInlineSnapshot(`"'123'"`);
expect(objKey('1invalid')).toMatchInlineSnapshot(`"'1invalid'"`);
expect(objKey('in-valid')).toMatchInlineSnapshot(`"'in-valid'"`);
expect(objKey('in.valid')).toMatchInlineSnapshot(`"'in.valid'"`);
});
});

// JS-invalid keys
expect(objKey('123')).toBe("'123'");
expect(objKey('1invalid')).toBe("'1invalid'");
expect(objKey('in-valid')).toBe("'in-valid'");
expect(objKey('in.valid')).toBe("'in.valid'");
describe('padStr', () => {
test('basic', () => {
expect(padStr('input', 10)).toMatchInlineSnapshot(`"input "`);
expect(padStr('input', 10, 'right')).toMatchInlineSnapshot(`" input"`);
expect(padStr('input', 10, 'center')).toMatchInlineSnapshot(`" input "`);
expect(padStr('reallyreallylongword', 5, 'center')).toMatchInlineSnapshot(`"reallyreallylongword"`);
});

test('chaotic', () => {
expect(padStr('input', -100, 'center')).toMatchInlineSnapshot(`"input"`);
expect(padStr('input', Infinity, 'center')).toMatchInlineSnapshot(
`" input "`,
);
expect(padStr('input', -Infinity, 'center')).toMatchInlineSnapshot(`"input"`);
expect(padStr('input', -0, 'center')).toMatchInlineSnapshot(`"input"`);
expect(padStr('input', NaN, 'center')).toMatchInlineSnapshot(`"input"`);
});
});

describe('indentBlock', () => {
test('basic', () => {
expect(indentBlock('my text', 4)).toMatchInlineSnapshot(`" my text"`);
expect(indentBlock(' my text', 4)).toMatchInlineSnapshot(`" my text"`);
expect(indentBlock(' my text', 4)).toMatchInlineSnapshot(`" my text"`);
expect(
indentBlock(
`line 1
line 2
line 3
line 4`,
2,
),
).toMatchInlineSnapshot(`
" line 1
line 2
line 3
line 4"
`);
});
});
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit a00e3c6

Please sign in to comment.