Skip to content

Commit ebe4002

Browse files
authored
chore: add CSS RTL detector (#539)
1 parent 95749a4 commit ebe4002

3 files changed

Lines changed: 192 additions & 1 deletion

File tree

test/unit/uno-preset-rtl.spec.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
2+
import { presetRtl } from '../../uno-preset-rtl'
3+
import { createGenerator } from 'unocss'
4+
5+
describe('uno-preset-rtl', () => {
6+
let warnSpy: MockInstance
7+
8+
beforeEach(() => {
9+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
10+
})
11+
12+
afterEach(() => {
13+
warnSpy.mockRestore()
14+
})
15+
16+
it('rtl rules replace css styles correctly', async () => {
17+
const uno = await createGenerator({
18+
presets: [presetRtl()],
19+
})
20+
21+
const { css } = await uno.generate(
22+
'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+
)
24+
25+
expect(css).toMatchInlineSnapshot(`
26+
"/* layer: default */
27+
.pl-1{padding-inline-start:calc(var(--spacing) * 1);}
28+
.pr-1{padding-inline-end:calc(var(--spacing) * 1);}
29+
.ml-1{margin-inline-start:calc(var(--spacing) * 1);}
30+
.mr-1{margin-inline-end:calc(var(--spacing) * 1);}
31+
.left-0{inset-inline-start:calc(var(--spacing) * 0);}
32+
.right-0{inset-inline-end:calc(var(--spacing) * 0);}
33+
.text-left{text-align:start;}
34+
.text-right{text-align:end;}
35+
.border-l{border-inline-start-width:1px;}
36+
.border-r{border-inline-end-width:1px;}"
37+
`)
38+
39+
const warnings = warnSpy.mock.calls.flat()
40+
expect(warnings).toMatchInlineSnapshot(`
41+
[
42+
"[RTL] Avoid using 'left-0'. Use 'inset-is-0' instead.",
43+
"[RTL] Avoid using 'right-0'. Use 'inset-ie-0' instead.",
44+
"[RTL] Avoid using 'pl-1'. Use 'ps-1' instead.",
45+
"[RTL] Avoid using 'ml-1'. Use 'ms-1' instead.",
46+
"[RTL] Avoid using 'pr-1'. Use 'pe-1' instead.",
47+
"[RTL] Avoid using 'mr-1'. Use 'me-1' instead.",
48+
"[RTL] Avoid using 'text-left'. Use 'text-start' instead.",
49+
"[RTL] Avoid using 'text-right'. Use 'text-end' instead.",
50+
"[RTL] Avoid using 'border-l'. Use 'border-is' instead.",
51+
"[RTL] Avoid using 'border-r'. Use 'border-ie' instead.",
52+
"[RTL] Avoid using 'rounded-l'. Use 'rounded-is' instead.",
53+
"[RTL] Avoid using 'rounded-r'. Use 'rounded-ie' instead.",
54+
]
55+
`)
56+
})
57+
})

uno-preset-rtl.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import type { CSSEntries, DynamicMatcher, Preset, RuleContext } from 'unocss'
2+
import { cornerMap, directionSize, h } from '@unocss/preset-wind4/utils'
3+
4+
const directionMap: Record<string, string[]> = {
5+
'l': ['-left'],
6+
'r': ['-right'],
7+
't': ['-top'],
8+
'b': ['-bottom'],
9+
's': ['-inline-start'],
10+
'e': ['-inline-end'],
11+
'x': ['-left', '-right'],
12+
'y': ['-top', '-bottom'],
13+
'': [''],
14+
'bs': ['-block-start'],
15+
'be': ['-block-end'],
16+
'is': ['-inline-start'],
17+
'ie': ['-inline-end'],
18+
'block': ['-block-start', '-block-end'],
19+
'inline': ['-inline-start', '-inline-end'],
20+
}
21+
22+
function directionSizeRTL(
23+
propertyPrefix: string,
24+
prefixMap?: { l: string; r: string },
25+
): DynamicMatcher {
26+
const matcher = directionSize(propertyPrefix)
27+
return (args, context) => {
28+
const [match, direction, size] = args
29+
const defaultMap = { l: 'is', r: 'ie' }
30+
const map = prefixMap || defaultMap
31+
const replacement = map[direction as 'l' | 'r']
32+
// oxlint-disable-next-line no-console -- warn logging
33+
console.warn(
34+
`[RTL] Avoid using '${match}'. Use '${match.replace(direction === 'l' ? 'l' : 'r', replacement)}' instead.`,
35+
)
36+
return matcher([match, replacement, size], context)
37+
}
38+
}
39+
40+
function handlerRounded(
41+
[, a = '', s = 'DEFAULT']: string[],
42+
{ theme }: RuleContext<any>,
43+
): CSSEntries | undefined {
44+
if (a in cornerMap) {
45+
if (s === 'full') return cornerMap[a].map(i => [`border${i}-radius`, 'calc(infinity * 1px)'])
46+
47+
const _v = theme.radius?.[s] ?? h.bracket.cssvar.global.fraction.rem(s)
48+
if (_v != null) {
49+
return cornerMap[a].map(i => [`border${i}-radius`, _v])
50+
}
51+
}
52+
}
53+
54+
function handlerBorderSize([, a = '', b = '1']: string[]): CSSEntries | undefined {
55+
const v = h.bracket.cssvar.global.px(b)
56+
if (a in directionMap && v != null) return directionMap[a].map(i => [`border${i}-width`, v])
57+
}
58+
59+
/**
60+
* CSS RTL support to detect, replace and warn wrong left/right usages.
61+
* @public
62+
*/
63+
export function presetRtl(): Preset {
64+
return {
65+
name: 'rtl-preset',
66+
rules: [
67+
// RTL overrides
68+
// We need to move the dash out of the capturing group to avoid capturing it in the direction
69+
[
70+
/^p([rl])-(.+)?$/,
71+
directionSizeRTL('padding', { l: 's', r: 'e' }),
72+
{ autocomplete: '(m|p)<directions>-<num>' },
73+
],
74+
[
75+
/^m([rl])-(.+)?$/,
76+
directionSizeRTL('margin', { l: 's', r: 'e' }),
77+
{ autocomplete: '(m|p)<directions>-<num>' },
78+
],
79+
[
80+
/^(?:position-|pos-)?(left|right)-(.+)$/,
81+
([, direction, size], context) => {
82+
const replacement = direction === 'left' ? 'inset-is' : 'inset-ie'
83+
// oxlint-disable-next-line no-console -- warn logging
84+
console.warn(
85+
`[RTL] Avoid using '${direction}-${size}'. Use '${replacement}-${size}' instead.`,
86+
)
87+
return directionSize('inset')(['', direction === 'left' ? 'is' : 'ie', size], context)
88+
},
89+
{ autocomplete: '(left|right)-<num>' },
90+
],
91+
[
92+
/^text-(left|right)$/,
93+
([, direction]) => {
94+
const replacement = direction === 'left' ? 'start' : 'end'
95+
// oxlint-disable-next-line no-console -- warn logging
96+
console.warn(`[RTL] Avoid using 'text-${direction}'. Use 'text-${replacement}' instead.`)
97+
return { 'text-align': replacement }
98+
},
99+
{ autocomplete: 'text-(left|right)' },
100+
],
101+
[
102+
/^rounded-([rl])(?:-(.+))?$/,
103+
(args, context) => {
104+
const [_, direction, size] = args
105+
const replacementMap: Record<string, string> = {
106+
l: 'is',
107+
r: 'ie',
108+
}
109+
const replacement = replacementMap[direction]
110+
// oxlint-disable-next-line no-console -- warn logging
111+
console.warn(
112+
`[RTL] Avoid using 'rounded-${direction}'. Use 'rounded-${replacement}' instead.`,
113+
)
114+
return handlerRounded(['', replacement, size], context)
115+
},
116+
],
117+
[
118+
/^border-([rl])(?:-(.+))?$/,
119+
args => {
120+
const [_, direction, size] = args
121+
const replacement = direction === 'l' ? 'is' : 'ie'
122+
// oxlint-disable-next-line no-console -- warn logging
123+
console.warn(
124+
`[RTL] Avoid using 'border-${direction}'. Use 'border-${replacement}' instead.`,
125+
)
126+
return handlerBorderSize(['', replacement, size || '1'])
127+
},
128+
],
129+
],
130+
}
131+
}

uno.config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
transformerVariantGroup,
77
} from 'unocss'
88
import type { Theme } from '@unocss/preset-wind4/theme'
9+
import { presetRtl } from './uno-preset-rtl'
910

1011
const customIcons = {
1112
tangled:
@@ -23,7 +24,9 @@ export default defineConfig({
2324
custom: customIcons,
2425
},
2526
}),
26-
],
27+
// keep this preset last
28+
process.env.CI ? undefined : presetRtl(),
29+
].filter(Boolean),
2730
transformers: [transformerDirectives(), transformerVariantGroup()],
2831
theme: {
2932
font: {

0 commit comments

Comments
 (0)