@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.
Files changed (36) hide show
  1. package/package.json +6 -5
  2. package/src/ThemeProvider.ts +37 -0
  3. package/src/__tests__/ThemeProvider.test.ts +67 -0
  4. package/src/__tests__/benchmark.bench.ts +189 -0
  5. package/src/__tests__/composition-chain.test.ts +489 -0
  6. package/src/__tests__/css.test.ts +70 -0
  7. package/src/__tests__/forward.test.ts +282 -0
  8. package/src/__tests__/globalStyle.test.ts +72 -0
  9. package/src/__tests__/hash.test.ts +70 -0
  10. package/src/__tests__/hybrid-injection.test.ts +205 -0
  11. package/src/__tests__/index.ts +14 -0
  12. package/src/__tests__/insertion-effect.test.ts +106 -0
  13. package/src/__tests__/integration.test.ts +149 -0
  14. package/src/__tests__/keyframes.test.ts +68 -0
  15. package/src/__tests__/memory-growth.test.ts +152 -0
  16. package/src/__tests__/p3-features.test.ts +258 -0
  17. package/src/__tests__/resolve.test.ts +249 -0
  18. package/src/__tests__/shared.test.ts +73 -0
  19. package/src/__tests__/sheet-advanced.test.ts +669 -0
  20. package/src/__tests__/sheet-split-atrules.test.ts +411 -0
  21. package/src/__tests__/sheet.test.ts +164 -0
  22. package/src/__tests__/styled-ssr.test.ts +67 -0
  23. package/src/__tests__/styled.test.ts +303 -0
  24. package/src/__tests__/theme.test.ts +33 -0
  25. package/src/__tests__/useCSS.test.ts +142 -0
  26. package/src/css.ts +13 -0
  27. package/src/forward.ts +276 -0
  28. package/src/globalStyle.ts +48 -0
  29. package/src/hash.ts +30 -0
  30. package/src/index.ts +15 -0
  31. package/src/keyframes.ts +36 -0
  32. package/src/resolve.ts +172 -0
  33. package/src/shared.ts +12 -0
  34. package/src/sheet.ts +387 -0
  35. package/src/styled.tsx +277 -0
  36. 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
+ }