Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
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
75 changes: 45 additions & 30 deletions test/unit/uno-preset-rtl.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { presetRtl, resetRtlWarnings } from '../../uno-preset-rtl'
import { createGenerator } from 'unocss'
import { createGenerator, presetWind4 } from 'unocss'

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

it('rtl rules replace css styles correctly', async () => {
const uno = await createGenerator({
presets: [presetRtl()],
presets: [presetWind4(), presetRtl()],
})

const { css } = await uno.generate(
'left-0 right-0 pl-1 ml-1 pr-1 mr-1 text-left text-right border-l border-r rounded-l rounded-r',
'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',
)

expect(css).toMatchInlineSnapshot(`
"/* layer: default */
.pl-1{padding-inline-start:calc(var(--spacing) * 1);}
.pr-1{padding-inline-end:calc(var(--spacing) * 1);}
.ml-1{margin-inline-start:calc(var(--spacing) * 1);}
.mr-1{margin-inline-end:calc(var(--spacing) * 1);}
.left-0{inset-inline-start:calc(var(--spacing) * 0);}
.right-0{inset-inline-end:calc(var(--spacing) * 0);}
.text-left{text-align:start;}
.text-right{text-align:end;}
.border-l{border-inline-start-width:1px;}
.border-r{border-inline-end-width:1px;}"
`)
"/* layer: theme */
: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); }
/* layer: base */
*, ::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; }
/* layer: shortcuts */
.text-left{text-align:start;--x-rtl-start:"text-left -> text-start";}
.text-right{text-align:end;--x-rtl-end:"text-right -> text-end";}
.hover\\:text-right:hover{text-align:end;--x-rtl-end:"hover:text-right -> hover:text-end";}
/* layer: default */
.pl-1{padding-inline-start:calc(var(--spacing) * 1);}
.pr-1{padding-inline-end:calc(var(--spacing) * 1);}
.ml-1{margin-inline-start:calc(var(--spacing) * 1);}
.mr-1{margin-inline-end:calc(var(--spacing) * 1);}
.left-0{inset-inline-start:calc(var(--spacing) * 0);}
.position-left-4{inset-inline-start:calc(var(--spacing) * 4);}
.right-0{inset-inline-end:calc(var(--spacing) * 0);}
.rounded-l{border-end-start-radius:0.25rem;border-start-start-radius:0.25rem;}
.rounded-r{border-start-end-radius:0.25rem;border-end-end-radius:0.25rem;}
.border-l{border-inline-start-width:1px;}
.border-r{border-inline-end-width:1px;}
@media (min-width: 40rem){
.sm\\:pl-2{padding-inline-start:calc(var(--spacing) * 2);}
}"
`)

const warnings = warnSpy.mock.calls.flat()
expect(warnings).toMatchInlineSnapshot(`
[
"[RTL] Avoid using 'left-0'. Use 'inset-is-0' instead.",
"[RTL] Avoid using 'right-0'. Use 'inset-ie-0' instead.",
"[RTL] Avoid using 'pl-1'. Use 'ps-1' instead.",
"[RTL] Avoid using 'ml-1'. Use 'ms-1' instead.",
"[RTL] Avoid using 'pr-1'. Use 'pe-1' instead.",
"[RTL] Avoid using 'mr-1'. Use 'me-1' instead.",
"[RTL] Avoid using 'text-left'. Use 'text-start' instead.",
"[RTL] Avoid using 'text-right'. Use 'text-end' instead.",
"[RTL] Avoid using 'border-l'. Use 'border-is' instead.",
"[RTL] Avoid using 'border-r'. Use 'border-ie' instead.",
"[RTL] Avoid using 'rounded-l'. Use 'rounded-is' instead.",
"[RTL] Avoid using 'rounded-r'. Use 'rounded-ie' instead.",
]
`)
[
"[RTL] Avoid using 'left-0', use 'inset-is-0' instead.",
"[RTL] Avoid using 'right-0', use 'inset-ie-0' instead.",
"[RTL] Avoid using 'pl-1', use 'ps-1' instead.",
"[RTL] Avoid using 'ml-1', use 'ms-1' instead.",
"[RTL] Avoid using 'pr-1', use 'pe-1' instead.",
"[RTL] Avoid using 'mr-1', use 'me-1' instead.",
"[RTL] Avoid using 'border-l', use 'border-is' instead.",
"[RTL] Avoid using 'border-r', use 'border-ie' instead.",
"[RTL] Avoid using 'rounded-l', use 'rounded-is' instead.",
"[RTL] Avoid using 'rounded-r', use 'rounded-ie' instead.",
"[RTL] Avoid using 'position-left-4', use 'inset-is-4' instead.",
"[RTL] Avoid using 'sm:pl-2', use 'sm:ps-2' instead.",
"[RTL] Avoid using 'text-left', use 'text-start' instead.",
"[RTL] Avoid using 'text-right', use 'text-end' instead.",
"[RTL] Avoid using 'hover:text-right', use 'hover:text-end' instead.",
]
`)
})
})
103 changes: 70 additions & 33 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 All @@ -17,6 +19,15 @@ export function resetRtlWarnings() {
warnedClasses.clear()
}

function reportWarning(match: string, suggestedClass: string, checker?: CollectorChecker) {
const message = `${checker ? 'a' : 'A'}void using '${match}', use '${suggestedClass}' instead.`
if (checker) {
checker(message, match)
} else {
warnOnce(`[RTL] ${message}`, match)
}
}

const directionMap: Record<string, string[]> = {
'l': ['-left'],
'r': ['-right'],
Expand All @@ -38,18 +49,22 @@ const directionMap: Record<string, string[]> = {
function directionSizeRTL(
propertyPrefix: string,
prefixMap?: { l: string; r: string },
checker?: CollectorChecker,
): DynamicMatcher {
const matcher = directionSize(propertyPrefix)
return (args, context) => {
const [match, direction, size] = args
return ([match, direction, size], context) => {
if (!size) return undefined
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 prefix = match.substring(0, 1) // 'p' or 'm'
const suggestedBase = match.replace(`${prefix}${direction!}`, `${prefix}${replacement}`)
const suggestedClass = fullClass.replace(match, suggestedBase)

reportWarning(fullClass, suggestedClass, checker)

return matcher([match, replacement, size], context)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Expand Down Expand Up @@ -78,74 +93,96 @@ 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',
shortcuts: [
['text-left', 'text-start x-rtl-start'],
['text-right', 'text-end x-rtl-end'],
],
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
// match is 'left-4' or 'position-left-4'
// replacement is 'inset-is' or 'inset-ie'
// We want 'inset-is-4'
const suggestedBase = `${replacement}-${size}`
const suggestedClass = fullClass.replace(match, suggestedBase)

reportWarning(fullClass, suggestedClass, checker)

return directionSize('inset')(['', direction === 'left' ? 'is' : 'ie', size], context)
},
{ autocomplete: '(left|right)-<num>' },
],
[
/^text-(left|right)$/,
([, direction]) => {
const replacement = direction === 'left' ? 'start' : 'end'
warnOnce(
`[RTL] Avoid using 'text-${direction}'. Use 'text-${replacement}' instead.`,
`text-${direction}`,
/^x-rtl-(start|end)$/,
([match, direction], context) => {
const originalClass = context.rawSelector || match

const suggestedClass = originalClass.replace(
direction === 'start' ? 'left' : 'right',
direction!,
)
return { 'text-align': replacement }

reportWarning(originalClass, suggestedClass, checker)

// Return a cssvar with the warning message to satisfy UnoCSS
// and avoid "unmatched utility" warning.
return {
[`--x-rtl-${direction!}`]: `"${originalClass} -> ${suggestedClass}"`,
}
Comment thread
userquin marked this conversation as resolved.
},
{ 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(`rounded-${direction!}`, `rounded-${replacement}`)
const suggestedClass = fullClass.replace(match, suggestedBase)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
reportWarning(fullClass, suggestedClass, checker)

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(`border-${direction!}`, `border-${replacement}`)
const suggestedClass = fullClass.replace(match, suggestedBase)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
reportWarning(fullClass, suggestedClass, checker)

return handlerBorderSize(['', replacement, size || '1'])
},
],
Expand Down
Loading