forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import type { CSSEntries, DynamicMatcher, Preset, RuleContext } from 'unocss'
2import { cornerMap, directionSize, h } from '@unocss/preset-wind4/utils'
3
4type CollectorChecker = (warning: string, rule: string) => void
5
6// Track warnings to avoid duplicates
7const warnedClasses = new Set<string>()
8
9function warnOnce(message: string, key: string) {
10 if (!warnedClasses.has(key)) {
11 warnedClasses.add(key)
12 // oxlint-disable-next-line no-console -- warn logging
13 console.warn(message)
14 }
15}
16
17/** Reset warning state (for testing) */
18export function resetRtlWarnings() {
19 warnedClasses.clear()
20}
21
22function reportWarning(match: string, suggestedClass: string, checker?: CollectorChecker) {
23 const message = `${checker ? 'a' : 'A'}void using '${match}', use '${suggestedClass}' instead, or 'force-${match}' to keep physical direction.`
24 if (checker) {
25 checker(message, match)
26 } else {
27 warnOnce(`[RTL] ${message}`, match)
28 }
29}
30
31const directionMap: Record<string, string[]> = {
32 'l': ['-left'],
33 'r': ['-right'],
34 't': ['-top'],
35 'b': ['-bottom'],
36 's': ['-inline-start'],
37 'e': ['-inline-end'],
38 'x': ['-left', '-right'],
39 'y': ['-top', '-bottom'],
40 '': [''],
41 'bs': ['-block-start'],
42 'be': ['-block-end'],
43 'is': ['-inline-start'],
44 'ie': ['-inline-end'],
45 'block': ['-block-start', '-block-end'],
46 'inline': ['-inline-start', '-inline-end'],
47}
48
49function directionSizeRTL(
50 propertyPrefix: string,
51 prefixMap?: { l: string; r: string },
52 checker?: CollectorChecker,
53): DynamicMatcher {
54 const matcher = directionSize(propertyPrefix)
55 return ([match, direction, size], context) => {
56 if (!size) return undefined
57 const defaultMap = { l: 'is', r: 'ie' }
58 const map = prefixMap || defaultMap
59 const replacement = map[direction as 'l' | 'r']
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
68 return matcher([match, replacement, size], context)
69 }
70}
71
72function handlerRounded(
73 [, a = '', s = 'DEFAULT']: string[],
74 { theme }: RuleContext<any>,
75): CSSEntries | undefined {
76 const corners = cornerMap[a]
77 if (!corners) return undefined
78
79 if (s === 'full') return corners.map(i => [`border${i}-radius`, 'calc(infinity * 1px)'])
80
81 const _v = theme.radius?.[s] ?? h.bracket?.cssvar?.global?.fraction?.rem?.(s)
82 if (_v != null) {
83 return corners.map(i => [`border${i}-radius`, _v])
84 }
85}
86
87function handlerBorderSize([, a = '', b = '1']: string[]): CSSEntries | undefined {
88 const v = h.bracket?.cssvar?.global?.px?.(b)
89 const directions = directionMap[a]
90 if (directions && v != null) return directions.map(i => [`border${i}-width`, v])
91}
92
93function handlerForceDirectionSize(
94 propertyPrefix: string,
95 [, direction, size]: string[],
96 { theme }: RuleContext<any>,
97): CSSEntries | undefined {
98 const v =
99 theme.spacing?.[size || 'DEFAULT'] ?? h.bracket?.cssvar?.global?.auto?.fraction?.rem?.(size!)
100 const directions = directionMap[direction!]
101
102 if (v != null && directions) {
103 return directions.map(i => [`${propertyPrefix}${i}`, v])
104 }
105}
106
107/**
108 * CSS RTL support to detect, replace and warn wrong left/right usages.
109 */
110export function presetRtl(checker?: CollectorChecker): Preset {
111 return {
112 name: 'rtl-preset',
113 shortcuts: [
114 ['text-left', 'text-start x-rtl-start'],
115 ['text-right', 'text-end x-rtl-end'],
116 ],
117 rules: [
118 // Force physical directions (bypass RTL logic)
119 [
120 /^force-p([rl])-(.+)?$/,
121 (match, context) => handlerForceDirectionSize('padding', match, context),
122 { autocomplete: 'force-p(l|r)-<num>' },
123 ],
124 [
125 /^force-m([rl])-(.+)?$/,
126 (match, context) => handlerForceDirectionSize('margin', match, context),
127 { autocomplete: 'force-m(l|r)-<num>' },
128 ],
129 [
130 /^force-(?:position-|pos-)?(left|right)-(.+)$/,
131 ([_, direction, size], context) => {
132 // Map 'left'/'right' to 'l'/'r' for directionMap lookup if needed,
133 // but directionMap has 'left'/'right' keys? No, it has 'l'/'r'.
134 // Wait, directionMap keys are 'l', 'r'.
135 // But inset usually uses 'left', 'right' properties directly.
136 // Let's use a custom handler for inset to be safe.
137 const v =
138 (context.theme as unknown as any).spacing?.[size || 'DEFAULT'] ??
139 h.bracket?.cssvar?.global?.auto?.fraction?.rem?.(size!)
140 if (v != null) {
141 return [[direction === 'left' ? 'left' : 'right', v]]
142 }
143 },
144 { autocomplete: 'force-(left|right)-<num>' },
145 ],
146 [
147 /^force-text-(left|right)$/,
148 ([, direction]) => ({ 'text-align': direction }),
149 { autocomplete: 'force-text-(left|right)' },
150 ],
151 [
152 /^force-rounded-([rl])(?:-(.+))?$/,
153 ([, direction, size], context) =>
154 handlerRounded(['', direction!, size ?? 'DEFAULT'], context),
155 { autocomplete: 'force-rounded-(l|r)-<num>' },
156 ],
157 [
158 /^force-border-([rl])(?:-(.+))?$/,
159 ([, direction, size]) => handlerBorderSize(['', direction!, size || '1']),
160 { autocomplete: 'force-border-(l|r)-<num>' },
161 ],
162
163 // RTL overrides
164 // We need to move the dash out of the capturing group to avoid capturing it in the direction
165 [
166 /^p([rl])-(.+)?$/,
167 directionSizeRTL('padding', { l: 's', r: 'e' }, checker),
168 { autocomplete: '(m|p)<directions>-<num>' },
169 ],
170 [
171 /^m([rl])-(.+)?$/,
172 directionSizeRTL('margin', { l: 's', r: 'e' }, checker),
173 { autocomplete: '(m|p)<directions>-<num>' },
174 ],
175 [
176 /^(?:position-|pos-)?(left|right)-(.+)$/,
177 ([match, direction, size], context) => {
178 if (!size) return undefined
179 const replacement = direction === 'left' ? 'inset-is' : 'inset-ie'
180
181 const fullClass = context.rawSelector || match
182 // match is 'left-4' or 'position-left-4'
183 // replacement is 'inset-is' or 'inset-ie'
184 // We want 'inset-is-4'
185 const suggestedBase = `${replacement}-${size}`
186 const suggestedClass = fullClass.replace(match, suggestedBase)
187
188 reportWarning(fullClass, suggestedClass, checker)
189
190 return directionSize('inset')(['', direction === 'left' ? 'is' : 'ie', size], context)
191 },
192 { autocomplete: '(left|right)-<num>' },
193 ],
194 [
195 /^x-rtl-(start|end)$/,
196 ([match, direction], context) => {
197 const originalClass = context.rawSelector || match
198
199 const suggestedClass = originalClass.replace(
200 direction === 'start' ? 'left' : 'right',
201 direction!,
202 )
203
204 reportWarning(originalClass, suggestedClass, checker)
205
206 // Return a cssvar with the warning message to satisfy UnoCSS
207 // and avoid "unmatched utility" warning.
208 return {
209 [`--x-rtl-${direction!}`]: `"${originalClass} -> ${suggestedClass}"`,
210 }
211 },
212 { autocomplete: 'text-(left|right)' },
213 ],
214 [
215 /^rounded-([rl])(?:-(.+))?$/,
216 ([match, direction, size], context) => {
217 if (!direction) return undefined
218 const replacementMap: Record<string, string> = {
219 l: 'is',
220 r: 'ie',
221 }
222 const replacement = replacementMap[direction]
223 if (!replacement) return undefined
224
225 const fullClass = context.rawSelector || match
226 const suggestedBase = match.replace(`rounded-${direction!}`, `rounded-${replacement}`)
227 const suggestedClass = fullClass.replace(match, suggestedBase)
228
229 reportWarning(fullClass, suggestedClass, checker)
230
231 return handlerRounded(['', replacement, size ?? 'DEFAULT'], context)
232 },
233 ],
234 [
235 /^border-([rl])(?:-(.+))?$/,
236 ([match, direction, size], context) => {
237 const replacement = direction === 'l' ? 'is' : 'ie'
238
239 const fullClass = context.rawSelector || match
240 const suggestedBase = match.replace(`border-${direction!}`, `border-${replacement}`)
241 const suggestedClass = fullClass.replace(match, suggestedBase)
242
243 reportWarning(fullClass, suggestedClass, checker)
244
245 return handlerBorderSize(['', replacement, size || '1'])
246 },
247 ],
248 ],
249 }
250}