diff --git a/test/unit/uno-preset-rtl.spec.ts b/test/unit/uno-preset-rtl.spec.ts new file mode 100644 index 0000000000..98d955c708 --- /dev/null +++ b/test/unit/uno-preset-rtl.spec.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest' +import { presetRtl } from '../../uno-preset-rtl' +import { createGenerator } from 'unocss' + +describe('uno-preset-rtl', () => { + let warnSpy: MockInstance + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + it('rtl rules replace css styles correctly', async () => { + const uno = await createGenerator({ + presets: [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', + ) + + 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;}" + `) + + 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.", + ] + `) + }) +}) diff --git a/uno-preset-rtl.ts b/uno-preset-rtl.ts new file mode 100644 index 0000000000..b20d65b634 --- /dev/null +++ b/uno-preset-rtl.ts @@ -0,0 +1,131 @@ +import type { CSSEntries, DynamicMatcher, Preset, RuleContext } from 'unocss' +import { cornerMap, directionSize, h } from '@unocss/preset-wind4/utils' + +const directionMap: Record = { + 'l': ['-left'], + 'r': ['-right'], + 't': ['-top'], + 'b': ['-bottom'], + 's': ['-inline-start'], + 'e': ['-inline-end'], + 'x': ['-left', '-right'], + 'y': ['-top', '-bottom'], + '': [''], + 'bs': ['-block-start'], + 'be': ['-block-end'], + 'is': ['-inline-start'], + 'ie': ['-inline-end'], + 'block': ['-block-start', '-block-end'], + 'inline': ['-inline-start', '-inline-end'], +} + +function directionSizeRTL( + propertyPrefix: string, + prefixMap?: { l: string; r: string }, +): DynamicMatcher { + const matcher = directionSize(propertyPrefix) + return (args, context) => { + const [match, direction, size] = args + const defaultMap = { l: 'is', r: 'ie' } + const map = prefixMap || defaultMap + const replacement = map[direction as 'l' | 'r'] + // oxlint-disable-next-line no-console -- warn logging + console.warn( + `[RTL] Avoid using '${match}'. Use '${match.replace(direction === 'l' ? 'l' : 'r', replacement)}' instead.`, + ) + return matcher([match, replacement, size], context) + } +} + +function handlerRounded( + [, a = '', s = 'DEFAULT']: string[], + { theme }: RuleContext, +): CSSEntries | undefined { + if (a in cornerMap) { + if (s === 'full') return cornerMap[a].map(i => [`border${i}-radius`, 'calc(infinity * 1px)']) + + const _v = theme.radius?.[s] ?? h.bracket.cssvar.global.fraction.rem(s) + if (_v != null) { + return cornerMap[a].map(i => [`border${i}-radius`, _v]) + } + } +} + +function handlerBorderSize([, a = '', b = '1']: string[]): CSSEntries | undefined { + const v = h.bracket.cssvar.global.px(b) + if (a in directionMap && v != null) return directionMap[a].map(i => [`border${i}-width`, v]) +} + +/** + * CSS RTL support to detect, replace and warn wrong left/right usages. + * @public + */ +export function presetRtl(): Preset { + return { + name: 'rtl-preset', + 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' }), + { autocomplete: '(m|p)-' }, + ], + [ + /^m([rl])-(.+)?$/, + directionSizeRTL('margin', { l: 's', r: 'e' }), + { autocomplete: '(m|p)-' }, + ], + [ + /^(?:position-|pos-)?(left|right)-(.+)$/, + ([, direction, size], context) => { + const replacement = direction === 'left' ? 'inset-is' : 'inset-ie' + // oxlint-disable-next-line no-console -- warn logging + console.warn( + `[RTL] Avoid using '${direction}-${size}'. Use '${replacement}-${size}' instead.`, + ) + return directionSize('inset')(['', direction === 'left' ? 'is' : 'ie', size], context) + }, + { autocomplete: '(left|right)-' }, + ], + [ + /^text-(left|right)$/, + ([, direction]) => { + const replacement = direction === 'left' ? 'start' : 'end' + // oxlint-disable-next-line no-console -- warn logging + console.warn(`[RTL] Avoid using 'text-${direction}'. Use 'text-${replacement}' instead.`) + return { 'text-align': replacement } + }, + { autocomplete: 'text-(left|right)' }, + ], + [ + /^rounded-([rl])(?:-(.+))?$/, + (args, context) => { + const [_, direction, size] = args + const replacementMap: Record = { + l: 'is', + r: 'ie', + } + const replacement = replacementMap[direction] + // oxlint-disable-next-line no-console -- warn logging + console.warn( + `[RTL] Avoid using 'rounded-${direction}'. Use 'rounded-${replacement}' instead.`, + ) + return handlerRounded(['', replacement, size], context) + }, + ], + [ + /^border-([rl])(?:-(.+))?$/, + args => { + const [_, direction, size] = args + const replacement = direction === 'l' ? 'is' : 'ie' + // oxlint-disable-next-line no-console -- warn logging + console.warn( + `[RTL] Avoid using 'border-${direction}'. Use 'border-${replacement}' instead.`, + ) + return handlerBorderSize(['', replacement, size || '1']) + }, + ], + ], + } +} diff --git a/uno.config.ts b/uno.config.ts index 036dceb783..d87a7898a0 100644 --- a/uno.config.ts +++ b/uno.config.ts @@ -6,6 +6,7 @@ import { transformerVariantGroup, } from 'unocss' import type { Theme } from '@unocss/preset-wind4/theme' +import { presetRtl } from './uno-preset-rtl' const customIcons = { tangled: @@ -23,7 +24,9 @@ export default defineConfig({ custom: customIcons, }, }), - ], + // keep this preset last + process.env.CI ? undefined : presetRtl(), + ].filter(Boolean), transformers: [transformerDirectives(), transformerVariantGroup()], theme: { font: {