@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.
Files changed (36) hide show
  1. package/package.json +12 -10
  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/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"
@@ -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
+ }