@pyreon/styler 0.11.1 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -5
- package/src/ThemeProvider.ts +37 -0
- package/src/__tests__/ThemeProvider.test.ts +67 -0
- package/src/__tests__/benchmark.bench.ts +189 -0
- package/src/__tests__/composition-chain.test.ts +489 -0
- package/src/__tests__/css.test.ts +70 -0
- package/src/__tests__/forward.test.ts +282 -0
- package/src/__tests__/globalStyle.test.ts +72 -0
- package/src/__tests__/hash.test.ts +70 -0
- package/src/__tests__/hybrid-injection.test.ts +205 -0
- package/src/__tests__/index.ts +14 -0
- package/src/__tests__/insertion-effect.test.ts +106 -0
- package/src/__tests__/integration.test.ts +149 -0
- package/src/__tests__/keyframes.test.ts +68 -0
- package/src/__tests__/memory-growth.test.ts +152 -0
- package/src/__tests__/p3-features.test.ts +258 -0
- package/src/__tests__/resolve.test.ts +249 -0
- package/src/__tests__/shared.test.ts +73 -0
- package/src/__tests__/sheet-advanced.test.ts +669 -0
- package/src/__tests__/sheet-split-atrules.test.ts +411 -0
- package/src/__tests__/sheet.test.ts +164 -0
- package/src/__tests__/styled-ssr.test.ts +67 -0
- package/src/__tests__/styled.test.ts +303 -0
- package/src/__tests__/theme.test.ts +33 -0
- package/src/__tests__/useCSS.test.ts +142 -0
- package/src/css.ts +13 -0
- package/src/forward.ts +276 -0
- package/src/globalStyle.ts +48 -0
- package/src/hash.ts +30 -0
- package/src/index.ts +15 -0
- package/src/keyframes.ts +36 -0
- package/src/resolve.ts +172 -0
- package/src/shared.ts +12 -0
- package/src/sheet.ts +387 -0
- package/src/styled.tsx +277 -0
- package/src/useCSS.ts +20 -0
package/src/sheet.ts
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StyleSheet manager. Handles CSS rule injection, hash-based deduplication,
|
|
3
|
+
* SSR buffering, client-side hydration, bounded cache, and @layer support.
|
|
4
|
+
*
|
|
5
|
+
* Media queries (@media), @supports, and @container blocks nested inside
|
|
6
|
+
* component CSS are automatically extracted into separate top-level rules.
|
|
7
|
+
*/
|
|
8
|
+
import { hash } from "./hash"
|
|
9
|
+
import { clearNormCache } from "./resolve"
|
|
10
|
+
|
|
11
|
+
const PREFIX = "pyr"
|
|
12
|
+
const ATTR = "data-pyreon-styler"
|
|
13
|
+
const DEFAULT_MAX_CACHE_SIZE = 10000
|
|
14
|
+
|
|
15
|
+
export interface StyleSheetOptions {
|
|
16
|
+
/** Maximum number of cached rules before eviction (default: 10000). */
|
|
17
|
+
maxCacheSize?: number
|
|
18
|
+
/** CSS @layer name to wrap scoped rules in. */
|
|
19
|
+
layer?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class StyleSheet {
|
|
23
|
+
private cache = new Map<string, string>()
|
|
24
|
+
private insertCache = new Map<string, string>()
|
|
25
|
+
private sheet: CSSStyleSheet | null = null
|
|
26
|
+
private ssrBuffer: string[] = []
|
|
27
|
+
private isSSR: boolean
|
|
28
|
+
private maxCacheSize: number
|
|
29
|
+
private layer: string | undefined
|
|
30
|
+
|
|
31
|
+
constructor(options: StyleSheetOptions = {}) {
|
|
32
|
+
this.maxCacheSize = options.maxCacheSize ?? DEFAULT_MAX_CACHE_SIZE
|
|
33
|
+
this.layer = options.layer
|
|
34
|
+
this.isSSR = typeof document === "undefined"
|
|
35
|
+
if (!this.isSSR) this.mount()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private mount() {
|
|
39
|
+
// Reuse existing <style> tag from SSR hydration
|
|
40
|
+
const existing = document.querySelector(`style[${ATTR}]`) as HTMLStyleElement | null
|
|
41
|
+
|
|
42
|
+
if (existing) {
|
|
43
|
+
this.sheet = existing.sheet ?? null
|
|
44
|
+
this.hydrateFromTag(existing)
|
|
45
|
+
} else {
|
|
46
|
+
const el = document.createElement("style")
|
|
47
|
+
el.setAttribute(ATTR, "")
|
|
48
|
+
document.head.appendChild(el)
|
|
49
|
+
this.sheet = el.sheet ?? null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Inject @layer declaration if configured
|
|
53
|
+
if (this.layer && this.sheet) {
|
|
54
|
+
try {
|
|
55
|
+
this.sheet.insertRule(`@layer ${this.layer};`, 0)
|
|
56
|
+
} catch {
|
|
57
|
+
// skip if @layer not supported
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Extract className from a selector like ".pyr-abc" or ".pyr-abc.pyr-abc" → "pyr-abc" */
|
|
63
|
+
private extractClassName(selectorText: string): string | null {
|
|
64
|
+
if (selectorText[0] !== ".") return null
|
|
65
|
+
const dotIdx = selectorText.indexOf(".", 1)
|
|
66
|
+
return dotIdx > 0 ? selectorText.slice(1, dotIdx) : selectorText.slice(1)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Parse existing rules from SSR-rendered <style> tag into cache. */
|
|
70
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex logic is inherent to this function
|
|
71
|
+
private hydrateFromTag(el: HTMLStyleElement) {
|
|
72
|
+
const sheet = el.sheet
|
|
73
|
+
if (!sheet) return
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < sheet.cssRules.length; i++) {
|
|
76
|
+
const rule = sheet.cssRules[i]
|
|
77
|
+
|
|
78
|
+
if (rule instanceof CSSStyleRule) {
|
|
79
|
+
const className = this.extractClassName(rule.selectorText)
|
|
80
|
+
if (className) this.cache.set(className, className)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Handle split @media rules that wrap our selectors
|
|
84
|
+
if (typeof CSSMediaRule !== "undefined" && rule instanceof CSSMediaRule) {
|
|
85
|
+
for (let j = 0; j < rule.cssRules.length; j++) {
|
|
86
|
+
const inner = rule.cssRules[j]
|
|
87
|
+
if (inner instanceof CSSStyleRule) {
|
|
88
|
+
const className = this.extractClassName(inner.selectorText)
|
|
89
|
+
if (className) this.cache.set(className, className)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Evict oldest entries when cache exceeds max size. */
|
|
97
|
+
private evictIfNeeded() {
|
|
98
|
+
if (this.cache.size <= this.maxCacheSize) return
|
|
99
|
+
|
|
100
|
+
// Map iteration order is insertion order — delete oldest 10%
|
|
101
|
+
const toDelete = Math.floor(this.maxCacheSize * 0.1)
|
|
102
|
+
let count = 0
|
|
103
|
+
for (const key of this.cache.keys()) {
|
|
104
|
+
if (count >= toDelete) break
|
|
105
|
+
this.cache.delete(key)
|
|
106
|
+
count++
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Extract nested at-rules (@media, @supports, @container) from CSS text
|
|
112
|
+
* and wrap their content in the given selector as separate top-level rules.
|
|
113
|
+
*/
|
|
114
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex logic is inherent to this function
|
|
115
|
+
private splitAtRules(cssText: string, selector: string): { base: string; atRules: string[] } {
|
|
116
|
+
// Fast path: no at-rules to split
|
|
117
|
+
if (cssText.indexOf("@") === -1) return { base: cssText, atRules: [] }
|
|
118
|
+
|
|
119
|
+
const atRules: string[] = []
|
|
120
|
+
const baseParts: string[] = []
|
|
121
|
+
let depth = 0
|
|
122
|
+
let atStart = -1
|
|
123
|
+
let lastBase = 0
|
|
124
|
+
|
|
125
|
+
for (let i = 0; i < cssText.length; i++) {
|
|
126
|
+
const ch = cssText[i]
|
|
127
|
+
|
|
128
|
+
if (ch === "{") {
|
|
129
|
+
depth++
|
|
130
|
+
} else if (ch === "}") {
|
|
131
|
+
depth--
|
|
132
|
+
if (depth === 0 && atStart >= 0) {
|
|
133
|
+
// End of a tracked at-rule block — extract and wrap with selector
|
|
134
|
+
const openBrace = cssText.indexOf("{", atStart)
|
|
135
|
+
const atPrefix = cssText.slice(atStart, openBrace).trim()
|
|
136
|
+
const innerCSS = cssText.slice(openBrace + 1, i).trim()
|
|
137
|
+
if (innerCSS) {
|
|
138
|
+
atRules.push(`${atPrefix}{${selector}{${innerCSS}}}`)
|
|
139
|
+
}
|
|
140
|
+
atStart = -1
|
|
141
|
+
lastBase = i + 1
|
|
142
|
+
}
|
|
143
|
+
} else if (depth === 0 && ch === "@" && atStart < 0) {
|
|
144
|
+
// Check if this starts a splittable at-rule (not @keyframes, @font-face, etc.)
|
|
145
|
+
const remaining = cssText.slice(i, i + 20)
|
|
146
|
+
if (/^@(?:media|supports|container)\b/.test(remaining)) {
|
|
147
|
+
// Save any base CSS that precedes this at-rule
|
|
148
|
+
const baseBefore = cssText.slice(lastBase, i).trim()
|
|
149
|
+
if (baseBefore) baseParts.push(baseBefore)
|
|
150
|
+
atStart = i
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Collect remaining base CSS after the last at-rule
|
|
156
|
+
if (lastBase < cssText.length && atStart < 0) {
|
|
157
|
+
const remaining = cssText.slice(lastBase).trim()
|
|
158
|
+
if (remaining) baseParts.push(remaining)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// If no at-rules were found, return original unchanged
|
|
162
|
+
if (atRules.length === 0) return { base: cssText, atRules: [] }
|
|
163
|
+
|
|
164
|
+
return { base: baseParts.join(" "), atRules }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Compute a className from CSS text without injecting (pure function).
|
|
169
|
+
*/
|
|
170
|
+
getClassName(cssText: string): string {
|
|
171
|
+
const cached = this.insertCache.get(cssText)
|
|
172
|
+
if (cached) return cached
|
|
173
|
+
const h = hash(cssText)
|
|
174
|
+
return `${PREFIX}-${h}`
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Insert CSS rules for a component. Returns the class name (deterministic, hash-based).
|
|
179
|
+
* Deduplicates: same CSS text always produces the same class name and
|
|
180
|
+
* the rules are only injected once.
|
|
181
|
+
*
|
|
182
|
+
* When `boost` is true, the selector is doubled (`.pyr-abc.pyr-abc`)
|
|
183
|
+
* to raise specificity from (0,1,0) to (0,2,0).
|
|
184
|
+
*/
|
|
185
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex logic is inherent to this function
|
|
186
|
+
insert(cssText: string, boost = false): string {
|
|
187
|
+
// Fast path: skip hash computation on repeated insertions of same CSS text
|
|
188
|
+
const icKey = boost ? `${cssText}\0` : cssText
|
|
189
|
+
const icHit = this.insertCache.get(icKey)
|
|
190
|
+
if (icHit) return icHit
|
|
191
|
+
|
|
192
|
+
const h = hash(cssText)
|
|
193
|
+
const className = `${PREFIX}-${h}`
|
|
194
|
+
|
|
195
|
+
if (this.cache.has(className)) {
|
|
196
|
+
this.insertCache.set(icKey, className)
|
|
197
|
+
return className
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
this.evictIfNeeded()
|
|
201
|
+
this.cache.set(className, className)
|
|
202
|
+
|
|
203
|
+
const selector = boost ? `.${className}.${className}` : `.${className}`
|
|
204
|
+
|
|
205
|
+
// Split nested at-rules into separate top-level rules
|
|
206
|
+
const { base, atRules } = this.splitAtRules(cssText, selector)
|
|
207
|
+
|
|
208
|
+
const rules: string[] = []
|
|
209
|
+
if (base) rules.push(`${selector}{${base}}`)
|
|
210
|
+
rules.push(...atRules)
|
|
211
|
+
|
|
212
|
+
// Apply @layer wrapping if configured
|
|
213
|
+
const finalRules = this.layer ? rules.map((r) => `@layer ${this.layer}{${r}}`) : rules
|
|
214
|
+
|
|
215
|
+
if (this.isSSR) {
|
|
216
|
+
for (const rule of finalRules) {
|
|
217
|
+
this.ssrBuffer.push(rule)
|
|
218
|
+
}
|
|
219
|
+
} else if (this.sheet) {
|
|
220
|
+
for (const rule of finalRules) {
|
|
221
|
+
try {
|
|
222
|
+
this.sheet.insertRule(rule, this.sheet.cssRules.length)
|
|
223
|
+
} catch (_e) {
|
|
224
|
+
if (process.env.NODE_ENV !== "production") {
|
|
225
|
+
// biome-ignore lint/suspicious/noConsole: dev-only CSS rule insertion warning
|
|
226
|
+
console.warn("[styler] Failed to insert CSS rule:", rule, _e)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
this.insertCache.set(icKey, className)
|
|
233
|
+
return className
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Insert a @keyframes rule. Deduplicates by animation name. */
|
|
237
|
+
insertKeyframes(name: string, body: string): void {
|
|
238
|
+
if (this.cache.has(name)) return
|
|
239
|
+
|
|
240
|
+
this.evictIfNeeded()
|
|
241
|
+
this.cache.set(name, name)
|
|
242
|
+
|
|
243
|
+
const rule = `@keyframes ${name}{${body}}`
|
|
244
|
+
|
|
245
|
+
if (this.isSSR) {
|
|
246
|
+
this.ssrBuffer.push(rule)
|
|
247
|
+
} else if (this.sheet) {
|
|
248
|
+
try {
|
|
249
|
+
this.sheet.insertRule(rule, this.sheet.cssRules.length)
|
|
250
|
+
} catch (_e) {
|
|
251
|
+
if (process.env.NODE_ENV !== "production") {
|
|
252
|
+
// silently ignore invalid CSS rules in production
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Split CSS text into individual top-level rules.
|
|
260
|
+
* CSSStyleSheet.insertRule() only accepts one rule at a time.
|
|
261
|
+
*/
|
|
262
|
+
private splitRules(cssText: string): string[] {
|
|
263
|
+
const rules: string[] = []
|
|
264
|
+
let depth = 0
|
|
265
|
+
let start = 0
|
|
266
|
+
|
|
267
|
+
for (let i = 0; i < cssText.length; i++) {
|
|
268
|
+
const ch = cssText[i]
|
|
269
|
+
if (ch === "{") depth++
|
|
270
|
+
else if (ch === "}") {
|
|
271
|
+
depth--
|
|
272
|
+
if (depth === 0) {
|
|
273
|
+
const rule = cssText.slice(start, i + 1).trim()
|
|
274
|
+
if (rule) rules.push(rule)
|
|
275
|
+
start = i + 1
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return rules
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Insert global CSS rules (no wrapper selector). Deduplicates by hash. */
|
|
284
|
+
insertGlobal(cssText: string): void {
|
|
285
|
+
const h = hash(cssText)
|
|
286
|
+
const key = `global-${h}`
|
|
287
|
+
|
|
288
|
+
if (this.cache.has(key)) return
|
|
289
|
+
|
|
290
|
+
this.evictIfNeeded()
|
|
291
|
+
this.cache.set(key, key)
|
|
292
|
+
|
|
293
|
+
if (this.isSSR) {
|
|
294
|
+
this.ssrBuffer.push(cssText)
|
|
295
|
+
} else if (this.sheet) {
|
|
296
|
+
const rules = this.splitRules(cssText)
|
|
297
|
+
for (const rule of rules) {
|
|
298
|
+
try {
|
|
299
|
+
this.sheet.insertRule(rule, this.sheet.cssRules.length)
|
|
300
|
+
} catch (_e) {
|
|
301
|
+
if (process.env.NODE_ENV !== "production") {
|
|
302
|
+
// biome-ignore lint/suspicious/noConsole: dev-only CSS rule insertion warning
|
|
303
|
+
console.warn("[styler] Failed to insert global CSS rule:", rule, _e)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Returns collected CSS for SSR as a complete `<style>` tag string. */
|
|
311
|
+
getStyleTag(): string {
|
|
312
|
+
const css = this.ssrBuffer.join("").replace(/<\/style/gi, "<\\/style")
|
|
313
|
+
return `<style ${ATTR}="">${css}</style>`
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Returns collected CSS rules as a raw string (useful for streaming SSR). */
|
|
317
|
+
getStyles(): string {
|
|
318
|
+
return this.ssrBuffer.join("")
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Reset SSR buffer and cache (call between server requests). */
|
|
322
|
+
reset(): void {
|
|
323
|
+
this.ssrBuffer = []
|
|
324
|
+
this.cache.clear()
|
|
325
|
+
this.insertCache.clear()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Clear the dedup cache. Useful for HMR / dev-time reloads. */
|
|
329
|
+
clearCache(): void {
|
|
330
|
+
this.cache.clear()
|
|
331
|
+
this.insertCache.clear()
|
|
332
|
+
clearNormCache()
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Full cleanup: clear cache and remove all CSS rules from the DOM.
|
|
337
|
+
* Intended for HMR / dev-time reloads where stale styles must be purged.
|
|
338
|
+
*/
|
|
339
|
+
clearAll(): void {
|
|
340
|
+
this.cache.clear()
|
|
341
|
+
this.insertCache.clear()
|
|
342
|
+
clearNormCache()
|
|
343
|
+
this.ssrBuffer = []
|
|
344
|
+
if (this.sheet) {
|
|
345
|
+
while (this.sheet.cssRules.length > 0) {
|
|
346
|
+
this.sheet.deleteRule(0)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Compute className and full CSS rule text without injecting.
|
|
353
|
+
*/
|
|
354
|
+
prepare(cssText: string, boost = false): { className: string; rules: string } {
|
|
355
|
+
const h = hash(cssText)
|
|
356
|
+
const className = `${PREFIX}-${h}`
|
|
357
|
+
const selector = boost ? `.${className}.${className}` : `.${className}`
|
|
358
|
+
const { base, atRules } = this.splitAtRules(cssText, selector)
|
|
359
|
+
|
|
360
|
+
const allRules: string[] = []
|
|
361
|
+
if (base) allRules.push(`${selector}{${base}}`)
|
|
362
|
+
allRules.push(...atRules)
|
|
363
|
+
|
|
364
|
+
const finalRules = this.layer ? allRules.map((r) => `@layer ${this.layer}{${r}}`) : allRules
|
|
365
|
+
|
|
366
|
+
return { className, rules: finalRules.join("") }
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Check if a className is already in the cache. O(1) Map lookup. */
|
|
370
|
+
has(className: string): boolean {
|
|
371
|
+
return this.cache.has(className)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Current number of cached rules. */
|
|
375
|
+
get cacheSize(): number {
|
|
376
|
+
return this.cache.size
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Default singleton sheet for client-side use. */
|
|
381
|
+
export const sheet = new StyleSheet()
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Factory for creating isolated StyleSheet instances.
|
|
385
|
+
* Use in SSR to get per-request isolation.
|
|
386
|
+
*/
|
|
387
|
+
export const createSheet = (options?: StyleSheetOptions): StyleSheet => new StyleSheet(options)
|
package/src/styled.tsx
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* styled() component factory. Creates Pyreon components that inject CSS
|
|
3
|
+
* class names from tagged template literals.
|
|
4
|
+
*
|
|
5
|
+
* Supports:
|
|
6
|
+
* - styled('div')`...` and styled(Component)`...`
|
|
7
|
+
* - styled.div`...` (via Proxy)
|
|
8
|
+
* - `as` prop for polymorphic rendering
|
|
9
|
+
* - $-prefixed transient props (not forwarded to DOM)
|
|
10
|
+
* - Custom shouldForwardProp for per-component prop filtering
|
|
11
|
+
* - Static path optimization (templates with no dynamic interpolations)
|
|
12
|
+
* - Boost specificity via doubled selector
|
|
13
|
+
*
|
|
14
|
+
* CSS nesting (`&` selectors) works natively — the resolver passes CSS
|
|
15
|
+
* through without transformation, so `&:hover`, `&::before`, etc. work
|
|
16
|
+
* as-is in browsers supporting CSS Nesting (all modern browsers).
|
|
17
|
+
*/
|
|
18
|
+
import type { ComponentFn, VNode } from "@pyreon/core"
|
|
19
|
+
import { h } from "@pyreon/core"
|
|
20
|
+
import { buildProps } from "./forward"
|
|
21
|
+
import { type Interpolation, normalizeCSS, resolve } from "./resolve"
|
|
22
|
+
import { isDynamic } from "./shared"
|
|
23
|
+
import { sheet } from "./sheet"
|
|
24
|
+
import { useTheme } from "./ThemeProvider"
|
|
25
|
+
|
|
26
|
+
type Tag = string | ComponentFn<any>
|
|
27
|
+
|
|
28
|
+
export interface StyledOptions {
|
|
29
|
+
/** Custom prop filter. Return true to forward the prop to the DOM element. */
|
|
30
|
+
shouldForwardProp?: (prop: string) => boolean
|
|
31
|
+
/**
|
|
32
|
+
* Double the class selector to raise specificity from (0,1,0) to (0,2,0).
|
|
33
|
+
* Ensures this component's styles override inner library components
|
|
34
|
+
* regardless of CSS source order.
|
|
35
|
+
*/
|
|
36
|
+
boost?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const getDisplayName = (tag: Tag): string =>
|
|
40
|
+
typeof tag === "string"
|
|
41
|
+
? tag
|
|
42
|
+
: (tag as ComponentFn<any> & { displayName?: string }).displayName || tag.name || "Component"
|
|
43
|
+
|
|
44
|
+
// Component cache: same template literal + tag + no options → same component.
|
|
45
|
+
// WeakMap on `strings` (TemplateStringsArray is object-identity per source location).
|
|
46
|
+
const staticComponentCache = new WeakMap<TemplateStringsArray, Map<Tag, ComponentFn>>()
|
|
47
|
+
|
|
48
|
+
// Single-entry hot cache — just 3 reference comparisons, no Map/WeakMap overhead.
|
|
49
|
+
let _hotStrings: TemplateStringsArray | null = null
|
|
50
|
+
let _hotTag: Tag | null = null
|
|
51
|
+
let _hotComponent: ComponentFn | null = null
|
|
52
|
+
|
|
53
|
+
const createStyledComponent = (
|
|
54
|
+
tag: Tag,
|
|
55
|
+
strings: TemplateStringsArray,
|
|
56
|
+
values: Interpolation[],
|
|
57
|
+
options?: StyledOptions,
|
|
58
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex logic is inherent to this function
|
|
59
|
+
): ComponentFn => {
|
|
60
|
+
// Ultra-fast hot cache: 3 reference comparisons → return immediately
|
|
61
|
+
if (values.length === 0 && !options) {
|
|
62
|
+
if (strings === _hotStrings && tag === _hotTag) return _hotComponent as ComponentFn
|
|
63
|
+
|
|
64
|
+
// WeakMap fallback for alternating patterns
|
|
65
|
+
const tagMap = staticComponentCache.get(strings)
|
|
66
|
+
if (tagMap) {
|
|
67
|
+
const cached = tagMap.get(tag)
|
|
68
|
+
if (cached) {
|
|
69
|
+
_hotStrings = strings
|
|
70
|
+
_hotTag = tag
|
|
71
|
+
_hotComponent = cached
|
|
72
|
+
return cached
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Fast check: no values means no dynamic interpolations — avoids .some() scan
|
|
78
|
+
const hasDynamicValues = values.length > 0 && values.some(isDynamic)
|
|
79
|
+
const customFilter = options ? options.shouldForwardProp : undefined
|
|
80
|
+
const boost = options ? (options.boost ?? false) : false
|
|
81
|
+
|
|
82
|
+
// STATIC FAST PATH: no function interpolations → compute class once at creation time
|
|
83
|
+
if (!hasDynamicValues) {
|
|
84
|
+
// Inline resolve for the common no-values case
|
|
85
|
+
const raw = values.length === 0 ? (strings[0] as string) : resolve(strings, values, {})
|
|
86
|
+
const cssText = normalizeCSS(raw)
|
|
87
|
+
const hasCss = cssText.length > 0
|
|
88
|
+
|
|
89
|
+
const staticClassName = hasCss ? sheet.insert(cssText, boost) : ""
|
|
90
|
+
|
|
91
|
+
const StaticStyled: ComponentFn = (rawProps: Record<string, any>): VNode | null => {
|
|
92
|
+
const finalTag = rawProps.as || tag
|
|
93
|
+
const isDOM = typeof finalTag === "string"
|
|
94
|
+
const finalProps = buildProps(rawProps, staticClassName, isDOM, customFilter)
|
|
95
|
+
|
|
96
|
+
return h(
|
|
97
|
+
finalTag as string,
|
|
98
|
+
finalProps,
|
|
99
|
+
...(Array.isArray(rawProps.children)
|
|
100
|
+
? rawProps.children
|
|
101
|
+
: rawProps.children != null
|
|
102
|
+
? [rawProps.children]
|
|
103
|
+
: []),
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
;(StaticStyled as ComponentFn & { displayName?: string }).displayName =
|
|
108
|
+
`styled(${getDisplayName(tag)})`
|
|
109
|
+
|
|
110
|
+
// Store in component cache + hot cache for future reuse
|
|
111
|
+
if (!options && values.length === 0) {
|
|
112
|
+
let tagMap = staticComponentCache.get(strings)
|
|
113
|
+
if (!tagMap) {
|
|
114
|
+
tagMap = new Map()
|
|
115
|
+
staticComponentCache.set(strings, tagMap)
|
|
116
|
+
}
|
|
117
|
+
tagMap.set(tag, StaticStyled)
|
|
118
|
+
_hotStrings = strings
|
|
119
|
+
_hotTag = tag
|
|
120
|
+
_hotComponent = StaticStyled
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return StaticStyled
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// DYNAMIC PATH: resolve CSS on every render with theme/props.
|
|
127
|
+
const DynamicStyled: ComponentFn = (rawProps: Record<string, any>): VNode | null => {
|
|
128
|
+
const theme = useTheme()
|
|
129
|
+
const allProps = { ...rawProps, theme }
|
|
130
|
+
const cssText = normalizeCSS(resolve(strings, values, allProps))
|
|
131
|
+
|
|
132
|
+
const className = cssText.length > 0 ? sheet.insert(cssText, boost) : ""
|
|
133
|
+
|
|
134
|
+
const finalTag = rawProps.as || tag
|
|
135
|
+
const isDOM = typeof finalTag === "string"
|
|
136
|
+
const finalProps = buildProps(rawProps, className, isDOM, customFilter)
|
|
137
|
+
|
|
138
|
+
return h(
|
|
139
|
+
finalTag as string,
|
|
140
|
+
finalProps,
|
|
141
|
+
...(Array.isArray(rawProps.children)
|
|
142
|
+
? rawProps.children
|
|
143
|
+
: rawProps.children != null
|
|
144
|
+
? [rawProps.children]
|
|
145
|
+
: []),
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
;(DynamicStyled as ComponentFn & { displayName?: string }).displayName =
|
|
150
|
+
`styled(${getDisplayName(tag)})`
|
|
151
|
+
return DynamicStyled
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Factory function: styled(tag) returns a tagged template function. */
|
|
155
|
+
const styledFactory = (tag: Tag, options?: StyledOptions) => {
|
|
156
|
+
const templateFn = (strings: TemplateStringsArray, ...values: Interpolation[]) =>
|
|
157
|
+
createStyledComponent(tag, strings, values, options)
|
|
158
|
+
|
|
159
|
+
return templateFn
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Main styled export. Supports both calling conventions:
|
|
164
|
+
* - `styled('div')` or `styled(Component)` → returns tagged template function
|
|
165
|
+
* - `styled('div', { shouldForwardProp })` → with custom prop filtering
|
|
166
|
+
* - `styled.div` → shorthand via Proxy (no options)
|
|
167
|
+
*/
|
|
168
|
+
// Cache template functions per tag to avoid closure allocation on every Proxy get
|
|
169
|
+
const proxyCache = new Map<string, (...args: any[]) => any>()
|
|
170
|
+
|
|
171
|
+
type TagTemplateFn = (strings: TemplateStringsArray, ...values: Interpolation[]) => ComponentFn
|
|
172
|
+
|
|
173
|
+
type HtmlTags =
|
|
174
|
+
| "a"
|
|
175
|
+
| "abbr"
|
|
176
|
+
| "address"
|
|
177
|
+
| "article"
|
|
178
|
+
| "aside"
|
|
179
|
+
| "audio"
|
|
180
|
+
| "b"
|
|
181
|
+
| "blockquote"
|
|
182
|
+
| "body"
|
|
183
|
+
| "br"
|
|
184
|
+
| "button"
|
|
185
|
+
| "canvas"
|
|
186
|
+
| "caption"
|
|
187
|
+
| "code"
|
|
188
|
+
| "col"
|
|
189
|
+
| "colgroup"
|
|
190
|
+
| "dd"
|
|
191
|
+
| "details"
|
|
192
|
+
| "div"
|
|
193
|
+
| "dl"
|
|
194
|
+
| "dt"
|
|
195
|
+
| "em"
|
|
196
|
+
| "fieldset"
|
|
197
|
+
| "figcaption"
|
|
198
|
+
| "figure"
|
|
199
|
+
| "footer"
|
|
200
|
+
| "form"
|
|
201
|
+
| "h1"
|
|
202
|
+
| "h2"
|
|
203
|
+
| "h3"
|
|
204
|
+
| "h4"
|
|
205
|
+
| "h5"
|
|
206
|
+
| "h6"
|
|
207
|
+
| "head"
|
|
208
|
+
| "header"
|
|
209
|
+
| "hr"
|
|
210
|
+
| "html"
|
|
211
|
+
| "i"
|
|
212
|
+
| "iframe"
|
|
213
|
+
| "img"
|
|
214
|
+
| "input"
|
|
215
|
+
| "label"
|
|
216
|
+
| "legend"
|
|
217
|
+
| "li"
|
|
218
|
+
| "link"
|
|
219
|
+
| "main"
|
|
220
|
+
| "mark"
|
|
221
|
+
| "menu"
|
|
222
|
+
| "meta"
|
|
223
|
+
| "nav"
|
|
224
|
+
| "ol"
|
|
225
|
+
| "optgroup"
|
|
226
|
+
| "option"
|
|
227
|
+
| "output"
|
|
228
|
+
| "p"
|
|
229
|
+
| "picture"
|
|
230
|
+
| "pre"
|
|
231
|
+
| "progress"
|
|
232
|
+
| "q"
|
|
233
|
+
| "section"
|
|
234
|
+
| "select"
|
|
235
|
+
| "small"
|
|
236
|
+
| "source"
|
|
237
|
+
| "span"
|
|
238
|
+
| "strong"
|
|
239
|
+
| "style"
|
|
240
|
+
| "sub"
|
|
241
|
+
| "summary"
|
|
242
|
+
| "sup"
|
|
243
|
+
| "svg"
|
|
244
|
+
| "table"
|
|
245
|
+
| "tbody"
|
|
246
|
+
| "td"
|
|
247
|
+
| "template"
|
|
248
|
+
| "textarea"
|
|
249
|
+
| "tfoot"
|
|
250
|
+
| "th"
|
|
251
|
+
| "thead"
|
|
252
|
+
| "time"
|
|
253
|
+
| "tr"
|
|
254
|
+
| "u"
|
|
255
|
+
| "ul"
|
|
256
|
+
| "video"
|
|
257
|
+
|
|
258
|
+
export type StyledFunction = ((tag: Tag, options?: StyledOptions) => TagTemplateFn) & {
|
|
259
|
+
[K in HtmlTags]: TagTemplateFn
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Proxy is needed to support styled.div`...` syntax; the cast bridges
|
|
263
|
+
// styledFactory's call signature to StyledFunction which adds HTML tag properties.
|
|
264
|
+
// Proxy target uses `as any` because TS can't resolve Proxy<StyledFunction> with mapped types
|
|
265
|
+
export const styled: StyledFunction = new Proxy(styledFactory as any, {
|
|
266
|
+
get(_target: unknown, prop: string) {
|
|
267
|
+
if (prop === "prototype" || prop === "$$typeof") return undefined
|
|
268
|
+
// styled.div`...`, styled.span`...`, etc.
|
|
269
|
+
let fn = proxyCache.get(prop)
|
|
270
|
+
if (!fn) {
|
|
271
|
+
fn = (strings: TemplateStringsArray, ...values: Interpolation[]) =>
|
|
272
|
+
createStyledComponent(prop, strings, values)
|
|
273
|
+
proxyCache.set(prop, fn)
|
|
274
|
+
}
|
|
275
|
+
return fn
|
|
276
|
+
},
|
|
277
|
+
})
|
package/src/useCSS.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook that resolves a CSSResult template with props, injects CSS
|
|
3
|
+
* into the shared stylesheet, and returns the className.
|
|
4
|
+
*
|
|
5
|
+
* Use this when you need computed CSS class names on plain elements
|
|
6
|
+
* without the overhead of a styled component layer.
|
|
7
|
+
*/
|
|
8
|
+
import { type CSSResult, normalizeCSS, resolve } from "./resolve"
|
|
9
|
+
import { sheet } from "./sheet"
|
|
10
|
+
import { useTheme } from "./ThemeProvider"
|
|
11
|
+
|
|
12
|
+
export function useCSS(template: CSSResult, props?: Record<string, any>, boost?: boolean): string {
|
|
13
|
+
const theme = useTheme()
|
|
14
|
+
const allProps = theme ? { ...props, theme } : (props ?? {})
|
|
15
|
+
const cssText = normalizeCSS(resolve(template.strings, template.values, allProps))
|
|
16
|
+
|
|
17
|
+
if (!cssText.trim()) return ""
|
|
18
|
+
|
|
19
|
+
return sheet.insert(cssText, boost)
|
|
20
|
+
}
|