Skip to content

Commit eb19ccd

Browse files
authored
test: configure lint-css to check a11y and rtl (#1203)
1 parent 13ef534 commit eb19ccd

File tree

7 files changed

+198
-7
lines changed

7 files changed

+198
-7
lines changed

.github/workflows/autofix.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ jobs:
3232
- name: 📦 Install dependencies
3333
run: pnpm install
3434

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
3737

3838
- name: 🌐 Compare translations
3939
run: pnpm i18n:check

app/components/LicenseDisplay.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const hasAnyValidLicense = computed(() => tokens.value.some(t => t.type === 'lic
2424
{{ token.value }}
2525
</a>
2626
<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>
2828
</template>
2929
<span
3030
v-if="hasAnyValidLicense"

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@
1919
"i18n:check:fix": "node scripts/compare-translations.ts --fix",
2020
"i18n:report": "node scripts/find-invalid-translations.ts",
2121
"i18n:report:fix": "node scripts/remove-unused-translations.ts",
22-
"rtl:check": "node scripts/rtl-checker.ts",
2322
"knip": "knip",
2423
"knip:fix": "knip --fix",
2524
"lint": "oxlint && oxfmt --check",
2625
"lint:fix": "oxlint --fix && oxfmt",
26+
"lint:css": "node scripts/unocss-checker.ts",
2727
"generate": "nuxt generate",
2828
"npmx-connector": "pnpm --filter npmx-connector dev",
2929
"generate-pwa-icons": "pwa-assets-generator",
@@ -151,6 +151,9 @@
151151
"*.{js,ts,mjs,cjs,vue}": [
152152
"pnpm oxlint --fix"
153153
],
154+
"*.vue": [
155+
"pnpm lint:css"
156+
],
154157
"*.{js,ts,mjs,cjs,vue,json,yml,md,html,css}": [
155158
"pnpm oxfmt"
156159
]
Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { fileURLToPath } from 'node:url'
44
import { resolve } from 'node:path'
55
import { createGenerator } from 'unocss'
66
import { presetRtl } from '../uno-preset-rtl.ts'
7+
import { presetA11y } from '../uno-preset-a11y.ts'
78
import { COLORS } from './utils.ts'
89
import { presetWind4 } from 'unocss'
910

11+
const argvFiles = process.argv.slice(2)
1012
const APP_DIRECTORY = fileURLToPath(new URL('../app', import.meta.url))
1113

1214
async function checkFile(path: Dirent): Promise<string | undefined> {
@@ -33,6 +35,17 @@ async function checkFile(path: Dirent): Promise<string | undefined> {
3335
`${COLORS.red} ❌ [RTL] ${filename}:${idx}${ruleIdx > -1 ? `:${ruleIdx + 1}` : ''} - ${warning}${COLORS.reset}`,
3436
)
3537
}),
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+
}),
3649
],
3750
})
3851
const lines = file.split('\n')
@@ -46,7 +59,10 @@ async function checkFile(path: Dirent): Promise<string | undefined> {
4659
}
4760

4861
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+
})
5066
let hasErrors = false
5167
for await (const file of dir) {
5268
const result = await checkFile(file)
@@ -61,7 +77,7 @@ async function check(): Promise<void> {
6177
process.exit(1)
6278
} else {
6379
// 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}`)
6581
}
6682
}
6783

test/unit/uno-preset-a11y.spec.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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+
})

uno-preset-a11y.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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+
}

uno.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from 'unocss'
88
import type { Theme } from '@unocss/preset-wind4/theme'
99
import { presetRtl } from './uno-preset-rtl'
10+
import { presetA11y } from './uno-preset-a11y'
1011

1112
const customIcons = {
1213
'agent-skills':
@@ -32,7 +33,7 @@ export default defineConfig({
3233
},
3334
}),
3435
// keep this preset last
35-
...(process.env.CI ? [] : [presetRtl()]),
36+
...(process.env.CI ? [] : [presetRtl(), presetA11y()]),
3637
].filter(Boolean),
3738
transformers: [transformerDirectives(), transformerVariantGroup()],
3839
theme: {

0 commit comments

Comments
 (0)