Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/autofix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ jobs:
- name: 📦 Install dependencies
run: pnpm install

- name: 🎨 Check for non-RTL CSS classes
run: pnpm rtl:check

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

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"dev:docs": "pnpm run --filter npmx-docs dev --port=3001",
"i18n:check": "node scripts/compare-translations.ts",
"i18n:check:fix": "node scripts/compare-translations.ts --fix",
"rtl:check": "node scripts/rtl-checker.ts",
"knip": "knip",
"knip:fix": "knip --fix",
"lint": "oxlint && oxfmt --check",
Expand Down
10 changes: 1 addition & 9 deletions scripts/compare-translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,11 @@ import process from 'node:process'
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { COLORS } from './utils.ts'

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

const COLORS = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
} as const

type NestedObject = { [key: string]: unknown }

const loadJson = (filePath: string): NestedObject => {
Expand Down
68 changes: 68 additions & 0 deletions scripts/rtl-checker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { Dirent } from 'node:fs'
import { glob, readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { resolve } from 'node:path'
import { createGenerator } from 'unocss'
import { presetRtl } from '../uno-preset-rtl.ts'
import { COLORS } from './utils.ts'
import { presetWind4 } from 'unocss'

const APP_DIRECTORY = fileURLToPath(new URL('../app', import.meta.url))

async function checkFile(path: Dirent): Promise<string | undefined> {
if (path.isDirectory() || !path.name.endsWith('.vue')) {
return undefined
}

const filename = resolve(APP_DIRECTORY, path.parentPath, path.name)
const file = await readFile(filename, 'utf-8')
let idx = -1
let line: string
const warnings = new Map<number, string[]>()
const uno = await createGenerator({
presets: [
presetWind4(),
presetRtl((warning, rule) => {
let entry = warnings.get(idx)
if (!entry) {
entry = []
warnings.set(idx, entry)
}
const ruleIdx = line.indexOf(rule)
entry.push(
`${COLORS.red} ❌ [RTL] ${filename}:${idx}${ruleIdx > -1 ? `:${ruleIdx + 1}` : ''} - ${warning}${COLORS.reset}`,
)
}),
],
})
const lines = file.split('\n')
for (let i = 0; i < lines.length; i++) {
idx = i + 1
line = lines[i]
await uno.generate(line)
}

return warnings.size > 0 ? Array.from(warnings.values()).flat().join('\n') : undefined
}

async function check(): Promise<void> {
const dir = glob('**/*.vue', { withFileTypes: true, cwd: APP_DIRECTORY })
let hasErrors = false
for await (const file of dir) {
const result = await checkFile(file)
if (result) {
hasErrors = true
// oxlint-disable-next-line no-console -- warn logging
console.error(result)
}
}

if (hasErrors) {
process.exit(1)
} else {
// oxlint-disable-next-line no-console -- success logging
console.log(`${COLORS.green}✅ CSS RTL check passed!${COLORS.reset}`)
}
}

check()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unhandled promise rejection — add a .catch() handler.

check() returns Promise<void>. If any file read, glob, or generation throws, the rejection is unhandled. Node will exit with a non-zero code on unhandled rejections, but the error message will be a raw stack trace rather than a clear diagnostic.

🛡️ Proposed fix
-check()
+check().catch((error) => {
+  console.error(`${COLORS.red}RTL check failed: ${error}${COLORS.reset}`)
+  process.exit(1)
+})

Based on learnings: "Use error handling patterns consistently".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
check()
check().catch((error) => {
console.error(`${COLORS.red}RTL check failed: ${error}${COLORS.reset}`)
process.exit(1)
})

8 changes: 8 additions & 0 deletions scripts/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const COLORS = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
} as const
101 changes: 72 additions & 29 deletions uno-preset-rtl.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { CSSEntries, DynamicMatcher, Preset, RuleContext } from 'unocss'
import { cornerMap, directionSize, h } from '@unocss/preset-wind4/utils'

export type CollectorChecker = (warning: string, rule: string) => void

// Track warnings to avoid duplicates
const warnedClasses = new Set<string>()

Expand Down Expand Up @@ -38,6 +40,7 @@ const directionMap: Record<string, string[]> = {
function directionSizeRTL(
propertyPrefix: string,
prefixMap?: { l: string; r: string },
checker?: CollectorChecker,
): DynamicMatcher {
const matcher = directionSize(propertyPrefix)
return (args, context) => {
Expand All @@ -46,10 +49,16 @@ function directionSizeRTL(
const defaultMap = { l: 'is', r: 'ie' }
const map = prefixMap || defaultMap
const replacement = map[direction as 'l' | 'r']
warnOnce(
`[RTL] Avoid using '${match}'. Use '${match.replace(direction === 'l' ? 'l' : 'r', replacement)}' instead.`,
match,
)

const fullClass = context.rawSelector || match
const suggestedBase = match.replace(direction === 'l' ? 'l' : 'r', replacement)
const suggestedClass = fullClass.replace(match, suggestedBase)

if (checker) {
checker(`avoid using '${fullClass}', use '${suggestedClass}' instead.`, fullClass)
} else {
warnOnce(`[RTL] Avoid using '${fullClass}'. Use '${suggestedClass}' instead.`, fullClass)
}
return matcher([match, replacement, size], context)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Expand Down Expand Up @@ -78,74 +87,108 @@ function handlerBorderSize([, a = '', b = '1']: string[]): CSSEntries | undefine
/**
* CSS RTL support to detect, replace and warn wrong left/right usages.
*/
export function presetRtl(): Preset {
export function presetRtl(checker?: CollectorChecker): Preset {
return {
name: 'rtl-preset',
rules: [
// RTL overrides
// We need to move the dash out of the capturing group to avoid capturing it in the direction
[
/^p([rl])-(.+)?$/,
directionSizeRTL('padding', { l: 's', r: 'e' }),
directionSizeRTL('padding', { l: 's', r: 'e' }, checker),
{ autocomplete: '(m|p)<directions>-<num>' },
],
[
/^m([rl])-(.+)?$/,
directionSizeRTL('margin', { l: 's', r: 'e' }),
directionSizeRTL('margin', { l: 's', r: 'e' }, checker),
{ autocomplete: '(m|p)<directions>-<num>' },
],
[
/^(?:position-|pos-)?(left|right)-(.+)$/,
([, direction, size], context) => {
([match, direction, size], context) => {
if (!size) return undefined
const replacement = direction === 'left' ? 'inset-is' : 'inset-ie'
warnOnce(
`[RTL] Avoid using '${direction}-${size}'. Use '${replacement}-${size}' instead.`,
`${direction}-${size}`,
)

const fullClass = context.rawSelector || match
const suggestedBase = match.replace(direction!, replacement)
const suggestedClass = fullClass.replace(match, suggestedBase)

if (checker) {
checker(`avoid using '${fullClass}'. Use '${suggestedClass}' instead.`, fullClass)
} else {
warnOnce(
`[RTL] Avoid using '${fullClass}'. Use '${suggestedClass}' instead.`,
fullClass,
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return directionSize('inset')(['', direction === 'left' ? 'is' : 'ie', size], context)
},
{ autocomplete: '(left|right)-<num>' },
],
[
/^text-(left|right)$/,
([, direction]) => {
([match, direction], context) => {
const replacement = direction === 'left' ? 'start' : 'end'
warnOnce(
`[RTL] Avoid using 'text-${direction}'. Use 'text-${replacement}' instead.`,
`text-${direction}`,
)

const fullClass = context.rawSelector || match
const suggestedBase = match.replace(direction!, replacement)
const suggestedClass = fullClass.replace(match, suggestedBase)

if (checker) {
checker(`avoid using '${fullClass}', use '${suggestedClass}' instead.`, fullClass)
} else {
warnOnce(
`[RTL] Avoid using '${fullClass}'. Use '${suggestedClass}' instead.`,
fullClass,
)
}
Comment thread
userquin marked this conversation as resolved.
return { 'text-align': replacement }
},
{ autocomplete: 'text-(left|right)' },
],
[
/^rounded-([rl])(?:-(.+))?$/,
(args, context) => {
const [_, direction, size] = args
([match, direction, size], context) => {
if (!direction) return undefined
const replacementMap: Record<string, string> = {
l: 'is',
r: 'ie',
}
const replacement = replacementMap[direction]
if (!replacement) return undefined
warnOnce(
`[RTL] Avoid using 'rounded-${direction}'. Use 'rounded-${replacement}' instead.`,
`rounded-${direction}`,
)

const fullClass = context.rawSelector || match
const suggestedBase = match.replace(direction!, replacement)
const suggestedClass = fullClass.replace(match, suggestedBase)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (checker) {
checker(`avoid using '${fullClass}', use '${suggestedClass}' instead.`, fullClass)
} else {
warnOnce(
`[RTL] Avoid using '${fullClass}'. Use '${suggestedClass}' instead.`,
fullClass,
)
}
return handlerRounded(['', replacement, size ?? 'DEFAULT'], context)
},
],
[
/^border-([rl])(?:-(.+))?$/,
args => {
const [_, direction, size] = args
([match, direction, size], context) => {
const replacement = direction === 'l' ? 'is' : 'ie'
warnOnce(
`[RTL] Avoid using 'border-${direction}'. Use 'border-${replacement}' instead.`,
`border-${direction}`,
)

const fullClass = context.rawSelector || match
const suggestedBase = match.replace(direction!, replacement)
const suggestedClass = fullClass.replace(match, suggestedBase)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (checker) {
checker(`avoid using '${fullClass}', use '${suggestedClass}' instead.`, fullClass)
} else {
warnOnce(
`[RTL] Avoid using '${fullClass}'. Use '${suggestedClass}' instead.`,
fullClass,
)
}
return handlerBorderSize(['', replacement, size || '1'])
},
],
Expand Down
Loading