Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion app/components/Package/DownloadAnalytics.vue
Original file line number Diff line number Diff line change
Expand Up @@ -915,7 +915,7 @@ const chartConfig = computed(() => {
${label}
</span>

<span class="text-base text-[var(--fg)] font-mono tabular-nums text-right">
<span class="text-base text-[var(--fg)] font-mono tabular-nums text-end">
${v}
</span>
</div>`
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",
"i18n:report": "node scripts/find-invalid-translations.ts",
"knip": "knip",
"knip:fix": "knip --fix",
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
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)
}
}
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}"`,
}
},
{ 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)

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)

reportWarning(fullClass, suggestedClass, checker)

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