Skip to content

Commit e076462

Browse files
userquindanielroe
andauthored
chore: add RTL CSS Checker to autofix (#1077)
Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent 2acb298 commit e076462

File tree

8 files changed

+197
-73
lines changed

8 files changed

+197
-73
lines changed

.github/workflows/autofix.yml

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

35+
- name: 🎨 Check for non-RTL CSS classes
36+
run: pnpm rtl:check
37+
3538
- name: 🌐 Compare translations
3639
run: pnpm i18n:check
3740

app/components/Package/DownloadAnalytics.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -915,7 +915,7 @@ const chartConfig = computed(() => {
915915
${label}
916916
</span>
917917
918-
<span class="text-base text-[var(--fg)] font-mono tabular-nums text-right">
918+
<span class="text-base text-[var(--fg)] font-mono tabular-nums text-end">
919919
${v}
920920
</span>
921921
</div>`

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"dev:docs": "pnpm run --filter npmx-docs dev --port=3001",
1818
"i18n:check": "node scripts/compare-translations.ts",
1919
"i18n:check:fix": "node scripts/compare-translations.ts --fix",
20+
"rtl:check": "node scripts/rtl-checker.ts",
2021
"i18n:report": "node scripts/find-invalid-translations.ts",
2122
"knip": "knip",
2223
"knip:fix": "knip --fix",

scripts/compare-translations.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,11 @@ import process from 'node:process'
33
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
44
import { join } from 'node:path'
55
import { fileURLToPath } from 'node:url'
6+
import { COLORS } from './utils.ts'
67

78
const LOCALES_DIRECTORY = fileURLToPath(new URL('../i18n/locales', import.meta.url))
89
const REFERENCE_FILE_NAME = 'en.json'
910

10-
const COLORS = {
11-
reset: '\x1b[0m',
12-
red: '\x1b[31m',
13-
green: '\x1b[32m',
14-
yellow: '\x1b[33m',
15-
magenta: '\x1b[35m',
16-
cyan: '\x1b[36m',
17-
} as const
18-
1911
type NestedObject = { [key: string]: unknown }
2012

2113
const loadJson = (filePath: string): NestedObject => {

scripts/rtl-checker.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { Dirent } from 'node:fs'
2+
import { glob, readFile } from 'node:fs/promises'
3+
import { fileURLToPath } from 'node:url'
4+
import { resolve } from 'node:path'
5+
import { createGenerator } from 'unocss'
6+
import { presetRtl } from '../uno-preset-rtl.ts'
7+
import { COLORS } from './utils.ts'
8+
import { presetWind4 } from 'unocss'
9+
10+
const APP_DIRECTORY = fileURLToPath(new URL('../app', import.meta.url))
11+
12+
async function checkFile(path: Dirent): Promise<string | undefined> {
13+
if (path.isDirectory() || !path.name.endsWith('.vue')) {
14+
return undefined
15+
}
16+
17+
const filename = resolve(APP_DIRECTORY, path.parentPath, path.name)
18+
const file = await readFile(filename, 'utf-8')
19+
let idx = -1
20+
let line: string
21+
const warnings = new Map<number, string[]>()
22+
const uno = await createGenerator({
23+
presets: [
24+
presetWind4(),
25+
presetRtl((warning, rule) => {
26+
let entry = warnings.get(idx)
27+
if (!entry) {
28+
entry = []
29+
warnings.set(idx, entry)
30+
}
31+
const ruleIdx = line.indexOf(rule)
32+
entry.push(
33+
`${COLORS.red} ❌ [RTL] ${filename}:${idx}${ruleIdx > -1 ? `:${ruleIdx + 1}` : ''} - ${warning}${COLORS.reset}`,
34+
)
35+
}),
36+
],
37+
})
38+
const lines = file.split('\n')
39+
for (let i = 0; i < lines.length; i++) {
40+
idx = i + 1
41+
line = lines[i]
42+
await uno.generate(line)
43+
}
44+
45+
return warnings.size > 0 ? Array.from(warnings.values()).flat().join('\n') : undefined
46+
}
47+
48+
async function check(): Promise<void> {
49+
const dir = glob('**/*.vue', { withFileTypes: true, cwd: APP_DIRECTORY })
50+
let hasErrors = false
51+
for await (const file of dir) {
52+
const result = await checkFile(file)
53+
if (result) {
54+
hasErrors = true
55+
// oxlint-disable-next-line no-console -- warn logging
56+
console.error(result)
57+
}
58+
}
59+
60+
if (hasErrors) {
61+
process.exit(1)
62+
} else {
63+
// oxlint-disable-next-line no-console -- success logging
64+
console.log(`${COLORS.green}✅ CSS RTL check passed!${COLORS.reset}`)
65+
}
66+
}
67+
68+
check()

scripts/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const COLORS = {
2+
reset: '\x1b[0m',
3+
red: '\x1b[31m',
4+
green: '\x1b[32m',
5+
yellow: '\x1b[33m',
6+
magenta: '\x1b[35m',
7+
cyan: '\x1b[36m',
8+
} as const

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

Lines changed: 45 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
22
import { presetRtl, resetRtlWarnings } from '../../uno-preset-rtl'
3-
import { createGenerator } from 'unocss'
3+
import { createGenerator, presetWind4 } from 'unocss'
44

55
describe('uno-preset-rtl', () => {
66
let warnSpy: MockInstance
@@ -16,43 +16,58 @@ describe('uno-preset-rtl', () => {
1616

1717
it('rtl rules replace css styles correctly', async () => {
1818
const uno = await createGenerator({
19-
presets: [presetRtl()],
19+
presets: [presetWind4(), presetRtl()],
2020
})
2121

2222
const { css } = await uno.generate(
23-
'left-0 right-0 pl-1 ml-1 pr-1 mr-1 text-left text-right border-l border-r rounded-l rounded-r',
23+
'left-0 right-0 pl-1 ml-1 pr-1 mr-1 text-left text-right border-l border-r rounded-l rounded-r sm:pl-2 hover:text-right position-left-4',
2424
)
2525

2626
expect(css).toMatchInlineSnapshot(`
27-
"/* layer: default */
28-
.pl-1{padding-inline-start:calc(var(--spacing) * 1);}
29-
.pr-1{padding-inline-end:calc(var(--spacing) * 1);}
30-
.ml-1{margin-inline-start:calc(var(--spacing) * 1);}
31-
.mr-1{margin-inline-end:calc(var(--spacing) * 1);}
32-
.left-0{inset-inline-start:calc(var(--spacing) * 0);}
33-
.right-0{inset-inline-end:calc(var(--spacing) * 0);}
34-
.text-left{text-align:start;}
35-
.text-right{text-align:end;}
36-
.border-l{border-inline-start-width:1px;}
37-
.border-r{border-inline-end-width:1px;}"
38-
`)
27+
"/* layer: theme */
28+
:root, :host { --spacing: 0.25rem; --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: shortcuts */
32+
.text-left{text-align:start;--x-rtl-start:"text-left -> text-start";}
33+
.text-right{text-align:end;--x-rtl-end:"text-right -> text-end";}
34+
.hover\\:text-right:hover{text-align:end;--x-rtl-end:"hover:text-right -> hover:text-end";}
35+
/* layer: default */
36+
.pl-1{padding-inline-start:calc(var(--spacing) * 1);}
37+
.pr-1{padding-inline-end:calc(var(--spacing) * 1);}
38+
.ml-1{margin-inline-start:calc(var(--spacing) * 1);}
39+
.mr-1{margin-inline-end:calc(var(--spacing) * 1);}
40+
.left-0{inset-inline-start:calc(var(--spacing) * 0);}
41+
.position-left-4{inset-inline-start:calc(var(--spacing) * 4);}
42+
.right-0{inset-inline-end:calc(var(--spacing) * 0);}
43+
.rounded-l{border-end-start-radius:0.25rem;border-start-start-radius:0.25rem;}
44+
.rounded-r{border-start-end-radius:0.25rem;border-end-end-radius:0.25rem;}
45+
.border-l{border-inline-start-width:1px;}
46+
.border-r{border-inline-end-width:1px;}
47+
@media (min-width: 40rem){
48+
.sm\\:pl-2{padding-inline-start:calc(var(--spacing) * 2);}
49+
}"
50+
`)
3951

4052
const warnings = warnSpy.mock.calls.flat()
4153
expect(warnings).toMatchInlineSnapshot(`
42-
[
43-
"[RTL] Avoid using 'left-0'. Use 'inset-is-0' instead.",
44-
"[RTL] Avoid using 'right-0'. Use 'inset-ie-0' instead.",
45-
"[RTL] Avoid using 'pl-1'. Use 'ps-1' instead.",
46-
"[RTL] Avoid using 'ml-1'. Use 'ms-1' instead.",
47-
"[RTL] Avoid using 'pr-1'. Use 'pe-1' instead.",
48-
"[RTL] Avoid using 'mr-1'. Use 'me-1' instead.",
49-
"[RTL] Avoid using 'text-left'. Use 'text-start' instead.",
50-
"[RTL] Avoid using 'text-right'. Use 'text-end' instead.",
51-
"[RTL] Avoid using 'border-l'. Use 'border-is' instead.",
52-
"[RTL] Avoid using 'border-r'. Use 'border-ie' instead.",
53-
"[RTL] Avoid using 'rounded-l'. Use 'rounded-is' instead.",
54-
"[RTL] Avoid using 'rounded-r'. Use 'rounded-ie' instead.",
55-
]
56-
`)
54+
[
55+
"[RTL] Avoid using 'left-0', use 'inset-is-0' instead.",
56+
"[RTL] Avoid using 'right-0', use 'inset-ie-0' instead.",
57+
"[RTL] Avoid using 'pl-1', use 'ps-1' instead.",
58+
"[RTL] Avoid using 'ml-1', use 'ms-1' instead.",
59+
"[RTL] Avoid using 'pr-1', use 'pe-1' instead.",
60+
"[RTL] Avoid using 'mr-1', use 'me-1' instead.",
61+
"[RTL] Avoid using 'border-l', use 'border-is' instead.",
62+
"[RTL] Avoid using 'border-r', use 'border-ie' instead.",
63+
"[RTL] Avoid using 'rounded-l', use 'rounded-is' instead.",
64+
"[RTL] Avoid using 'rounded-r', use 'rounded-ie' instead.",
65+
"[RTL] Avoid using 'position-left-4', use 'inset-is-4' instead.",
66+
"[RTL] Avoid using 'sm:pl-2', use 'sm:ps-2' instead.",
67+
"[RTL] Avoid using 'text-left', use 'text-start' instead.",
68+
"[RTL] Avoid using 'text-right', use 'text-end' instead.",
69+
"[RTL] Avoid using 'hover:text-right', use 'hover:text-end' instead.",
70+
]
71+
`)
5772
})
5873
})

uno-preset-rtl.ts

Lines changed: 70 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { CSSEntries, DynamicMatcher, Preset, RuleContext } from 'unocss'
22
import { cornerMap, directionSize, h } from '@unocss/preset-wind4/utils'
33

4+
export type CollectorChecker = (warning: string, rule: string) => void
5+
46
// Track warnings to avoid duplicates
57
const warnedClasses = new Set<string>()
68

@@ -17,6 +19,15 @@ export function resetRtlWarnings() {
1719
warnedClasses.clear()
1820
}
1921

22+
function reportWarning(match: string, suggestedClass: string, checker?: CollectorChecker) {
23+
const message = `${checker ? 'a' : 'A'}void using '${match}', use '${suggestedClass}' instead.`
24+
if (checker) {
25+
checker(message, match)
26+
} else {
27+
warnOnce(`[RTL] ${message}`, match)
28+
}
29+
}
30+
2031
const directionMap: Record<string, string[]> = {
2132
'l': ['-left'],
2233
'r': ['-right'],
@@ -38,18 +49,22 @@ const directionMap: Record<string, string[]> = {
3849
function directionSizeRTL(
3950
propertyPrefix: string,
4051
prefixMap?: { l: string; r: string },
52+
checker?: CollectorChecker,
4153
): DynamicMatcher {
4254
const matcher = directionSize(propertyPrefix)
43-
return (args, context) => {
44-
const [match, direction, size] = args
55+
return ([match, direction, size], context) => {
4556
if (!size) return undefined
4657
const defaultMap = { l: 'is', r: 'ie' }
4758
const map = prefixMap || defaultMap
4859
const replacement = map[direction as 'l' | 'r']
49-
warnOnce(
50-
`[RTL] Avoid using '${match}'. Use '${match.replace(direction === 'l' ? 'l' : 'r', replacement)}' instead.`,
51-
match,
52-
)
60+
61+
const fullClass = context.rawSelector || match
62+
const prefix = match.substring(0, 1) // 'p' or 'm'
63+
const suggestedBase = match.replace(`${prefix}${direction!}`, `${prefix}${replacement}`)
64+
const suggestedClass = fullClass.replace(match, suggestedBase)
65+
66+
reportWarning(fullClass, suggestedClass, checker)
67+
5368
return matcher([match, replacement, size], context)
5469
}
5570
}
@@ -78,74 +93,96 @@ function handlerBorderSize([, a = '', b = '1']: string[]): CSSEntries | undefine
7893
/**
7994
* CSS RTL support to detect, replace and warn wrong left/right usages.
8095
*/
81-
export function presetRtl(): Preset {
96+
export function presetRtl(checker?: CollectorChecker): Preset {
8297
return {
8398
name: 'rtl-preset',
99+
shortcuts: [
100+
['text-left', 'text-start x-rtl-start'],
101+
['text-right', 'text-end x-rtl-end'],
102+
],
84103
rules: [
85104
// RTL overrides
86105
// We need to move the dash out of the capturing group to avoid capturing it in the direction
87106
[
88107
/^p([rl])-(.+)?$/,
89-
directionSizeRTL('padding', { l: 's', r: 'e' }),
108+
directionSizeRTL('padding', { l: 's', r: 'e' }, checker),
90109
{ autocomplete: '(m|p)<directions>-<num>' },
91110
],
92111
[
93112
/^m([rl])-(.+)?$/,
94-
directionSizeRTL('margin', { l: 's', r: 'e' }),
113+
directionSizeRTL('margin', { l: 's', r: 'e' }, checker),
95114
{ autocomplete: '(m|p)<directions>-<num>' },
96115
],
97116
[
98117
/^(?:position-|pos-)?(left|right)-(.+)$/,
99-
([, direction, size], context) => {
118+
([match, direction, size], context) => {
100119
if (!size) return undefined
101120
const replacement = direction === 'left' ? 'inset-is' : 'inset-ie'
102-
warnOnce(
103-
`[RTL] Avoid using '${direction}-${size}'. Use '${replacement}-${size}' instead.`,
104-
`${direction}-${size}`,
105-
)
121+
122+
const fullClass = context.rawSelector || match
123+
// match is 'left-4' or 'position-left-4'
124+
// replacement is 'inset-is' or 'inset-ie'
125+
// We want 'inset-is-4'
126+
const suggestedBase = `${replacement}-${size}`
127+
const suggestedClass = fullClass.replace(match, suggestedBase)
128+
129+
reportWarning(fullClass, suggestedClass, checker)
130+
106131
return directionSize('inset')(['', direction === 'left' ? 'is' : 'ie', size], context)
107132
},
108133
{ autocomplete: '(left|right)-<num>' },
109134
],
110135
[
111-
/^text-(left|right)$/,
112-
([, direction]) => {
113-
const replacement = direction === 'left' ? 'start' : 'end'
114-
warnOnce(
115-
`[RTL] Avoid using 'text-${direction}'. Use 'text-${replacement}' instead.`,
116-
`text-${direction}`,
136+
/^x-rtl-(start|end)$/,
137+
([match, direction], context) => {
138+
const originalClass = context.rawSelector || match
139+
140+
const suggestedClass = originalClass.replace(
141+
direction === 'start' ? 'left' : 'right',
142+
direction!,
117143
)
118-
return { 'text-align': replacement }
144+
145+
reportWarning(originalClass, suggestedClass, checker)
146+
147+
// Return a cssvar with the warning message to satisfy UnoCSS
148+
// and avoid "unmatched utility" warning.
149+
return {
150+
[`--x-rtl-${direction!}`]: `"${originalClass} -> ${suggestedClass}"`,
151+
}
119152
},
120153
{ autocomplete: 'text-(left|right)' },
121154
],
122155
[
123156
/^rounded-([rl])(?:-(.+))?$/,
124-
(args, context) => {
125-
const [_, direction, size] = args
157+
([match, direction, size], context) => {
126158
if (!direction) return undefined
127159
const replacementMap: Record<string, string> = {
128160
l: 'is',
129161
r: 'ie',
130162
}
131163
const replacement = replacementMap[direction]
132164
if (!replacement) return undefined
133-
warnOnce(
134-
`[RTL] Avoid using 'rounded-${direction}'. Use 'rounded-${replacement}' instead.`,
135-
`rounded-${direction}`,
136-
)
165+
166+
const fullClass = context.rawSelector || match
167+
const suggestedBase = match.replace(`rounded-${direction!}`, `rounded-${replacement}`)
168+
const suggestedClass = fullClass.replace(match, suggestedBase)
169+
170+
reportWarning(fullClass, suggestedClass, checker)
171+
137172
return handlerRounded(['', replacement, size ?? 'DEFAULT'], context)
138173
},
139174
],
140175
[
141176
/^border-([rl])(?:-(.+))?$/,
142-
args => {
143-
const [_, direction, size] = args
177+
([match, direction, size], context) => {
144178
const replacement = direction === 'l' ? 'is' : 'ie'
145-
warnOnce(
146-
`[RTL] Avoid using 'border-${direction}'. Use 'border-${replacement}' instead.`,
147-
`border-${direction}`,
148-
)
179+
180+
const fullClass = context.rawSelector || match
181+
const suggestedBase = match.replace(`border-${direction!}`, `border-${replacement}`)
182+
const suggestedClass = fullClass.replace(match, suggestedBase)
183+
184+
reportWarning(fullClass, suggestedClass, checker)
185+
149186
return handlerBorderSize(['', replacement, size || '1'])
150187
},
151188
],

0 commit comments

Comments
 (0)