Skip to content

Commit 27e4d19

Browse files
committed
chore: add RTL CSS Checker to autofix
1 parent 6acba09 commit 27e4d19

6 files changed

Lines changed: 153 additions & 38 deletions

File tree

.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

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
"knip": "knip",
2122
"knip:fix": "knip --fix",
2223
"lint": "oxlint && oxfmt --check",

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

uno-preset-rtl.ts

Lines changed: 72 additions & 29 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

@@ -38,6 +40,7 @@ const directionMap: Record<string, string[]> = {
3840
function directionSizeRTL(
3941
propertyPrefix: string,
4042
prefixMap?: { l: string; r: string },
43+
checker?: CollectorChecker,
4144
): DynamicMatcher {
4245
const matcher = directionSize(propertyPrefix)
4346
return (args, context) => {
@@ -46,10 +49,16 @@ function directionSizeRTL(
4649
const defaultMap = { l: 'is', r: 'ie' }
4750
const map = prefixMap || defaultMap
4851
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-
)
52+
53+
const fullClass = context.rawSelector || match
54+
const suggestedBase = match.replace(direction === 'l' ? 'l' : 'r', replacement)
55+
const suggestedClass = fullClass.replace(match, suggestedBase)
56+
57+
if (checker) {
58+
checker(`avoid using '${fullClass}', use '${suggestedClass}' instead.`, fullClass)
59+
} else {
60+
warnOnce(`[RTL] Avoid using '${fullClass}'. Use '${suggestedClass}' instead.`, fullClass)
61+
}
5362
return matcher([match, replacement, size], context)
5463
}
5564
}
@@ -78,74 +87,108 @@ function handlerBorderSize([, a = '', b = '1']: string[]): CSSEntries | undefine
7887
/**
7988
* CSS RTL support to detect, replace and warn wrong left/right usages.
8089
*/
81-
export function presetRtl(): Preset {
90+
export function presetRtl(checker?: CollectorChecker): Preset {
8291
return {
8392
name: 'rtl-preset',
8493
rules: [
8594
// RTL overrides
8695
// We need to move the dash out of the capturing group to avoid capturing it in the direction
8796
[
8897
/^p([rl])-(.+)?$/,
89-
directionSizeRTL('padding', { l: 's', r: 'e' }),
98+
directionSizeRTL('padding', { l: 's', r: 'e' }, checker),
9099
{ autocomplete: '(m|p)<directions>-<num>' },
91100
],
92101
[
93102
/^m([rl])-(.+)?$/,
94-
directionSizeRTL('margin', { l: 's', r: 'e' }),
103+
directionSizeRTL('margin', { l: 's', r: 'e' }, checker),
95104
{ autocomplete: '(m|p)<directions>-<num>' },
96105
],
97106
[
98107
/^(?:position-|pos-)?(left|right)-(.+)$/,
99-
([, direction, size], context) => {
108+
([match, direction, size], context) => {
100109
if (!size) return undefined
101110
const replacement = direction === 'left' ? 'inset-is' : 'inset-ie'
102-
warnOnce(
103-
`[RTL] Avoid using '${direction}-${size}'. Use '${replacement}-${size}' instead.`,
104-
`${direction}-${size}`,
105-
)
111+
112+
const fullClass = context.rawSelector || match
113+
const suggestedBase = match.replace(direction!, replacement)
114+
const suggestedClass = fullClass.replace(match, suggestedBase)
115+
116+
if (checker) {
117+
checker(`avoid using '${fullClass}'. Use '${suggestedClass}' instead.`, fullClass)
118+
} else {
119+
warnOnce(
120+
`[RTL] Avoid using '${fullClass}'. Use '${suggestedClass}' instead.`,
121+
fullClass,
122+
)
123+
}
106124
return directionSize('inset')(['', direction === 'left' ? 'is' : 'ie', size], context)
107125
},
108126
{ autocomplete: '(left|right)-<num>' },
109127
],
110128
[
111129
/^text-(left|right)$/,
112-
([, direction]) => {
130+
([match, direction], context) => {
113131
const replacement = direction === 'left' ? 'start' : 'end'
114-
warnOnce(
115-
`[RTL] Avoid using 'text-${direction}'. Use 'text-${replacement}' instead.`,
116-
`text-${direction}`,
117-
)
132+
133+
const fullClass = context.rawSelector || match
134+
const suggestedBase = match.replace(direction!, replacement)
135+
const suggestedClass = fullClass.replace(match, suggestedBase)
136+
137+
if (checker) {
138+
checker(`avoid using '${fullClass}', use '${suggestedClass}' instead.`, fullClass)
139+
} else {
140+
warnOnce(
141+
`[RTL] Avoid using '${fullClass}'. Use '${suggestedClass}' instead.`,
142+
fullClass,
143+
)
144+
}
118145
return { 'text-align': replacement }
119146
},
120147
{ autocomplete: 'text-(left|right)' },
121148
],
122149
[
123150
/^rounded-([rl])(?:-(.+))?$/,
124-
(args, context) => {
125-
const [_, direction, size] = args
151+
([match, direction, size], context) => {
126152
if (!direction) return undefined
127153
const replacementMap: Record<string, string> = {
128154
l: 'is',
129155
r: 'ie',
130156
}
131157
const replacement = replacementMap[direction]
132158
if (!replacement) return undefined
133-
warnOnce(
134-
`[RTL] Avoid using 'rounded-${direction}'. Use 'rounded-${replacement}' instead.`,
135-
`rounded-${direction}`,
136-
)
159+
160+
const fullClass = context.rawSelector || match
161+
const suggestedBase = match.replace(direction!, replacement)
162+
const suggestedClass = fullClass.replace(match, suggestedBase)
163+
164+
if (checker) {
165+
checker(`avoid using '${fullClass}', use '${suggestedClass}' instead.`, fullClass)
166+
} else {
167+
warnOnce(
168+
`[RTL] Avoid using '${fullClass}'. Use '${suggestedClass}' instead.`,
169+
fullClass,
170+
)
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(direction!, replacement)
182+
const suggestedClass = fullClass.replace(match, suggestedBase)
183+
184+
if (checker) {
185+
checker(`avoid using '${fullClass}', use '${suggestedClass}' instead.`, fullClass)
186+
} else {
187+
warnOnce(
188+
`[RTL] Avoid using '${fullClass}'. Use '${suggestedClass}' instead.`,
189+
fullClass,
190+
)
191+
}
149192
return handlerBorderSize(['', replacement, size || '1'])
150193
},
151194
],

0 commit comments

Comments
 (0)