diff --git a/.changeset/cold-dodos-doubt.md b/.changeset/cold-dodos-doubt.md new file mode 100644 index 00000000..fef335dc --- /dev/null +++ b/.changeset/cold-dodos-doubt.md @@ -0,0 +1,7 @@ +--- +"@cobalt-ui/cli": patch +"@cobalt-ui/core": patch +"@cobalt-ui/lint-a11y": patch +--- + +Improve lint error outputs diff --git a/.changeset/giant-hotels-protect.md b/.changeset/giant-hotels-protect.md new file mode 100644 index 00000000..bc1dd6c6 --- /dev/null +++ b/.changeset/giant-hotels-protect.md @@ -0,0 +1,5 @@ +--- +"@cobalt-ui/utils": patch +--- + +Add indentLine and indentBlock helpers diff --git a/packages/cli/src/lint.ts b/packages/cli/src/lint.ts index 0d932495..61bfc078 100644 --- a/packages/cli/src/lint.ts +++ b/packages/cli/src/lint.ts @@ -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; @@ -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)}`); } } } diff --git a/packages/lint-a11y/package.json b/packages/lint-a11y/package.json index 47d3e5b4..227de476 100644 --- a/packages/lint-a11y/package.json +++ b/packages/lint-a11y/package.json @@ -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" }, diff --git a/packages/lint-a11y/src/index.ts b/packages/lint-a11y/src/index.ts index 4348fcf9..b951b4fa 100644 --- a/packages/lint-a11y/src/index.ts +++ b/packages/lint-a11y/src/index.ts @@ -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'; @@ -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]) { @@ -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, + }), }); } } @@ -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, + }), }); } } @@ -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', diff --git a/packages/utils/src/indent.ts b/packages/utils/src/indent.ts deleted file mode 100644 index a99ce8bd..00000000 --- a/packages/utils/src/indent.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** indent string by level */ -export function indent(input: string, level = 0): string { - let startingWS = ''; - for (let n = 0; n < level; n++) { - startingWS += ' '; - } - return `${startingWS}${input.trim()}`; -} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index f8cb15b2..7751fd5a 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -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'; diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 39310424..b744f6dc 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -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('')}]+$`); @@ -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)}`)}`; +} diff --git a/packages/utils/test/string.test.ts b/packages/utils/test/string.test.ts index 6567d96a..42ffb249 100644 --- a/packages/utils/test/string.test.ts +++ b/packages/utils/test/string.test.ts @@ -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" + `); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 51912050..b277fc6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,6 +267,9 @@ importers: packages/lint-a11y: dependencies: + '@cobalt-ui/utils': + specifier: workspace:^1.2.4 + version: link:../utils apca-w3: specifier: ^0.1.9 version: 0.1.9