[READ-ONLY] a fast, modern browser for the npm registry

chore: add RTL CSS Checker to autofix (#1077)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Joaquín Sánchez
Daniel Roe
and committed by
GitHub
e0764627 2acb2982

+197 -73
+3
.github/workflows/autofix.yml
··· 32 32 - name: 📦 Install dependencies 33 33 run: pnpm install 34 34 35 + - name: 🎨 Check for non-RTL CSS classes 36 + run: pnpm rtl:check 37 + 35 38 - name: 🌐 Compare translations 36 39 run: pnpm i18n:check 37 40
+1 -1
app/components/Package/DownloadAnalytics.vue
··· 915 915 ${label} 916 916 </span> 917 917 918 - <span class="text-base text-[var(--fg)] font-mono tabular-nums text-right"> 918 + <span class="text-base text-[var(--fg)] font-mono tabular-nums text-end"> 919 919 ${v} 920 920 </span> 921 921 </div>`
+1
package.json
··· 17 17 "dev:docs": "pnpm run --filter npmx-docs dev --port=3001", 18 18 "i18n:check": "node scripts/compare-translations.ts", 19 19 "i18n:check:fix": "node scripts/compare-translations.ts --fix", 20 + "rtl:check": "node scripts/rtl-checker.ts", 20 21 "i18n:report": "node scripts/find-invalid-translations.ts", 21 22 "knip": "knip", 22 23 "knip:fix": "knip --fix",
+1 -9
scripts/compare-translations.ts
··· 3 3 import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' 4 4 import { join } from 'node:path' 5 5 import { fileURLToPath } from 'node:url' 6 + import { COLORS } from './utils.ts' 6 7 7 8 const LOCALES_DIRECTORY = fileURLToPath(new URL('../i18n/locales', import.meta.url)) 8 9 const REFERENCE_FILE_NAME = 'en.json' 9 - 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 10 19 11 type NestedObject = { [key: string]: unknown } 20 12
+68
scripts/rtl-checker.ts
··· 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()
+8
scripts/utils.ts
··· 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
+45 -30
test/unit/uno-preset-rtl.spec.ts
··· 1 1 import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' 2 2 import { presetRtl, resetRtlWarnings } from '../../uno-preset-rtl' 3 - import { createGenerator } from 'unocss' 3 + import { createGenerator, presetWind4 } from 'unocss' 4 4 5 5 describe('uno-preset-rtl', () => { 6 6 let warnSpy: MockInstance ··· 16 16 17 17 it('rtl rules replace css styles correctly', async () => { 18 18 const uno = await createGenerator({ 19 - presets: [presetRtl()], 19 + presets: [presetWind4(), presetRtl()], 20 20 }) 21 21 22 22 const { css } = await uno.generate( 23 - 'left-0 right-0 pl-1 ml-1 pr-1 mr-1 text-left text-right border-l border-r rounded-l rounded-r', 23 + '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', 24 24 ) 25 25 26 26 expect(css).toMatchInlineSnapshot(` 27 - "/* layer: default */ 28 - .pl-1{padding-inline-start:calc(var(--spacing) * 1);} 29 - .pr-1{padding-inline-end:calc(var(--spacing) * 1);} 30 - .ml-1{margin-inline-start:calc(var(--spacing) * 1);} 31 - .mr-1{margin-inline-end:calc(var(--spacing) * 1);} 32 - .left-0{inset-inline-start:calc(var(--spacing) * 0);} 33 - .right-0{inset-inline-end:calc(var(--spacing) * 0);} 34 - .text-left{text-align:start;} 35 - .text-right{text-align:end;} 36 - .border-l{border-inline-start-width:1px;} 37 - .border-r{border-inline-end-width:1px;}" 38 - `) 27 + "/* layer: theme */ 28 + :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); } 29 + /* layer: base */ 30 + *, ::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; } 31 + /* layer: shortcuts */ 32 + .text-left{text-align:start;--x-rtl-start:"text-left -> text-start";} 33 + .text-right{text-align:end;--x-rtl-end:"text-right -> text-end";} 34 + .hover\\:text-right:hover{text-align:end;--x-rtl-end:"hover:text-right -> hover:text-end";} 35 + /* layer: default */ 36 + .pl-1{padding-inline-start:calc(var(--spacing) * 1);} 37 + .pr-1{padding-inline-end:calc(var(--spacing) * 1);} 38 + .ml-1{margin-inline-start:calc(var(--spacing) * 1);} 39 + .mr-1{margin-inline-end:calc(var(--spacing) * 1);} 40 + .left-0{inset-inline-start:calc(var(--spacing) * 0);} 41 + .position-left-4{inset-inline-start:calc(var(--spacing) * 4);} 42 + .right-0{inset-inline-end:calc(var(--spacing) * 0);} 43 + .rounded-l{border-end-start-radius:0.25rem;border-start-start-radius:0.25rem;} 44 + .rounded-r{border-start-end-radius:0.25rem;border-end-end-radius:0.25rem;} 45 + .border-l{border-inline-start-width:1px;} 46 + .border-r{border-inline-end-width:1px;} 47 + @media (min-width: 40rem){ 48 + .sm\\:pl-2{padding-inline-start:calc(var(--spacing) * 2);} 49 + }" 50 + `) 39 51 40 52 const warnings = warnSpy.mock.calls.flat() 41 53 expect(warnings).toMatchInlineSnapshot(` 42 - [ 43 - "[RTL] Avoid using 'left-0'. Use 'inset-is-0' instead.", 44 - "[RTL] Avoid using 'right-0'. Use 'inset-ie-0' instead.", 45 - "[RTL] Avoid using 'pl-1'. Use 'ps-1' instead.", 46 - "[RTL] Avoid using 'ml-1'. Use 'ms-1' instead.", 47 - "[RTL] Avoid using 'pr-1'. Use 'pe-1' instead.", 48 - "[RTL] Avoid using 'mr-1'. Use 'me-1' instead.", 49 - "[RTL] Avoid using 'text-left'. Use 'text-start' instead.", 50 - "[RTL] Avoid using 'text-right'. Use 'text-end' instead.", 51 - "[RTL] Avoid using 'border-l'. Use 'border-is' instead.", 52 - "[RTL] Avoid using 'border-r'. Use 'border-ie' instead.", 53 - "[RTL] Avoid using 'rounded-l'. Use 'rounded-is' instead.", 54 - "[RTL] Avoid using 'rounded-r'. Use 'rounded-ie' instead.", 55 - ] 56 - `) 54 + [ 55 + "[RTL] Avoid using 'left-0', use 'inset-is-0' instead.", 56 + "[RTL] Avoid using 'right-0', use 'inset-ie-0' instead.", 57 + "[RTL] Avoid using 'pl-1', use 'ps-1' instead.", 58 + "[RTL] Avoid using 'ml-1', use 'ms-1' instead.", 59 + "[RTL] Avoid using 'pr-1', use 'pe-1' instead.", 60 + "[RTL] Avoid using 'mr-1', use 'me-1' instead.", 61 + "[RTL] Avoid using 'border-l', use 'border-is' instead.", 62 + "[RTL] Avoid using 'border-r', use 'border-ie' instead.", 63 + "[RTL] Avoid using 'rounded-l', use 'rounded-is' instead.", 64 + "[RTL] Avoid using 'rounded-r', use 'rounded-ie' instead.", 65 + "[RTL] Avoid using 'position-left-4', use 'inset-is-4' instead.", 66 + "[RTL] Avoid using 'sm:pl-2', use 'sm:ps-2' instead.", 67 + "[RTL] Avoid using 'text-left', use 'text-start' instead.", 68 + "[RTL] Avoid using 'text-right', use 'text-end' instead.", 69 + "[RTL] Avoid using 'hover:text-right', use 'hover:text-end' instead.", 70 + ] 71 + `) 57 72 }) 58 73 })
+70 -33
uno-preset-rtl.ts
··· 1 1 import type { CSSEntries, DynamicMatcher, Preset, RuleContext } from 'unocss' 2 2 import { cornerMap, directionSize, h } from '@unocss/preset-wind4/utils' 3 3 4 + export type CollectorChecker = (warning: string, rule: string) => void 5 + 4 6 // Track warnings to avoid duplicates 5 7 const warnedClasses = new Set<string>() 6 8 ··· 17 19 warnedClasses.clear() 18 20 } 19 21 22 + function reportWarning(match: string, suggestedClass: string, checker?: CollectorChecker) { 23 + const message = `${checker ? 'a' : 'A'}void using '${match}', use '${suggestedClass}' instead.` 24 + if (checker) { 25 + checker(message, match) 26 + } else { 27 + warnOnce(`[RTL] ${message}`, match) 28 + } 29 + } 30 + 20 31 const directionMap: Record<string, string[]> = { 21 32 'l': ['-left'], 22 33 'r': ['-right'], ··· 38 49 function directionSizeRTL( 39 50 propertyPrefix: string, 40 51 prefixMap?: { l: string; r: string }, 52 + checker?: CollectorChecker, 41 53 ): DynamicMatcher { 42 54 const matcher = directionSize(propertyPrefix) 43 - return (args, context) => { 44 - const [match, direction, size] = args 55 + return ([match, direction, size], context) => { 45 56 if (!size) return undefined 46 57 const defaultMap = { l: 'is', r: 'ie' } 47 58 const map = prefixMap || defaultMap 48 59 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 - ) 60 + 61 + const fullClass = context.rawSelector || match 62 + const prefix = match.substring(0, 1) // 'p' or 'm' 63 + const suggestedBase = match.replace(`${prefix}${direction!}`, `${prefix}${replacement}`) 64 + const suggestedClass = fullClass.replace(match, suggestedBase) 65 + 66 + reportWarning(fullClass, suggestedClass, checker) 67 + 53 68 return matcher([match, replacement, size], context) 54 69 } 55 70 } ··· 78 93 /** 79 94 * CSS RTL support to detect, replace and warn wrong left/right usages. 80 95 */ 81 - export function presetRtl(): Preset { 96 + export function presetRtl(checker?: CollectorChecker): Preset { 82 97 return { 83 98 name: 'rtl-preset', 99 + shortcuts: [ 100 + ['text-left', 'text-start x-rtl-start'], 101 + ['text-right', 'text-end x-rtl-end'], 102 + ], 84 103 rules: [ 85 104 // RTL overrides 86 105 // We need to move the dash out of the capturing group to avoid capturing it in the direction 87 106 [ 88 107 /^p([rl])-(.+)?$/, 89 - directionSizeRTL('padding', { l: 's', r: 'e' }), 108 + directionSizeRTL('padding', { l: 's', r: 'e' }, checker), 90 109 { autocomplete: '(m|p)<directions>-<num>' }, 91 110 ], 92 111 [ 93 112 /^m([rl])-(.+)?$/, 94 - directionSizeRTL('margin', { l: 's', r: 'e' }), 113 + directionSizeRTL('margin', { l: 's', r: 'e' }, checker), 95 114 { autocomplete: '(m|p)<directions>-<num>' }, 96 115 ], 97 116 [ 98 117 /^(?:position-|pos-)?(left|right)-(.+)$/, 99 - ([, direction, size], context) => { 118 + ([match, direction, size], context) => { 100 119 if (!size) return undefined 101 120 const replacement = direction === 'left' ? 'inset-is' : 'inset-ie' 102 - warnOnce( 103 - `[RTL] Avoid using '${direction}-${size}'. Use '${replacement}-${size}' instead.`, 104 - `${direction}-${size}`, 105 - ) 121 + 122 + const fullClass = context.rawSelector || match 123 + // match is 'left-4' or 'position-left-4' 124 + // replacement is 'inset-is' or 'inset-ie' 125 + // We want 'inset-is-4' 126 + const suggestedBase = `${replacement}-${size}` 127 + const suggestedClass = fullClass.replace(match, suggestedBase) 128 + 129 + reportWarning(fullClass, suggestedClass, checker) 130 + 106 131 return directionSize('inset')(['', direction === 'left' ? 'is' : 'ie', size], context) 107 132 }, 108 133 { autocomplete: '(left|right)-<num>' }, 109 134 ], 110 135 [ 111 - /^text-(left|right)$/, 112 - ([, direction]) => { 113 - const replacement = direction === 'left' ? 'start' : 'end' 114 - warnOnce( 115 - `[RTL] Avoid using 'text-${direction}'. Use 'text-${replacement}' instead.`, 116 - `text-${direction}`, 136 + /^x-rtl-(start|end)$/, 137 + ([match, direction], context) => { 138 + const originalClass = context.rawSelector || match 139 + 140 + const suggestedClass = originalClass.replace( 141 + direction === 'start' ? 'left' : 'right', 142 + direction!, 117 143 ) 118 - return { 'text-align': replacement } 144 + 145 + reportWarning(originalClass, suggestedClass, checker) 146 + 147 + // Return a cssvar with the warning message to satisfy UnoCSS 148 + // and avoid "unmatched utility" warning. 149 + return { 150 + [`--x-rtl-${direction!}`]: `"${originalClass} -> ${suggestedClass}"`, 151 + } 119 152 }, 120 153 { autocomplete: 'text-(left|right)' }, 121 154 ], 122 155 [ 123 156 /^rounded-([rl])(?:-(.+))?$/, 124 - (args, context) => { 125 - const [_, direction, size] = args 157 + ([match, direction, size], context) => { 126 158 if (!direction) return undefined 127 159 const replacementMap: Record<string, string> = { 128 160 l: 'is', ··· 130 162 } 131 163 const replacement = replacementMap[direction] 132 164 if (!replacement) return undefined 133 - warnOnce( 134 - `[RTL] Avoid using 'rounded-${direction}'. Use 'rounded-${replacement}' instead.`, 135 - `rounded-${direction}`, 136 - ) 165 + 166 + const fullClass = context.rawSelector || match 167 + const suggestedBase = match.replace(`rounded-${direction!}`, `rounded-${replacement}`) 168 + const suggestedClass = fullClass.replace(match, suggestedBase) 169 + 170 + reportWarning(fullClass, suggestedClass, checker) 171 + 137 172 return handlerRounded(['', replacement, size ?? 'DEFAULT'], context) 138 173 }, 139 174 ], 140 175 [ 141 176 /^border-([rl])(?:-(.+))?$/, 142 - args => { 143 - const [_, direction, size] = args 177 + ([match, direction, size], context) => { 144 178 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(`border-${direction!}`, `border-${replacement}`) 182 + const suggestedClass = fullClass.replace(match, suggestedBase) 183 + 184 + reportWarning(fullClass, suggestedClass, checker) 185 + 149 186 return handlerBorderSize(['', replacement, size || '1']) 150 187 }, 151 188 ],