[READ-ONLY] a fast, modern browser for the npm registry

test: configure lint-css to check a11y and rtl (#1203)

authored by

Alex Savelyev and committed by
GitHub
eb19ccdd 13ef534b

+198 -7
+2 -2
.github/workflows/autofix.yml
··· 32 32 - name: 📦 Install dependencies 33 33 run: pnpm install 34 34 35 - - name: 🎨 Check for non-RTL CSS classes 36 - run: pnpm rtl:check 35 + - name: 🎨 Check for non-RTL/non-a11y CSS classes 36 + run: pnpm lint:css 37 37 38 38 - name: 🌐 Compare translations 39 39 run: pnpm i18n:check
+1 -1
app/components/LicenseDisplay.vue
··· 24 24 {{ token.value }} 25 25 </a> 26 26 <span v-else-if="token.type === 'license'">{{ token.value }}</span> 27 - <span v-else-if="token.type === 'operator'" class="text-[0.65em]">{{ token.value }}</span> 27 + <span v-else-if="token.type === 'operator'" class="text-4xs">{{ token.value }}</span> 28 28 </template> 29 29 <span 30 30 v-if="hasAnyValidLicense"
+4 -1
package.json
··· 19 19 "i18n:check:fix": "node scripts/compare-translations.ts --fix", 20 20 "i18n:report": "node scripts/find-invalid-translations.ts", 21 21 "i18n:report:fix": "node scripts/remove-unused-translations.ts", 22 - "rtl:check": "node scripts/rtl-checker.ts", 23 22 "knip": "knip", 24 23 "knip:fix": "knip --fix", 25 24 "lint": "oxlint && oxfmt --check", 26 25 "lint:fix": "oxlint --fix && oxfmt", 26 + "lint:css": "node scripts/unocss-checker.ts", 27 27 "generate": "nuxt generate", 28 28 "npmx-connector": "pnpm --filter npmx-connector dev", 29 29 "generate-pwa-icons": "pwa-assets-generator", ··· 150 150 ], 151 151 "*.{js,ts,mjs,cjs,vue}": [ 152 152 "pnpm oxlint --fix" 153 + ], 154 + "*.vue": [ 155 + "pnpm lint:css" 153 156 ], 154 157 "*.{js,ts,mjs,cjs,vue,json,yml,md,html,css}": [ 155 158 "pnpm oxfmt"
+18 -2
scripts/rtl-checker.ts scripts/unocss-checker.ts
··· 4 4 import { resolve } from 'node:path' 5 5 import { createGenerator } from 'unocss' 6 6 import { presetRtl } from '../uno-preset-rtl.ts' 7 + import { presetA11y } from '../uno-preset-a11y.ts' 7 8 import { COLORS } from './utils.ts' 8 9 import { presetWind4 } from 'unocss' 9 10 11 + const argvFiles = process.argv.slice(2) 10 12 const APP_DIRECTORY = fileURLToPath(new URL('../app', import.meta.url)) 11 13 12 14 async function checkFile(path: Dirent): Promise<string | undefined> { ··· 33 35 `${COLORS.red} ❌ [RTL] ${filename}:${idx}${ruleIdx > -1 ? `:${ruleIdx + 1}` : ''} - ${warning}${COLORS.reset}`, 34 36 ) 35 37 }), 38 + presetA11y((warning, rule) => { 39 + let entry = warnings.get(idx) 40 + if (!entry) { 41 + entry = [] 42 + warnings.set(idx, entry) 43 + } 44 + const ruleIdx = line.indexOf(rule) 45 + entry.push( 46 + `${COLORS.red} ❌ [A11y] ${filename}:${idx}${ruleIdx > -1 ? `:${ruleIdx + 1}` : ''} - ${warning}${COLORS.reset}`, 47 + ) 48 + }), 36 49 ], 37 50 }) 38 51 const lines = file.split('\n') ··· 46 59 } 47 60 48 61 async function check(): Promise<void> { 49 - const dir = glob('**/*.vue', { withFileTypes: true, cwd: APP_DIRECTORY }) 62 + const dir = glob(argvFiles.length > 0 ? argvFiles : '**/*.vue', { 63 + withFileTypes: true, 64 + cwd: APP_DIRECTORY, 65 + }) 50 66 let hasErrors = false 51 67 for await (const file of dir) { 52 68 const result = await checkFile(file) ··· 61 77 process.exit(1) 62 78 } else { 63 79 // oxlint-disable-next-line no-console -- success logging 64 - console.log(`${COLORS.green}✅ CSS RTL check passed!${COLORS.reset}`) 80 + console.log(`${COLORS.green}✅ CSS check passed!${COLORS.reset}`) 65 81 } 66 82 } 67 83
+94
test/unit/uno-preset-a11y.spec.ts
··· 1 + import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' 2 + import { presetA11y, resetA11yWarnings } from '../../uno-preset-a11y' 3 + import { createGenerator, presetWind4 } from 'unocss' 4 + 5 + describe('uno-preset-a11y', () => { 6 + let warnSpy: MockInstance 7 + 8 + beforeEach(() => { 9 + resetA11yWarnings() 10 + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) 11 + }) 12 + 13 + afterEach(() => { 14 + warnSpy.mockRestore() 15 + }) 16 + 17 + it('a11y rules generate font-size and warn correctly', async () => { 18 + const uno = await createGenerator({ 19 + presets: [presetWind4(), presetA11y()], 20 + }) 21 + 22 + const { css } = await uno.generate( 23 + 'text-[11px] text-[10px] text-[9px] text-[8px] text-[12px] text-[1.5em]', 24 + ) 25 + 26 + expect(css).toMatchInlineSnapshot(` 27 + "/* layer: theme */ 28 + :root, :host { --font-sans: ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"; --font-mono: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; --default-font-family: var(--font-sans); --default-monoFont-family: var(--font-mono); } 29 + /* layer: base */ 30 + *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0; padding: 0; border: 0 solid; } html, :host { line-height: 1.5; -webkit-text-size-adjust: 100%; tab-size: 4; font-family: var( --default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji' ); font-feature-settings: var(--default-font-featureSettings, normal); font-variation-settings: var(--default-font-variationSettings, normal); -webkit-tap-highlight-color: transparent; } hr { height: 0; color: inherit; border-top-width: 1px; } abbr:where([title]) { -webkit-text-decoration: underline dotted; text-decoration: underline dotted; } h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit; -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; } code, kbd, samp, pre { font-family: var( --default-monoFont-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace ); font-feature-settings: var(--default-monoFont-featureSettings, normal); font-variation-settings: var(--default-monoFont-variationSettings, normal); font-size: 1em; } small { font-size: 80%; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } table { text-indent: 0; border-color: inherit; border-collapse: collapse; } :-moz-focusring { outline: auto; } progress { vertical-align: baseline; } summary { display: list-item; } ol, ul, menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block; vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select, optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit; font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0; background-color: transparent; opacity: 1; } :where(select:is([multiple], [size])) optgroup { font-weight: bolder; } :where(select:is([multiple], [size])) optgroup option { padding-inline-start: 20px; } ::file-selector-button { margin-inline-end: 4px; } ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { ::placeholder { color: color-mix(in oklab, currentcolor 50%, transparent); } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance: none; } ::-webkit-date-and-time-value { min-height: 1lh; text-align: inherit; } ::-webkit-datetime-edit { display: inline-flex; } ::-webkit-datetime-edit-fields-wrapper { padding: 0; } ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-block: 0; } ::-webkit-calendar-picker-indicator { line-height: 1; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button { appearance: button; } ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } [hidden]:where(:not([hidden~='until-found'])) { display: none !important; } 31 + /* layer: default */ 32 + .text-\\[10px\\]{font-size:10px;} 33 + .text-\\[11px\\]{font-size:11px;} 34 + .text-\\[12px\\]{font-size:12px;} 35 + .text-\\[8px\\]{font-size:8px;} 36 + .text-\\[9px\\]{font-size:9px;} 37 + .text-\\[1\\.5em\\]{font-size:1.5em;}" 38 + `) 39 + 40 + const warnings = warnSpy.mock.calls.flat() 41 + expect(warnings).toMatchInlineSnapshot(` 42 + [ 43 + "[a11y] Avoid using 'text-[11px]', use 'text-2xs' instead.", 44 + "[a11y] Avoid using 'text-[10px]', use 'text-3xs' instead.", 45 + "[a11y] Avoid using 'text-[9px]', use 'text-4xs' instead.", 46 + "[a11y] Avoid using 'text-[8px]', use 'text-5xs' instead.", 47 + "[a11y] Avoid using 'text-[12px]', use text-<size> classes or rem values instead of custom values.", 48 + "[a11y] Avoid using 'text-[1.5em]', use text-<size> classes or rem values instead of custom values.", 49 + ] 50 + `) 51 + }) 52 + 53 + it('when checker is provided, checker is called and no console.warn', async () => { 54 + const collected: Array<[string, string]> = [] 55 + const checker = (warning: string, rule: string) => { 56 + collected.push([warning, rule]) 57 + } 58 + 59 + const uno = await createGenerator({ 60 + presets: [presetWind4(), presetA11y(checker)], 61 + }) 62 + 63 + const { css } = await uno.generate('text-[11px] text-[12px] text-[1.5em]') 64 + 65 + expect(css).toMatchInlineSnapshot(` 66 + "/* layer: theme */ 67 + :root, :host { --font-sans: ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"; --font-mono: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; --default-font-family: var(--font-sans); --default-monoFont-family: var(--font-mono); } 68 + /* layer: base */ 69 + *, ::after, ::before, ::backdrop, ::file-selector-button { box-sizing: border-box; margin: 0; padding: 0; border: 0 solid; } html, :host { line-height: 1.5; -webkit-text-size-adjust: 100%; tab-size: 4; font-family: var( --default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji' ); font-feature-settings: var(--default-font-featureSettings, normal); font-variation-settings: var(--default-font-variationSettings, normal); -webkit-tap-highlight-color: transparent; } hr { height: 0; color: inherit; border-top-width: 1px; } abbr:where([title]) { -webkit-text-decoration: underline dotted; text-decoration: underline dotted; } h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; } a { color: inherit; -webkit-text-decoration: inherit; text-decoration: inherit; } b, strong { font-weight: bolder; } code, kbd, samp, pre { font-family: var( --default-monoFont-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace ); font-feature-settings: var(--default-monoFont-featureSettings, normal); font-variation-settings: var(--default-monoFont-variationSettings, normal); font-size: 1em; } small { font-size: 80%; } sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } sub { bottom: -0.25em; } sup { top: -0.5em; } table { text-indent: 0; border-color: inherit; border-collapse: collapse; } :-moz-focusring { outline: auto; } progress { vertical-align: baseline; } summary { display: list-item; } ol, ul, menu { list-style: none; } img, svg, video, canvas, audio, iframe, embed, object { display: block; vertical-align: middle; } img, video { max-width: 100%; height: auto; } button, input, select, optgroup, textarea, ::file-selector-button { font: inherit; font-feature-settings: inherit; font-variation-settings: inherit; letter-spacing: inherit; color: inherit; border-radius: 0; background-color: transparent; opacity: 1; } :where(select:is([multiple], [size])) optgroup { font-weight: bolder; } :where(select:is([multiple], [size])) optgroup option { padding-inline-start: 20px; } ::file-selector-button { margin-inline-end: 4px; } ::placeholder { opacity: 1; } @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { ::placeholder { color: color-mix(in oklab, currentcolor 50%, transparent); } } textarea { resize: vertical; } ::-webkit-search-decoration { -webkit-appearance: none; } ::-webkit-date-and-time-value { min-height: 1lh; text-align: inherit; } ::-webkit-datetime-edit { display: inline-flex; } ::-webkit-datetime-edit-fields-wrapper { padding: 0; } ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-block: 0; } ::-webkit-calendar-picker-indicator { line-height: 1; } :-moz-ui-invalid { box-shadow: none; } button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button { appearance: button; } ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { height: auto; } [hidden]:where(:not([hidden~='until-found'])) { display: none !important; } 70 + /* layer: default */ 71 + .text-\\[11px\\]{font-size:11px;} 72 + .text-\\[12px\\]{font-size:12px;} 73 + .text-\\[1\\.5em\\]{font-size:1.5em;}" 74 + `) 75 + 76 + expect(warnSpy).not.toHaveBeenCalled() 77 + expect(collected).toMatchInlineSnapshot(` 78 + [ 79 + [ 80 + "[a11y] Avoid using 'text-[11px]', use 'text-2xs' instead.", 81 + "text-[11px]", 82 + ], 83 + [ 84 + "[a11y] Avoid using 'text-[12px]', use text-<size> classes or rem values instead of custom values.", 85 + "text-[12px]", 86 + ], 87 + [ 88 + "[a11y] Avoid using 'text-[1.5em]', use text-<size> classes or rem values instead of custom values.", 89 + "text-[1.5em]", 90 + ], 91 + ] 92 + `) 93 + }) 94 + })
+77
uno-preset-a11y.ts
··· 1 + import type { Preset } from 'unocss' 2 + 3 + export type CollectorChecker = (warning: string, rule: string) => void 4 + 5 + // Track warnings to avoid duplicates 6 + const warnedClasses = new Set<string>() 7 + 8 + function warnOnce(message: string, key: string) { 9 + if (!warnedClasses.has(key)) { 10 + warnedClasses.add(key) 11 + // oxlint-disable-next-line no-console -- warn logging 12 + console.warn(message) 13 + } 14 + } 15 + 16 + /** Reset warning state (for testing) */ 17 + export function resetA11yWarnings() { 18 + warnedClasses.clear() 19 + } 20 + 21 + const textPxToClass: Record<number, string> = { 22 + 11: 'text-2xs', 23 + 10: 'text-3xs', 24 + 9: 'text-4xs', 25 + 8: 'text-5xs', 26 + } 27 + 28 + function reportTextSizeWarning(match: string, suggestion: string, checker?: CollectorChecker) { 29 + const message = `[a11y] Avoid using '${match}', ${suggestion}.` 30 + if (checker) { 31 + checker(message, match) 32 + } else { 33 + warnOnce(message, match) 34 + } 35 + } 36 + 37 + export function presetA11y(checker?: CollectorChecker): Preset { 38 + return { 39 + name: 'a11y-preset', 40 + // text-[N] (arbitrary where N is a size in px or em): recommend text-2xs/text-3xs/text-4xs/text-5xs or "use classes" 41 + rules: [ 42 + [ 43 + /^text-\[(\d+(\.\d+)?)(px)?\]$/, 44 + ([match, numStr], context) => { 45 + const num = Number(numStr) 46 + const fullClass = context.rawSelector || match 47 + const suggestedClass = textPxToClass[num] 48 + if (suggestedClass) { 49 + reportTextSizeWarning(fullClass, `use '${suggestedClass}' instead`, checker) 50 + } else { 51 + reportTextSizeWarning( 52 + fullClass, 53 + 'use text-<size> classes or rem values instead of custom values', 54 + checker, 55 + ) 56 + } 57 + return [['font-size', `${num}px`]] 58 + }, 59 + { autocomplete: 'text-[<num>]' }, 60 + ], 61 + [ 62 + /^text-\[(\d+(\.\d+)?)em\]$/, 63 + ([match, numStr], context) => { 64 + const num = Number(numStr) 65 + const fullClass = context.rawSelector || match 66 + reportTextSizeWarning( 67 + fullClass, 68 + 'use text-<size> classes or rem values instead of custom values', 69 + checker, 70 + ) 71 + return [['font-size', `${num}em`]] 72 + }, 73 + { autocomplete: 'text-[<num>]em' }, 74 + ], 75 + ], 76 + } 77 + }
+2 -1
uno.config.ts
··· 7 7 } from 'unocss' 8 8 import type { Theme } from '@unocss/preset-wind4/theme' 9 9 import { presetRtl } from './uno-preset-rtl' 10 + import { presetA11y } from './uno-preset-a11y' 10 11 11 12 const customIcons = { 12 13 'agent-skills': ··· 32 33 }, 33 34 }), 34 35 // keep this preset last 35 - ...(process.env.CI ? [] : [presetRtl()]), 36 + ...(process.env.CI ? [] : [presetRtl(), presetA11y()]), 36 37 ].filter(Boolean), 37 38 transformers: [transformerDirectives(), transformerVariantGroup()], 38 39 theme: {