diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 4d78f60d6..8a5effea2 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -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 diff --git a/app/components/Package/DownloadAnalytics.vue b/app/components/Package/DownloadAnalytics.vue index edd5adf21..8823b6a0e 100644 --- a/app/components/Package/DownloadAnalytics.vue +++ b/app/components/Package/DownloadAnalytics.vue @@ -915,7 +915,7 @@ const chartConfig = computed(() => { ${label} - + ${v} ` diff --git a/package.json b/package.json index 407e16a66..232c81754 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/compare-translations.ts b/scripts/compare-translations.ts index 401d6dcbc..3ba5fc3c6 100644 --- a/scripts/compare-translations.ts +++ b/scripts/compare-translations.ts @@ -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 => { diff --git a/scripts/rtl-checker.ts b/scripts/rtl-checker.ts new file mode 100644 index 000000000..af7246983 --- /dev/null +++ b/scripts/rtl-checker.ts @@ -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 { + 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() + 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 { + 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() diff --git a/scripts/utils.ts b/scripts/utils.ts new file mode 100644 index 000000000..0fcd658aa --- /dev/null +++ b/scripts/utils.ts @@ -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 diff --git a/test/unit/uno-preset-rtl.spec.ts b/test/unit/uno-preset-rtl.spec.ts index aac9f6409..b755c5e38 100644 --- a/test/unit/uno-preset-rtl.spec.ts +++ b/test/unit/uno-preset-rtl.spec.ts @@ -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 @@ -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.", + ] + `) }) }) diff --git a/uno-preset-rtl.ts b/uno-preset-rtl.ts index e271744b9..b5ce793d3 100644 --- a/uno-preset-rtl.ts +++ b/uno-preset-rtl.ts @@ -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() @@ -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 = { 'l': ['-left'], 'r': ['-right'], @@ -38,18 +49,22 @@ const directionMap: Record = { 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) } } @@ -78,51 +93,68 @@ 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)-' }, ], [ /^m([rl])-(.+)?$/, - directionSizeRTL('margin', { l: 's', r: 'e' }), + directionSizeRTL('margin', { l: 's', r: 'e' }, checker), { autocomplete: '(m|p)-' }, ], [ /^(?: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)-' }, ], [ - /^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 = { l: 'is', @@ -130,22 +162,27 @@ export function presetRtl(): Preset { } 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']) }, ],