@pyreon/styler 0.11.0 → 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 +12 -10
- 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/forward.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML prop filtering. Prevents unknown props from being forwarded to DOM
|
|
3
|
+
* elements (which causes warnings). Props starting with `$` are
|
|
4
|
+
* transient (styling-only) and are always filtered out.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Common HTML attributes, event handlers, and ARIA/data attributes
|
|
8
|
+
const HTML_PROPS = new Set([
|
|
9
|
+
// Core props
|
|
10
|
+
"className",
|
|
11
|
+
"class",
|
|
12
|
+
"dangerouslySetInnerHTML",
|
|
13
|
+
"htmlFor",
|
|
14
|
+
"id",
|
|
15
|
+
"key",
|
|
16
|
+
"ref",
|
|
17
|
+
"style",
|
|
18
|
+
"tabIndex",
|
|
19
|
+
"role",
|
|
20
|
+
// Event handlers
|
|
21
|
+
"onAbort",
|
|
22
|
+
"onAnimationEnd",
|
|
23
|
+
"onAnimationIteration",
|
|
24
|
+
"onAnimationStart",
|
|
25
|
+
"onBlur",
|
|
26
|
+
"onChange",
|
|
27
|
+
"onClick",
|
|
28
|
+
"onCompositionEnd",
|
|
29
|
+
"onCompositionStart",
|
|
30
|
+
"onCompositionUpdate",
|
|
31
|
+
"onContextMenu",
|
|
32
|
+
"onCopy",
|
|
33
|
+
"onCut",
|
|
34
|
+
"onDoubleClick",
|
|
35
|
+
"onDrag",
|
|
36
|
+
"onDragEnd",
|
|
37
|
+
"onDragEnter",
|
|
38
|
+
"onDragLeave",
|
|
39
|
+
"onDragOver",
|
|
40
|
+
"onDragStart",
|
|
41
|
+
"onDrop",
|
|
42
|
+
"onError",
|
|
43
|
+
"onFocus",
|
|
44
|
+
"onInput",
|
|
45
|
+
"onKeyDown",
|
|
46
|
+
"onKeyPress",
|
|
47
|
+
"onKeyUp",
|
|
48
|
+
"onLoad",
|
|
49
|
+
"onMouseDown",
|
|
50
|
+
"onMouseEnter",
|
|
51
|
+
"onMouseLeave",
|
|
52
|
+
"onMouseMove",
|
|
53
|
+
"onMouseOut",
|
|
54
|
+
"onMouseOver",
|
|
55
|
+
"onMouseUp",
|
|
56
|
+
"onPaste",
|
|
57
|
+
"onPointerCancel",
|
|
58
|
+
"onPointerDown",
|
|
59
|
+
"onPointerEnter",
|
|
60
|
+
"onPointerLeave",
|
|
61
|
+
"onPointerMove",
|
|
62
|
+
"onPointerOut",
|
|
63
|
+
"onPointerOver",
|
|
64
|
+
"onPointerUp",
|
|
65
|
+
"onScroll",
|
|
66
|
+
"onSelect",
|
|
67
|
+
"onSubmit",
|
|
68
|
+
"onTouchCancel",
|
|
69
|
+
"onTouchEnd",
|
|
70
|
+
"onTouchMove",
|
|
71
|
+
"onTouchStart",
|
|
72
|
+
"onTransitionEnd",
|
|
73
|
+
"onWheel",
|
|
74
|
+
// HTML attributes
|
|
75
|
+
"accept",
|
|
76
|
+
"acceptCharset",
|
|
77
|
+
"accessKey",
|
|
78
|
+
"action",
|
|
79
|
+
"allow",
|
|
80
|
+
"allowFullScreen",
|
|
81
|
+
"alt",
|
|
82
|
+
"as",
|
|
83
|
+
"async",
|
|
84
|
+
"autoCapitalize",
|
|
85
|
+
"autoComplete",
|
|
86
|
+
"autoCorrect",
|
|
87
|
+
"autoFocus",
|
|
88
|
+
"autoPlay",
|
|
89
|
+
"capture",
|
|
90
|
+
"cellPadding",
|
|
91
|
+
"cellSpacing",
|
|
92
|
+
"charSet",
|
|
93
|
+
"checked",
|
|
94
|
+
"cite",
|
|
95
|
+
"cols",
|
|
96
|
+
"colSpan",
|
|
97
|
+
"content",
|
|
98
|
+
"contentEditable",
|
|
99
|
+
"controls",
|
|
100
|
+
"controlsList",
|
|
101
|
+
"coords",
|
|
102
|
+
"crossOrigin",
|
|
103
|
+
"dateTime",
|
|
104
|
+
"decoding",
|
|
105
|
+
"default",
|
|
106
|
+
"defaultChecked",
|
|
107
|
+
"defaultValue",
|
|
108
|
+
"defer",
|
|
109
|
+
"dir",
|
|
110
|
+
"disabled",
|
|
111
|
+
"disablePictureInPicture",
|
|
112
|
+
"disableRemotePlayback",
|
|
113
|
+
"download",
|
|
114
|
+
"draggable",
|
|
115
|
+
"encType",
|
|
116
|
+
"enterKeyHint",
|
|
117
|
+
"fetchPriority",
|
|
118
|
+
"form",
|
|
119
|
+
"formAction",
|
|
120
|
+
"formEncType",
|
|
121
|
+
"formMethod",
|
|
122
|
+
"formNoValidate",
|
|
123
|
+
"formTarget",
|
|
124
|
+
"frameBorder",
|
|
125
|
+
"headers",
|
|
126
|
+
"height",
|
|
127
|
+
"hidden",
|
|
128
|
+
"high",
|
|
129
|
+
"href",
|
|
130
|
+
"hrefLang",
|
|
131
|
+
"httpEquiv",
|
|
132
|
+
"inputMode",
|
|
133
|
+
"integrity",
|
|
134
|
+
"is",
|
|
135
|
+
"label",
|
|
136
|
+
"lang",
|
|
137
|
+
"list",
|
|
138
|
+
"loading",
|
|
139
|
+
"loop",
|
|
140
|
+
"low",
|
|
141
|
+
"max",
|
|
142
|
+
"maxLength",
|
|
143
|
+
"media",
|
|
144
|
+
"method",
|
|
145
|
+
"min",
|
|
146
|
+
"minLength",
|
|
147
|
+
"multiple",
|
|
148
|
+
"muted",
|
|
149
|
+
"name",
|
|
150
|
+
"noModule",
|
|
151
|
+
"noValidate",
|
|
152
|
+
"nonce",
|
|
153
|
+
"open",
|
|
154
|
+
"optimum",
|
|
155
|
+
"pattern",
|
|
156
|
+
"placeholder",
|
|
157
|
+
"playsInline",
|
|
158
|
+
"poster",
|
|
159
|
+
"preload",
|
|
160
|
+
"readOnly",
|
|
161
|
+
"referrerPolicy",
|
|
162
|
+
"rel",
|
|
163
|
+
"required",
|
|
164
|
+
"reversed",
|
|
165
|
+
"rows",
|
|
166
|
+
"rowSpan",
|
|
167
|
+
"sandbox",
|
|
168
|
+
"scope",
|
|
169
|
+
"scoped",
|
|
170
|
+
"scrolling",
|
|
171
|
+
"selected",
|
|
172
|
+
"shape",
|
|
173
|
+
"size",
|
|
174
|
+
"sizes",
|
|
175
|
+
"slot",
|
|
176
|
+
"span",
|
|
177
|
+
"spellCheck",
|
|
178
|
+
"src",
|
|
179
|
+
"srcDoc",
|
|
180
|
+
"srcLang",
|
|
181
|
+
"srcSet",
|
|
182
|
+
"start",
|
|
183
|
+
"step",
|
|
184
|
+
"summary",
|
|
185
|
+
"target",
|
|
186
|
+
"title",
|
|
187
|
+
"translate",
|
|
188
|
+
"type",
|
|
189
|
+
"useMap",
|
|
190
|
+
"value",
|
|
191
|
+
"width",
|
|
192
|
+
"wrap",
|
|
193
|
+
])
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Filters props for HTML elements. Keeps valid HTML attrs, data-*, aria-*.
|
|
197
|
+
* Rejects unknown props and $-prefixed transient props.
|
|
198
|
+
*/
|
|
199
|
+
export const filterProps = (props: Record<string, unknown>): Record<string, unknown> => {
|
|
200
|
+
const filtered: Record<string, unknown> = {}
|
|
201
|
+
|
|
202
|
+
for (const key in props) {
|
|
203
|
+
// Skip transient props ($-prefixed) — used for styling-only props
|
|
204
|
+
if (key.charCodeAt(0) === 36) continue // '$'
|
|
205
|
+
|
|
206
|
+
// Skip `as` prop — handled separately by styled
|
|
207
|
+
if (key === "as") continue
|
|
208
|
+
|
|
209
|
+
// Keep data-* and aria-* attributes
|
|
210
|
+
if (key.startsWith("data-") || key.startsWith("aria-")) {
|
|
211
|
+
filtered[key] = props[key]
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Keep known HTML props
|
|
216
|
+
if (HTML_PROPS.has(key)) {
|
|
217
|
+
filtered[key] = props[key]
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return filtered
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Build final props for a styled component in a single pass.
|
|
226
|
+
* Combines className merging, ref injection, and prop filtering into one
|
|
227
|
+
* allocation and one iteration.
|
|
228
|
+
*/
|
|
229
|
+
export const buildProps = (
|
|
230
|
+
rawProps: Record<string, any>,
|
|
231
|
+
generatedCls: string,
|
|
232
|
+
isDOM: boolean,
|
|
233
|
+
customFilter?: (prop: string) => boolean,
|
|
234
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex logic is inherent to this function
|
|
235
|
+
): Record<string, any> => {
|
|
236
|
+
const result: Record<string, any> = {}
|
|
237
|
+
|
|
238
|
+
// Merge generated + user className
|
|
239
|
+
const userCls = rawProps.class || rawProps.className
|
|
240
|
+
if (generatedCls) {
|
|
241
|
+
result.class = userCls ? `${generatedCls} ${userCls}` : generatedCls
|
|
242
|
+
} else if (userCls) {
|
|
243
|
+
result.class = userCls
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Component target — forward all props except as/className/class and $-prefixed
|
|
247
|
+
if (!isDOM) {
|
|
248
|
+
for (const key in rawProps) {
|
|
249
|
+
if (key === "as" || key === "className" || key === "class") continue
|
|
250
|
+
if (key.charCodeAt(0) === 36) continue // $-prefixed transient
|
|
251
|
+
result[key] = rawProps[key]
|
|
252
|
+
}
|
|
253
|
+
return result
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// DOM element with custom shouldForwardProp
|
|
257
|
+
if (customFilter) {
|
|
258
|
+
for (const key in rawProps) {
|
|
259
|
+
if (key === "as" || key === "className" || key === "class") continue
|
|
260
|
+
if (customFilter(key)) result[key] = rawProps[key]
|
|
261
|
+
}
|
|
262
|
+
return result
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// DOM element with default filtering
|
|
266
|
+
for (const key in rawProps) {
|
|
267
|
+
if (key === "as" || key === "className" || key === "class") continue
|
|
268
|
+
if (key.charCodeAt(0) === 36) continue // $-prefixed transient
|
|
269
|
+
if (key.startsWith("data-") || key.startsWith("aria-")) {
|
|
270
|
+
result[key] = rawProps[key]
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
if (HTML_PROPS.has(key)) result[key] = rawProps[key]
|
|
274
|
+
}
|
|
275
|
+
return result
|
|
276
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createGlobalStyle() — tagged template function that injects global CSS
|
|
3
|
+
* rules (not scoped to a class name). Returns a component function that
|
|
4
|
+
* injects styles when called and supports dynamic interpolations via
|
|
5
|
+
* props/theme.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const GlobalStyle = createGlobalStyle`
|
|
9
|
+
* body { margin: 0; font-family: ${({ theme }) => theme.font}; }
|
|
10
|
+
* *, *::before, *::after { box-sizing: border-box; }
|
|
11
|
+
* `
|
|
12
|
+
*/
|
|
13
|
+
import type { ComponentFn } from "@pyreon/core"
|
|
14
|
+
import { type Interpolation, normalizeCSS, resolve } from "./resolve"
|
|
15
|
+
import { isDynamic } from "./shared"
|
|
16
|
+
import { sheet } from "./sheet"
|
|
17
|
+
import { useTheme } from "./ThemeProvider"
|
|
18
|
+
|
|
19
|
+
export const createGlobalStyle = (
|
|
20
|
+
strings: TemplateStringsArray,
|
|
21
|
+
...values: Interpolation[]
|
|
22
|
+
): ComponentFn => {
|
|
23
|
+
const hasDynamicValues = values.some(isDynamic)
|
|
24
|
+
|
|
25
|
+
// STATIC FAST PATH: compute once at creation time
|
|
26
|
+
if (!hasDynamicValues) {
|
|
27
|
+
const cssText = normalizeCSS(resolve(strings, values, {}))
|
|
28
|
+
|
|
29
|
+
// Inject into sheet immediately
|
|
30
|
+
if (cssText.trim()) sheet.insertGlobal(cssText)
|
|
31
|
+
|
|
32
|
+
const StaticGlobal: ComponentFn = () => null
|
|
33
|
+
return StaticGlobal
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// DYNAMIC PATH: resolve on every render with theme/props
|
|
37
|
+
const DynamicGlobal: ComponentFn = (props: Record<string, any>) => {
|
|
38
|
+
const theme = useTheme()
|
|
39
|
+
const allProps = { ...props, theme }
|
|
40
|
+
const cssText = normalizeCSS(resolve(strings, values, allProps))
|
|
41
|
+
|
|
42
|
+
if (cssText.trim()) sheet.insertGlobal(cssText)
|
|
43
|
+
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return DynamicGlobal
|
|
48
|
+
}
|
package/src/hash.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fast FNV-1a non-cryptographic hash. Returns base-36 string for compact class names.
|
|
3
|
+
*
|
|
4
|
+
* 32-bit hash space → ~4.3 billion unique values. Collision probability is
|
|
5
|
+
* negligible for typical applications (< 10,000 unique CSS rules).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** FNV-1a offset basis — starting state for streaming hash. */
|
|
9
|
+
export const HASH_INIT = 2166136261
|
|
10
|
+
|
|
11
|
+
const FNV_PRIME = 16777619
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Feed a string segment into the running hash state.
|
|
15
|
+
* Streaming: hashUpdate(hashUpdate(HASH_INIT, 'ab'), 'cd') === hash('abcd').
|
|
16
|
+
*/
|
|
17
|
+
export const hashUpdate = (init: number, str: string): number => {
|
|
18
|
+
let h = init
|
|
19
|
+
for (let i = 0; i < str.length; i++) {
|
|
20
|
+
h ^= str.charCodeAt(i)
|
|
21
|
+
h = Math.imul(h, FNV_PRIME)
|
|
22
|
+
}
|
|
23
|
+
return h
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Finalize a hash state into a base-36 class name suffix. */
|
|
27
|
+
export const hashFinalize = (h: number): string => (h >>> 0).toString(36)
|
|
28
|
+
|
|
29
|
+
/** Hash a complete string in one shot. Returns base-36 string. */
|
|
30
|
+
export const hash = (str: string): string => hashFinalize(hashUpdate(HASH_INIT, str))
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { css } from "./css"
|
|
2
|
+
export { buildProps, filterProps } from "./forward"
|
|
3
|
+
export { createGlobalStyle } from "./globalStyle"
|
|
4
|
+
export { HASH_INIT, hash, hashFinalize, hashUpdate } from "./hash"
|
|
5
|
+
export { keyframes } from "./keyframes"
|
|
6
|
+
export type { CSSResult, Interpolation } from "./resolve"
|
|
7
|
+
export { clearNormCache, normalizeCSS, resolve, resolveValue } from "./resolve"
|
|
8
|
+
export { isDynamic } from "./shared"
|
|
9
|
+
export type { StyleSheetOptions } from "./sheet"
|
|
10
|
+
export { createSheet, StyleSheet, sheet } from "./sheet"
|
|
11
|
+
export type { StyledFunction, StyledOptions } from "./styled"
|
|
12
|
+
export { styled } from "./styled"
|
|
13
|
+
export type { DefaultTheme } from "./ThemeProvider"
|
|
14
|
+
export { ThemeContext, ThemeProvider, useTheme } from "./ThemeProvider"
|
|
15
|
+
export { useCSS } from "./useCSS"
|
package/src/keyframes.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* keyframes() tagged template function. Creates a CSS @keyframes rule,
|
|
3
|
+
* injects it into the stylesheet, and returns the generated animation name.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* const fadeIn = keyframes`
|
|
7
|
+
* from { opacity: 0; }
|
|
8
|
+
* to { opacity: 1; }
|
|
9
|
+
* `
|
|
10
|
+
* // fadeIn === "pyr-kf-abc123" (deterministic, hash-based)
|
|
11
|
+
*/
|
|
12
|
+
import { hash } from "./hash"
|
|
13
|
+
import { type Interpolation, normalizeCSS, resolve } from "./resolve"
|
|
14
|
+
import { sheet } from "./sheet"
|
|
15
|
+
|
|
16
|
+
class KeyframesResult {
|
|
17
|
+
readonly name: string
|
|
18
|
+
|
|
19
|
+
constructor(strings: TemplateStringsArray, values: Interpolation[]) {
|
|
20
|
+
const body = normalizeCSS(resolve(strings, values, {}))
|
|
21
|
+
const h = hash(body)
|
|
22
|
+
this.name = `pyr-kf-${h}`
|
|
23
|
+
|
|
24
|
+
sheet.insertKeyframes(this.name, body)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Returns the animation name when used in string context. */
|
|
28
|
+
toString(): string {
|
|
29
|
+
return this.name
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const keyframes = (
|
|
34
|
+
strings: TemplateStringsArray,
|
|
35
|
+
...values: Interpolation[]
|
|
36
|
+
): KeyframesResult => new KeyframesResult(strings, values)
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interpolation resolver: converts tagged template strings + values into a
|
|
3
|
+
* final CSS string. Handles nested CSSResults, arrays, functions, and
|
|
4
|
+
* primitive values.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { DefaultTheme } from "./ThemeProvider"
|
|
8
|
+
|
|
9
|
+
export type Interpolation =
|
|
10
|
+
| string
|
|
11
|
+
| number
|
|
12
|
+
| boolean
|
|
13
|
+
| null
|
|
14
|
+
| undefined
|
|
15
|
+
| CSSResult
|
|
16
|
+
| Interpolation[]
|
|
17
|
+
| ((props: { theme?: DefaultTheme & Record<string, any>; [key: string]: any }) => Interpolation)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Lazy representation of a `css` tagged template. Stores the raw template
|
|
21
|
+
* strings and interpolation values without resolving them. Resolution is
|
|
22
|
+
* deferred until a styled component renders (or until explicitly resolved).
|
|
23
|
+
*/
|
|
24
|
+
export class CSSResult {
|
|
25
|
+
constructor(
|
|
26
|
+
readonly strings: TemplateStringsArray,
|
|
27
|
+
readonly values: Interpolation[],
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
/** Resolve with empty props — useful for static templates, testing, and debugging. */
|
|
31
|
+
toString(): string {
|
|
32
|
+
return resolve(this.strings, this.values, {})
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Resolve a tagged template's strings + values into a final CSS string. */
|
|
37
|
+
export const resolve = (
|
|
38
|
+
strings: TemplateStringsArray,
|
|
39
|
+
values: Interpolation[],
|
|
40
|
+
props: Record<string, any>,
|
|
41
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex logic is inherent to this function
|
|
42
|
+
): string => {
|
|
43
|
+
// Tagged templates guarantee strings.length === values.length + 1,
|
|
44
|
+
// so strings[0] and strings[i+1] are always defined — no ?? needed.
|
|
45
|
+
let result = strings[0] as string
|
|
46
|
+
for (let i = 0; i < values.length; i++) {
|
|
47
|
+
const v = values[i]
|
|
48
|
+
const s = strings[i + 1] as string
|
|
49
|
+
// Inline the most common value types to avoid function call overhead.
|
|
50
|
+
if (typeof v === "function") {
|
|
51
|
+
const r = v(props)
|
|
52
|
+
result +=
|
|
53
|
+
(typeof r === "string"
|
|
54
|
+
? r
|
|
55
|
+
: r == null || r === false || r === true
|
|
56
|
+
? ""
|
|
57
|
+
: resolveValue(r as Interpolation, props)) + s
|
|
58
|
+
} else if (v == null || v === false || v === true) {
|
|
59
|
+
result += s
|
|
60
|
+
} else if (typeof v === "string") {
|
|
61
|
+
result += v + s
|
|
62
|
+
} else if (typeof v === "number") {
|
|
63
|
+
result += v + s
|
|
64
|
+
} else {
|
|
65
|
+
result += resolveValue(v, props) + s
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return result
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Normalize resolved CSS text for strict `insertRule` compatibility.
|
|
73
|
+
*
|
|
74
|
+
* Single-pass scanner that handles all cleanup in one traversal:
|
|
75
|
+
* - Strips block comments and line comments (preserves :// in URLs)
|
|
76
|
+
* - Collapses whitespace to single spaces
|
|
77
|
+
* - Removes redundant semicolons
|
|
78
|
+
* - Trims leading/trailing whitespace
|
|
79
|
+
*/
|
|
80
|
+
const normCache = new Map<string, string>()
|
|
81
|
+
/** Clear the normalizeCSS cache (called during HMR cleanup). */
|
|
82
|
+
export const clearNormCache = () => normCache.clear()
|
|
83
|
+
|
|
84
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex logic is inherent to this function
|
|
85
|
+
export const normalizeCSS = (css: string): string => {
|
|
86
|
+
const cached = normCache.get(css)
|
|
87
|
+
if (cached !== undefined) return cached
|
|
88
|
+
|
|
89
|
+
const len = css.length
|
|
90
|
+
let out = ""
|
|
91
|
+
let space = false // pending space to emit before next non-whitespace char
|
|
92
|
+
let last = 0 // charCode of last char written to output (0 = nothing yet)
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < len; i++) {
|
|
95
|
+
const c = css.charCodeAt(i)
|
|
96
|
+
|
|
97
|
+
// /* block comment */
|
|
98
|
+
if (c === 47 /* / */ && css.charCodeAt(i + 1) === 42 /* * */) {
|
|
99
|
+
const end = css.indexOf("*/", i + 2)
|
|
100
|
+
i = end === -1 ? len : end + 1
|
|
101
|
+
space = true
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// // line comment (but not :// in URLs)
|
|
106
|
+
if (c === 47 /* / */ && css.charCodeAt(i + 1) === 47 /* / */ && last !== 58 /* : */) {
|
|
107
|
+
const nl = css.indexOf("\n", i + 2)
|
|
108
|
+
i = nl === -1 ? len : nl
|
|
109
|
+
space = true
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Whitespace → collapse
|
|
114
|
+
if (c === 32 || c === 9 || c === 10 || c === 13 || c === 12) {
|
|
115
|
+
space = true
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Semicolon → skip if redundant (after start, {, }, or another ;)
|
|
120
|
+
if (c === 59 /* ; */) {
|
|
121
|
+
if (last === 0 || last === 123 /* { */ || last === 125 /* } */ || last === 59 /* ; */) {
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
space = false
|
|
125
|
+
out += ";"
|
|
126
|
+
last = 59
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Regular char — emit pending space (but not at start of output)
|
|
131
|
+
if (space && last !== 0) out += " "
|
|
132
|
+
space = false
|
|
133
|
+
|
|
134
|
+
out += css[i]
|
|
135
|
+
last = c
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Evict oldest ~10% to prevent memory leaks without cliff-edge drop
|
|
139
|
+
if (normCache.size > 2000) {
|
|
140
|
+
let count = 0
|
|
141
|
+
for (const key of normCache.keys()) {
|
|
142
|
+
if (count >= 200) break
|
|
143
|
+
normCache.delete(key)
|
|
144
|
+
count++
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
normCache.set(css, out)
|
|
148
|
+
|
|
149
|
+
return out
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const resolveValue = (value: Interpolation, props: Record<string, any>): string => {
|
|
153
|
+
// null, undefined, false, true → empty (enables conditional: ${cond && css`...`})
|
|
154
|
+
if (value == null || value === false || value === true) return ""
|
|
155
|
+
|
|
156
|
+
// function interpolation → call with props/theme context, resolve result
|
|
157
|
+
if (typeof value === "function") return resolveValue(value(props) as Interpolation, props)
|
|
158
|
+
|
|
159
|
+
// nested CSSResult → recursively resolve
|
|
160
|
+
if (value instanceof CSSResult) return resolve(value.strings, value.values, props)
|
|
161
|
+
|
|
162
|
+
// array of results (e.g. from makeItResponsive's breakpoints.map())
|
|
163
|
+
if (Array.isArray(value)) {
|
|
164
|
+
let arrayResult = ""
|
|
165
|
+
for (let i = 0; i < value.length; i++) {
|
|
166
|
+
arrayResult += resolveValue(value[i], props)
|
|
167
|
+
}
|
|
168
|
+
return arrayResult
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return String(value)
|
|
172
|
+
}
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities used across multiple modules.
|
|
3
|
+
*/
|
|
4
|
+
import { CSSResult, type Interpolation } from "./resolve"
|
|
5
|
+
|
|
6
|
+
/** Check if an interpolation value is dynamic (contains functions or nested dynamic CSSResults). */
|
|
7
|
+
export const isDynamic = (v: Interpolation): boolean => {
|
|
8
|
+
if (typeof v === "function") return true
|
|
9
|
+
if (Array.isArray(v)) return v.some(isDynamic)
|
|
10
|
+
if (v instanceof CSSResult) return v.values.some(isDynamic)
|
|
11
|
+
return false
|
|
12
|
+
}
|