@vitus-labs/styler 2.0.0-alpha.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023-present Vit Bokisch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,324 @@
1
+ # @vitus-labs/styler
2
+
3
+ A lightweight CSS-in-JS engine for React. Drop-in replacement for `styled-components` at a fraction of the size.
4
+
5
+ **3.06 KB** gzipped | **React 18+** | **SSR ready** | **TypeScript strict**
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @vitus-labs/styler
11
+ # or
12
+ bun add @vitus-labs/styler
13
+ ```
14
+
15
+ React 18+ is required as a peer dependency.
16
+
17
+ ## Quick Start
18
+
19
+ ```tsx
20
+ import { styled, css, ThemeProvider } from '@vitus-labs/styler'
21
+
22
+ const Button = styled('button')`
23
+ display: inline-flex;
24
+ align-items: center;
25
+ padding: 8px 16px;
26
+ border-radius: 4px;
27
+ background: ${({ theme }) => theme.colors.primary};
28
+ color: white;
29
+ cursor: pointer;
30
+
31
+ &:hover {
32
+ opacity: 0.9;
33
+ }
34
+ `
35
+
36
+ function App() {
37
+ return (
38
+ <ThemeProvider theme={{ colors: { primary: '#3b82f6' } }}>
39
+ <Button>Click me</Button>
40
+ </ThemeProvider>
41
+ )
42
+ }
43
+ ```
44
+
45
+ ## API
46
+
47
+ ### `styled(tag)`
48
+
49
+ Creates a styled React component from an HTML tag or another component.
50
+
51
+ ```tsx
52
+ // HTML tag
53
+ const Box = styled('div')`
54
+ display: flex;
55
+ `
56
+
57
+ // Shorthand (via Proxy)
58
+ const Box = styled.div`
59
+ display: flex;
60
+ `
61
+
62
+ // Wrapping a component
63
+ const StyledLink = styled(Link)`
64
+ color: blue;
65
+ text-decoration: none;
66
+ `
67
+ ```
68
+
69
+ #### Dynamic interpolations
70
+
71
+ Function interpolations receive all props plus the current `theme`:
72
+
73
+ ```tsx
74
+ const Text = styled('p')`
75
+ color: ${({ theme }) => theme.colors.text};
76
+ font-size: ${(props) => props.$size || '16px'};
77
+ `
78
+ ```
79
+
80
+ #### Polymorphic `as` prop
81
+
82
+ Render as a different element at runtime:
83
+
84
+ ```tsx
85
+ const Box = styled('div')`padding: 16px;`
86
+
87
+ <Box as="section">Renders as a &lt;section&gt;</Box>
88
+ ```
89
+
90
+ #### Ref forwarding
91
+
92
+ All styled components forward refs via `React.forwardRef`:
93
+
94
+ ```tsx
95
+ const Input = styled('input')`border: 1px solid #ccc;`
96
+
97
+ const ref = useRef<HTMLInputElement>(null)
98
+ <Input ref={ref} />
99
+ ```
100
+
101
+ #### Transient props
102
+
103
+ Props prefixed with `$` are not forwarded to the DOM:
104
+
105
+ ```tsx
106
+ const Box = styled('div')`
107
+ color: ${(p) => p.$active ? 'blue' : 'gray'};
108
+ `
109
+
110
+ // $active is used for styling but won't appear on the <div>
111
+ <Box $active />
112
+ ```
113
+
114
+ #### Custom prop filtering
115
+
116
+ ```tsx
117
+ const Box = styled('div', {
118
+ shouldForwardProp: (prop) => prop !== 'size',
119
+ })`
120
+ font-size: ${(p) => p.size}px;
121
+ `
122
+ ```
123
+
124
+ ### `css`
125
+
126
+ Tagged template for composable CSS fragments:
127
+
128
+ ```tsx
129
+ const flexCenter = css`
130
+ display: flex;
131
+ align-items: center;
132
+ justify-content: center;
133
+ `
134
+
135
+ const Card = styled('div')`
136
+ ${flexCenter};
137
+ padding: 16px;
138
+ `
139
+ ```
140
+
141
+ Supports conditional patterns:
142
+
143
+ ```tsx
144
+ const Box = styled('div')`
145
+ display: flex;
146
+ ${(props) => props.$bordered && css`
147
+ border: 1px solid #e0e0e0;
148
+ border-radius: 4px;
149
+ `};
150
+ `
151
+ ```
152
+
153
+ ### `keyframes`
154
+
155
+ Creates `@keyframes` animations:
156
+
157
+ ```tsx
158
+ const fadeIn = keyframes`
159
+ from { opacity: 0; }
160
+ to { opacity: 1; }
161
+ `
162
+
163
+ const FadeBox = styled('div')`
164
+ animation: ${fadeIn} 300ms ease-in;
165
+ `
166
+ ```
167
+
168
+ ### `createGlobalStyle`
169
+
170
+ Injects global CSS rules (not scoped to a class):
171
+
172
+ ```tsx
173
+ const GlobalStyle = createGlobalStyle`
174
+ *, *::before, *::after {
175
+ box-sizing: border-box;
176
+ }
177
+
178
+ body {
179
+ margin: 0;
180
+ font-family: ${({ theme }) => theme.font};
181
+ }
182
+ `
183
+
184
+ // Renders nothing, injects CSS when mounted
185
+ <GlobalStyle />
186
+ ```
187
+
188
+ ### `ThemeProvider` & `useTheme`
189
+
190
+ Provides a theme object to all nested styled components via React context:
191
+
192
+ ```tsx
193
+ const theme = {
194
+ colors: { primary: '#3b82f6', text: '#111' },
195
+ spacing: (n: number) => `${n * 4}px`,
196
+ }
197
+
198
+ <ThemeProvider theme={theme}>
199
+ <App />
200
+ </ThemeProvider>
201
+ ```
202
+
203
+ Access the theme from any component:
204
+
205
+ ```tsx
206
+ const MyComponent = () => {
207
+ const theme = useTheme()
208
+ return <div style={{ color: theme.colors.primary }} />
209
+ }
210
+ ```
211
+
212
+ #### TypeScript theme augmentation
213
+
214
+ Extend `DefaultTheme` for strict typing across your app:
215
+
216
+ ```ts
217
+ declare module '@vitus-labs/styler' {
218
+ interface DefaultTheme {
219
+ colors: { primary: string; text: string }
220
+ spacing: (n: number) => string
221
+ }
222
+ }
223
+ ```
224
+
225
+ ### `sheet` & `createSheet`
226
+
227
+ The singleton `sheet` manages CSS rule injection. For SSR, use `createSheet` for per-request isolation:
228
+
229
+ ```tsx
230
+ import { createSheet } from '@vitus-labs/styler'
231
+
232
+ // Server-side rendering
233
+ const sheet = createSheet()
234
+ const html = renderToString(<App />)
235
+ const styleTags = sheet.getStyleTag() // <style data-vl="">...</style>
236
+ sheet.reset()
237
+ ```
238
+
239
+ #### `@layer` support
240
+
241
+ Wrap all scoped rules in a CSS Cascade Layer:
242
+
243
+ ```tsx
244
+ import { createSheet } from '@vitus-labs/styler'
245
+
246
+ const sheet = createSheet({ layer: 'components' })
247
+ ```
248
+
249
+ #### HMR cleanup
250
+
251
+ ```tsx
252
+ if (import.meta.hot) {
253
+ import.meta.hot.accept(() => {
254
+ sheet.clearAll()
255
+ })
256
+ }
257
+ ```
258
+
259
+ ## How It Works
260
+
261
+ ### Static path (zero runtime cost)
262
+
263
+ Templates with no function interpolations are resolved **once at component creation time**. The CSS class is computed and injected immediately, and the React component is a thin `forwardRef` wrapper with no hooks.
264
+
265
+ ```tsx
266
+ // Class computed once at import time, not on every render
267
+ const Box = styled('div')`
268
+ display: flex;
269
+ padding: 16px;
270
+ `
271
+ ```
272
+
273
+ ### Dynamic path
274
+
275
+ Templates with function interpolations resolve on every render. The engine caches the last CSS string and skips re-hashing and re-injection when props haven't changed. Style injection uses `useInsertionEffect` for concurrent-mode safety.
276
+
277
+ ### CSS Nesting
278
+
279
+ Native CSS nesting is supported out of the box. The engine passes CSS through without transformation, so `&:hover`, `&::before`, nested selectors, and `@media` queries work as-is in all modern browsers.
280
+
281
+ ```tsx
282
+ const Card = styled('div')`
283
+ padding: 16px;
284
+
285
+ &:hover {
286
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
287
+ }
288
+
289
+ & > h2 {
290
+ margin: 0 0 8px;
291
+ }
292
+
293
+ @media (min-width: 768px) {
294
+ padding: 24px;
295
+ }
296
+ `
297
+ ```
298
+
299
+ ## Migrating from styled-components
300
+
301
+ The API is intentionally compatible. Most code works by changing the import:
302
+
303
+ ```diff
304
+ - import styled, { css, keyframes, createGlobalStyle, ThemeProvider } from 'styled-components'
305
+ + import { styled, css, keyframes, createGlobalStyle, ThemeProvider } from '@vitus-labs/styler'
306
+ ```
307
+
308
+ Key differences:
309
+
310
+ | Feature | styled-components | @vitus-labs/styler |
311
+ |---------|------------------|-------------------|
312
+ | Bundle size | ~16 KB gz | **3.06 KB gz** |
313
+ | `styled.div` shorthand | Yes | Yes |
314
+ | `as` prop | Yes | Yes |
315
+ | Ref forwarding | Yes | Yes |
316
+ | Transient `$` props | Yes | Yes |
317
+ | `shouldForwardProp` | `.withConfig()` | Second argument |
318
+ | SSR | `ServerStyleSheet` | `createSheet()` |
319
+ | CSS nesting | Preprocessed | Native (no transform) |
320
+ | `attrs()` | Yes | Use `@vitus-labs/attrs` |
321
+
322
+ ## License
323
+
324
+ MIT
package/lib/index.d.ts ADDED
@@ -0,0 +1,160 @@
1
+ import * as react from "react";
2
+ import { ComponentType, FC, ReactNode } from "react";
3
+
4
+ //#region src/resolve.d.ts
5
+ /**
6
+ * Interpolation resolver: converts tagged template strings + values into a
7
+ * final CSS string. Handles nested CSSResults, arrays, functions, and
8
+ * primitive values.
9
+ */
10
+ type Interpolation = string | number | boolean | null | undefined | CSSResult | Interpolation[] | ((props: any) => Interpolation);
11
+ /**
12
+ * Lazy representation of a `css` tagged template. Stores the raw template
13
+ * strings and interpolation values without resolving them. Resolution is
14
+ * deferred until a styled component renders (or until explicitly resolved).
15
+ */
16
+ declare class CSSResult {
17
+ readonly strings: TemplateStringsArray;
18
+ readonly values: Interpolation[];
19
+ constructor(strings: TemplateStringsArray, values: Interpolation[]);
20
+ /** Resolve with empty props — useful for static templates, testing, and debugging. */
21
+ toString(): string;
22
+ }
23
+ //#endregion
24
+ //#region src/css.d.ts
25
+ /**
26
+ * Tagged template function for CSS. Captures the template strings and
27
+ * interpolation values as a lazy CSSResult — resolution is deferred
28
+ * until a styled component renders.
29
+ *
30
+ * Works as both a tagged template (`css\`...\``) and a regular function
31
+ * call (`css(...args)`) since tagged templates are syntactic sugar for
32
+ * function calls with (TemplateStringsArray, ...values).
33
+ */
34
+ declare const css: (strings: TemplateStringsArray, ...values: Interpolation[]) => CSSResult;
35
+ //#endregion
36
+ //#region src/globalStyle.d.ts
37
+ declare const createGlobalStyle: (strings: TemplateStringsArray, ...values: Interpolation[]) => {
38
+ (props: Record<string, any>): null;
39
+ displayName: string;
40
+ };
41
+ //#endregion
42
+ //#region src/keyframes.d.ts
43
+ declare class KeyframesResult {
44
+ readonly name: string;
45
+ constructor(strings: TemplateStringsArray, values: Interpolation[]);
46
+ /** Returns the animation name when used in string context. */
47
+ toString(): string;
48
+ }
49
+ declare const keyframes: (strings: TemplateStringsArray, ...values: Interpolation[]) => KeyframesResult;
50
+ //#endregion
51
+ //#region src/sheet.d.ts
52
+ interface StyleSheetOptions {
53
+ /** Maximum number of cached rules before eviction (default: 10000). */
54
+ maxCacheSize?: number;
55
+ /** CSS @layer name to wrap scoped rules in. */
56
+ layer?: string;
57
+ }
58
+ declare class StyleSheet {
59
+ private cache;
60
+ private sheet;
61
+ private ssrBuffer;
62
+ private isSSR;
63
+ private maxCacheSize;
64
+ private layer;
65
+ constructor(options?: StyleSheetOptions);
66
+ private mount;
67
+ /** Parse existing rules from SSR-rendered <style> tag into cache. */
68
+ private hydrateFromTag;
69
+ /** Evict oldest entries when cache exceeds max size. */
70
+ private evictIfNeeded;
71
+ /**
72
+ * Compute a className from CSS text without injecting (pure function for render phase).
73
+ * Used with useInsertionEffect pattern: compute class during render, inject in effect.
74
+ */
75
+ getClassName(cssText: string): string;
76
+ /**
77
+ * Insert a CSS rule. Returns the class name (deterministic, hash-based).
78
+ * Deduplicates: same CSS text always produces the same class name and
79
+ * the rule is only injected once.
80
+ */
81
+ insert(cssText: string): string;
82
+ /** Insert a @keyframes rule. Deduplicates by animation name. */
83
+ insertKeyframes(name: string, body: string): void;
84
+ /** Insert a global CSS rule (no wrapper selector). Deduplicates by hash. */
85
+ insertGlobal(cssText: string): void;
86
+ /** Returns collected CSS for SSR as a complete `<style>` tag string. */
87
+ getStyleTag(): string;
88
+ /** Returns collected CSS rules as a raw string (useful for streaming SSR). */
89
+ getStyles(): string;
90
+ /** Reset SSR buffer (for concurrent server requests). */
91
+ reset(): void;
92
+ /** Clear the dedup cache. Useful for HMR / dev-time reloads. */
93
+ clearCache(): void;
94
+ /**
95
+ * Full cleanup: clear cache and remove all CSS rules from the DOM.
96
+ * Intended for HMR / dev-time reloads where stale styles must be purged.
97
+ *
98
+ * if (import.meta.hot) {
99
+ * import.meta.hot.accept(() => sheet.clearAll())
100
+ * }
101
+ */
102
+ clearAll(): void;
103
+ /** Check if a className is already in the cache. O(1) Map lookup. */
104
+ has(className: string): boolean;
105
+ /** Current number of cached rules. */
106
+ get cacheSize(): number;
107
+ }
108
+ /** Default singleton sheet for client-side use. */
109
+ declare const sheet: StyleSheet;
110
+ /**
111
+ * Factory for creating isolated StyleSheet instances.
112
+ * Use in SSR to get per-request isolation:
113
+ *
114
+ * const sheet = createSheet()
115
+ * // render with this sheet...
116
+ * const html = sheet.getStyleTag()
117
+ * sheet.reset()
118
+ */
119
+ declare const createSheet: (options?: StyleSheetOptions) => StyleSheet;
120
+ //#endregion
121
+ //#region src/styled.d.ts
122
+ type Tag = string | ComponentType<any>;
123
+ interface StyledOptions {
124
+ /** Custom prop filter. Return true to forward the prop to the DOM element. */
125
+ shouldForwardProp?: (prop: string) => boolean;
126
+ }
127
+ /** Factory function: styled(tag) returns a tagged template function. */
128
+ declare const styledFactory: (tag: Tag, options?: StyledOptions) => (strings: TemplateStringsArray, ...values: Interpolation[]) => react.ForwardRefExoticComponent<Omit<Record<string, any>, "ref"> & react.RefAttributes<unknown>>;
129
+ /**
130
+ * Main styled export. Supports both calling conventions:
131
+ * - `styled('div')` or `styled(Component)` → returns tagged template function
132
+ * - `styled('div', { shouldForwardProp })` → with custom prop filtering
133
+ * - `styled.div` → shorthand via Proxy (no options)
134
+ */
135
+ declare const styled: typeof styledFactory & Record<string, (strings: TemplateStringsArray, ...values: Interpolation[]) => any>;
136
+ //#endregion
137
+ //#region src/ThemeProvider.d.ts
138
+ /**
139
+ * Extensible theme interface. Consumers can augment this via module
140
+ * declaration merging for full strict types:
141
+ *
142
+ * declare module '@vitus-labs/styler' {
143
+ * interface DefaultTheme {
144
+ * colors: { primary: string; secondary: string }
145
+ * spacing: (n: number) => string
146
+ * }
147
+ * }
148
+ */
149
+ type DefaultTheme = {};
150
+ type Theme = DefaultTheme & Record<string, unknown>;
151
+ /** Hook to read the current theme from the nearest ThemeProvider. */
152
+ declare const useTheme: <T extends Theme = Theme>() => T;
153
+ /** Provides a theme object to all nested styled components via React context. */
154
+ declare const ThemeProvider: FC<{
155
+ theme: Theme;
156
+ children: ReactNode;
157
+ }>;
158
+ //#endregion
159
+ export { type CSSResult, type DefaultTheme, type Interpolation, type StyleSheetOptions, type StyledOptions, ThemeProvider, createGlobalStyle, createSheet, css, keyframes, sheet, styled, useTheme };
160
+ //# sourceMappingURL=index2.d.ts.map
package/lib/index.js ADDED
@@ -0,0 +1,691 @@
1
+ import { Fragment, createContext, createElement, forwardRef, useContext, useInsertionEffect, useRef } from "react";
2
+ import { jsx } from "react/jsx-runtime";
3
+
4
+ //#region src/resolve.ts
5
+ /**
6
+ * Lazy representation of a `css` tagged template. Stores the raw template
7
+ * strings and interpolation values without resolving them. Resolution is
8
+ * deferred until a styled component renders (or until explicitly resolved).
9
+ */
10
+ var CSSResult = class {
11
+ constructor(strings, values) {
12
+ this.strings = strings;
13
+ this.values = values;
14
+ }
15
+ /** Resolve with empty props — useful for static templates, testing, and debugging. */
16
+ toString() {
17
+ return resolve(this.strings, this.values, {});
18
+ }
19
+ };
20
+ /** Resolve a tagged template's strings + values into a final CSS string. */
21
+ const resolve = (strings, values, props) => {
22
+ let result = strings[0] ?? "";
23
+ for (let i = 0; i < values.length; i++) result += resolveValue(values[i], props) + (strings[i + 1] ?? "");
24
+ return result;
25
+ };
26
+ /**
27
+ * Normalize resolved CSS text for strict `insertRule` compatibility.
28
+ * Removes double semicolons, empty declarations, and excess whitespace
29
+ * that arise from template interpolation (conditional expressions,
30
+ * empty CSSResult values, etc.).
31
+ */
32
+ const normalizeCSS = (css) => {
33
+ let s = css.replace(/\s+/g, " ").trim();
34
+ while (s.includes("; ;") || s.includes(";;")) s = s.replace(/;(\s*;)+/g, ";");
35
+ s = s.replace(/\{\s*;/g, "{").replace(/}\s*;/g, "}");
36
+ s = s.replace(/^\s*;/, "").trim();
37
+ return s;
38
+ };
39
+ const resolveValue = (value, props) => {
40
+ if (value == null || value === false || value === true) return "";
41
+ if (typeof value === "function") return resolveValue(value(props), props);
42
+ if (value instanceof CSSResult) return resolve(value.strings, value.values, props);
43
+ if (Array.isArray(value)) {
44
+ let arrayResult = "";
45
+ for (let i = 0; i < value.length; i++) arrayResult += resolveValue(value[i], props);
46
+ return arrayResult;
47
+ }
48
+ return String(value);
49
+ };
50
+
51
+ //#endregion
52
+ //#region src/css.ts
53
+ /**
54
+ * Tagged template function for CSS. Captures the template strings and
55
+ * interpolation values as a lazy CSSResult — resolution is deferred
56
+ * until a styled component renders.
57
+ *
58
+ * Works as both a tagged template (`css\`...\``) and a regular function
59
+ * call (`css(...args)`) since tagged templates are syntactic sugar for
60
+ * function calls with (TemplateStringsArray, ...values).
61
+ */
62
+ const css = (strings, ...values) => new CSSResult(strings, values);
63
+
64
+ //#endregion
65
+ //#region src/shared.ts
66
+ /**
67
+ * Shared utilities used across multiple modules.
68
+ */
69
+ /** Check if an interpolation value is dynamic (contains functions or nested dynamic CSSResults). */
70
+ const isDynamic = (v) => {
71
+ if (typeof v === "function") return true;
72
+ if (Array.isArray(v)) return v.some(isDynamic);
73
+ if (v instanceof CSSResult) return v.values.some(isDynamic);
74
+ return false;
75
+ };
76
+
77
+ //#endregion
78
+ //#region src/hash.ts
79
+ /**
80
+ * Fast FNV-1a non-cryptographic hash. Returns base-36 string for compact class names.
81
+ *
82
+ * 32-bit hash space → ~4.3 billion unique values. Collision probability is
83
+ * negligible for typical applications (< 10,000 unique CSS rules). If a collision
84
+ * did occur, two different CSS strings would share the same class name and only
85
+ * the first would be injected (dedup). In practice this is a non-issue — CSS
86
+ * strings are rarely similar enough to collide under FNV-1a's avalanche properties.
87
+ */
88
+ /** FNV-1a offset basis — starting state for streaming hash. */
89
+ const HASH_INIT = 2166136261;
90
+ const FNV_PRIME = 16777619;
91
+ /**
92
+ * Feed a string segment into the running hash state.
93
+ * Streaming: hashUpdate(hashUpdate(HASH_INIT, 'ab'), 'cd') === hash('abcd').
94
+ */
95
+ const hashUpdate = (h, str) => {
96
+ for (let i = 0; i < str.length; i++) {
97
+ h ^= str.charCodeAt(i);
98
+ h = Math.imul(h, FNV_PRIME);
99
+ }
100
+ return h;
101
+ };
102
+ /** Finalize a hash state into a base-36 class name suffix. */
103
+ const hashFinalize = (h) => (h >>> 0).toString(36);
104
+ /** Hash a complete string in one shot. Returns base-36 string. */
105
+ const hash = (str) => hashFinalize(hashUpdate(HASH_INIT, str));
106
+
107
+ //#endregion
108
+ //#region src/sheet.ts
109
+ /**
110
+ * StyleSheet manager. Handles CSS rule injection, hash-based deduplication,
111
+ * SSR buffering, client-side hydration, bounded cache, and @layer support.
112
+ */
113
+ const PREFIX = "vl";
114
+ const ATTR = `data-${PREFIX}`;
115
+ const DEFAULT_MAX_CACHE_SIZE = 1e4;
116
+ var StyleSheet = class {
117
+ cache = /* @__PURE__ */ new Map();
118
+ sheet = null;
119
+ ssrBuffer = [];
120
+ isSSR;
121
+ maxCacheSize;
122
+ layer;
123
+ constructor(options = {}) {
124
+ this.maxCacheSize = options.maxCacheSize ?? DEFAULT_MAX_CACHE_SIZE;
125
+ this.layer = options.layer;
126
+ this.isSSR = typeof document === "undefined";
127
+ if (!this.isSSR) this.mount();
128
+ }
129
+ mount() {
130
+ const existing = document.querySelector(`style[${ATTR}]`);
131
+ if (existing) {
132
+ this.sheet = existing.sheet ?? null;
133
+ this.hydrateFromTag(existing);
134
+ } else {
135
+ const el = document.createElement("style");
136
+ el.setAttribute(ATTR, "");
137
+ document.head.appendChild(el);
138
+ this.sheet = el.sheet ?? null;
139
+ }
140
+ if (this.layer && this.sheet) try {
141
+ this.sheet.insertRule(`@layer ${this.layer};`, 0);
142
+ } catch {}
143
+ }
144
+ /** Parse existing rules from SSR-rendered <style> tag into cache. */
145
+ hydrateFromTag(el) {
146
+ const sheet = el.sheet;
147
+ if (!sheet) return;
148
+ for (let i = 0; i < sheet.cssRules.length; i++) {
149
+ const rule = sheet.cssRules[i];
150
+ if (rule instanceof CSSStyleRule) {
151
+ const className = rule.selectorText.slice(1);
152
+ this.cache.set(className, className);
153
+ }
154
+ }
155
+ }
156
+ /** Evict oldest entries when cache exceeds max size. */
157
+ evictIfNeeded() {
158
+ if (this.cache.size <= this.maxCacheSize) return;
159
+ const toDelete = Math.floor(this.maxCacheSize * .1);
160
+ let count = 0;
161
+ for (const key of this.cache.keys()) {
162
+ if (count >= toDelete) break;
163
+ this.cache.delete(key);
164
+ count++;
165
+ }
166
+ }
167
+ /**
168
+ * Compute a className from CSS text without injecting (pure function for render phase).
169
+ * Used with useInsertionEffect pattern: compute class during render, inject in effect.
170
+ */
171
+ getClassName(cssText) {
172
+ return `${PREFIX}-${hash(cssText)}`;
173
+ }
174
+ /**
175
+ * Insert a CSS rule. Returns the class name (deterministic, hash-based).
176
+ * Deduplicates: same CSS text always produces the same class name and
177
+ * the rule is only injected once.
178
+ */
179
+ insert(cssText) {
180
+ const className = `${PREFIX}-${hash(cssText)}`;
181
+ if (this.cache.has(className)) return className;
182
+ this.evictIfNeeded();
183
+ this.cache.set(className, className);
184
+ const baseRule = `.${className}{${cssText}}`;
185
+ const rule = this.layer ? `@layer ${this.layer}{${baseRule}}` : baseRule;
186
+ if (this.isSSR) this.ssrBuffer.push(rule);
187
+ else if (this.sheet) try {
188
+ this.sheet.insertRule(rule, this.sheet.cssRules.length);
189
+ } catch (e) {
190
+ if (process.env.NODE_ENV !== "production") console.warn(`[styler] Failed to insert CSS rule for .${className}:`, e.message, "\nCSS:", cssText.slice(0, 200));
191
+ }
192
+ return className;
193
+ }
194
+ /** Insert a @keyframes rule. Deduplicates by animation name. */
195
+ insertKeyframes(name, body) {
196
+ if (this.cache.has(name)) return;
197
+ this.evictIfNeeded();
198
+ this.cache.set(name, name);
199
+ const rule = `@keyframes ${name}{${body}}`;
200
+ if (this.isSSR) this.ssrBuffer.push(rule);
201
+ else if (this.sheet) try {
202
+ this.sheet.insertRule(rule, this.sheet.cssRules.length);
203
+ } catch (e) {
204
+ if (process.env.NODE_ENV !== "production") console.warn(`[styler] Failed to insert @keyframes "${name}":`, e.message);
205
+ }
206
+ }
207
+ /** Insert a global CSS rule (no wrapper selector). Deduplicates by hash. */
208
+ insertGlobal(cssText) {
209
+ const key = `global-${hash(cssText)}`;
210
+ if (this.cache.has(key)) return;
211
+ this.evictIfNeeded();
212
+ this.cache.set(key, key);
213
+ if (this.isSSR) this.ssrBuffer.push(cssText);
214
+ else if (this.sheet) try {
215
+ this.sheet.insertRule(cssText, this.sheet.cssRules.length);
216
+ } catch (e) {
217
+ if (process.env.NODE_ENV !== "production") console.warn("[styler] Failed to insert global CSS rule:", e.message, "\nCSS:", cssText.slice(0, 200));
218
+ }
219
+ }
220
+ /** Returns collected CSS for SSR as a complete `<style>` tag string. */
221
+ getStyleTag() {
222
+ return `<style ${ATTR}="">${this.ssrBuffer.join("")}</style>`;
223
+ }
224
+ /** Returns collected CSS rules as a raw string (useful for streaming SSR). */
225
+ getStyles() {
226
+ return this.ssrBuffer.join("");
227
+ }
228
+ /** Reset SSR buffer (for concurrent server requests). */
229
+ reset() {
230
+ this.ssrBuffer = [];
231
+ }
232
+ /** Clear the dedup cache. Useful for HMR / dev-time reloads. */
233
+ clearCache() {
234
+ this.cache.clear();
235
+ }
236
+ /**
237
+ * Full cleanup: clear cache and remove all CSS rules from the DOM.
238
+ * Intended for HMR / dev-time reloads where stale styles must be purged.
239
+ *
240
+ * if (import.meta.hot) {
241
+ * import.meta.hot.accept(() => sheet.clearAll())
242
+ * }
243
+ */
244
+ clearAll() {
245
+ this.cache.clear();
246
+ this.ssrBuffer = [];
247
+ if (this.sheet) while (this.sheet.cssRules.length > 0) this.sheet.deleteRule(0);
248
+ }
249
+ /** Check if a className is already in the cache. O(1) Map lookup. */
250
+ has(className) {
251
+ return this.cache.has(className);
252
+ }
253
+ /** Current number of cached rules. */
254
+ get cacheSize() {
255
+ return this.cache.size;
256
+ }
257
+ };
258
+ /** Default singleton sheet for client-side use. */
259
+ const sheet = new StyleSheet();
260
+ /**
261
+ * Factory for creating isolated StyleSheet instances.
262
+ * Use in SSR to get per-request isolation:
263
+ *
264
+ * const sheet = createSheet()
265
+ * // render with this sheet...
266
+ * const html = sheet.getStyleTag()
267
+ * sheet.reset()
268
+ */
269
+ const createSheet = (options) => new StyleSheet(options);
270
+
271
+ //#endregion
272
+ //#region src/ThemeProvider.tsx
273
+ const ThemeContext = createContext({});
274
+ /** Hook to read the current theme from the nearest ThemeProvider. */
275
+ const useTheme = () => useContext(ThemeContext);
276
+ /** Provides a theme object to all nested styled components via React context. */
277
+ const ThemeProvider = ({ theme, children }) => /* @__PURE__ */ jsx(ThemeContext.Provider, {
278
+ value: theme,
279
+ children
280
+ });
281
+
282
+ //#endregion
283
+ //#region src/globalStyle.ts
284
+ /**
285
+ * createGlobalStyle() — tagged template function that injects global CSS
286
+ * rules (not scoped to a class name). Returns a React component that
287
+ * injects styles when mounted and supports dynamic interpolations via
288
+ * props/theme.
289
+ *
290
+ * Usage:
291
+ * const GlobalStyle = createGlobalStyle`
292
+ * body { margin: 0; font-family: ${({ theme }) => theme.font}; }
293
+ * *, *::before, *::after { box-sizing: border-box; }
294
+ * `
295
+ * // <GlobalStyle /> — renders nothing, injects global CSS
296
+ *
297
+ * Approach inspired by Goober's glob() + styled-components' API:
298
+ * - Static templates: inject once at component creation (no React overhead)
299
+ * - Dynamic templates: re-inject on prop/theme change via useInsertionEffect
300
+ * - Hash-based dedup prevents duplicate injection
301
+ */
302
+ const createGlobalStyle = (strings, ...values) => {
303
+ if (!values.some(isDynamic)) {
304
+ const cssText = normalizeCSS(resolve(strings, values, {}));
305
+ if (cssText.trim()) sheet.insertGlobal(cssText);
306
+ const StaticGlobal = () => null;
307
+ StaticGlobal.displayName = "GlobalStyle";
308
+ return StaticGlobal;
309
+ }
310
+ const DynamicGlobal = (props) => {
311
+ const theme = useTheme();
312
+ const cssText = normalizeCSS(resolve(strings, values, {
313
+ ...props,
314
+ theme
315
+ }));
316
+ const prevCssRef = useRef("");
317
+ useInsertionEffect(() => {
318
+ if (cssText !== prevCssRef.current) {
319
+ prevCssRef.current = cssText;
320
+ if (cssText.trim()) sheet.insertGlobal(cssText);
321
+ }
322
+ });
323
+ return null;
324
+ };
325
+ DynamicGlobal.displayName = "GlobalStyle";
326
+ return DynamicGlobal;
327
+ };
328
+
329
+ //#endregion
330
+ //#region src/keyframes.ts
331
+ /**
332
+ * keyframes() tagged template function. Creates a CSS @keyframes rule,
333
+ * injects it into the stylesheet, and returns the generated animation name.
334
+ *
335
+ * Usage:
336
+ * const fadeIn = keyframes`
337
+ * from { opacity: 0; }
338
+ * to { opacity: 1; }
339
+ * `
340
+ * // fadeIn === "vl-kf-abc123" (deterministic, hash-based)
341
+ *
342
+ * Supports interpolation values (strings, numbers) but NOT function
343
+ * interpolations — keyframes are static by nature.
344
+ */
345
+ var KeyframesResult = class {
346
+ name;
347
+ constructor(strings, values) {
348
+ const body = normalizeCSS(resolve(strings, values, {}));
349
+ this.name = `vl-kf-${hash(body)}`;
350
+ sheet.insertKeyframes(this.name, body);
351
+ }
352
+ /** Returns the animation name when used in string context. */
353
+ toString() {
354
+ return this.name;
355
+ }
356
+ };
357
+ const keyframes = (strings, ...values) => new KeyframesResult(strings, values);
358
+
359
+ //#endregion
360
+ //#region src/forward.ts
361
+ /**
362
+ * HTML prop filtering. Prevents unknown props from being forwarded to DOM
363
+ * elements (which causes React warnings). Props starting with `$` are
364
+ * transient (styling-only) and are always filtered out.
365
+ */
366
+ const HTML_PROPS = new Set([
367
+ "children",
368
+ "className",
369
+ "dangerouslySetInnerHTML",
370
+ "htmlFor",
371
+ "id",
372
+ "key",
373
+ "ref",
374
+ "style",
375
+ "tabIndex",
376
+ "role",
377
+ "onAbort",
378
+ "onAnimationEnd",
379
+ "onAnimationIteration",
380
+ "onAnimationStart",
381
+ "onBlur",
382
+ "onChange",
383
+ "onClick",
384
+ "onCompositionEnd",
385
+ "onCompositionStart",
386
+ "onCompositionUpdate",
387
+ "onContextMenu",
388
+ "onCopy",
389
+ "onCut",
390
+ "onDoubleClick",
391
+ "onDrag",
392
+ "onDragEnd",
393
+ "onDragEnter",
394
+ "onDragLeave",
395
+ "onDragOver",
396
+ "onDragStart",
397
+ "onDrop",
398
+ "onError",
399
+ "onFocus",
400
+ "onInput",
401
+ "onKeyDown",
402
+ "onKeyPress",
403
+ "onKeyUp",
404
+ "onLoad",
405
+ "onMouseDown",
406
+ "onMouseEnter",
407
+ "onMouseLeave",
408
+ "onMouseMove",
409
+ "onMouseOut",
410
+ "onMouseOver",
411
+ "onMouseUp",
412
+ "onPaste",
413
+ "onPointerCancel",
414
+ "onPointerDown",
415
+ "onPointerEnter",
416
+ "onPointerLeave",
417
+ "onPointerMove",
418
+ "onPointerOut",
419
+ "onPointerOver",
420
+ "onPointerUp",
421
+ "onScroll",
422
+ "onSelect",
423
+ "onSubmit",
424
+ "onTouchCancel",
425
+ "onTouchEnd",
426
+ "onTouchMove",
427
+ "onTouchStart",
428
+ "onTransitionEnd",
429
+ "onWheel",
430
+ "accept",
431
+ "acceptCharset",
432
+ "accessKey",
433
+ "action",
434
+ "allow",
435
+ "allowFullScreen",
436
+ "alt",
437
+ "as",
438
+ "async",
439
+ "autoCapitalize",
440
+ "autoComplete",
441
+ "autoCorrect",
442
+ "autoFocus",
443
+ "autoPlay",
444
+ "capture",
445
+ "cellPadding",
446
+ "cellSpacing",
447
+ "charSet",
448
+ "checked",
449
+ "cite",
450
+ "cols",
451
+ "colSpan",
452
+ "content",
453
+ "contentEditable",
454
+ "controls",
455
+ "controlsList",
456
+ "coords",
457
+ "crossOrigin",
458
+ "dateTime",
459
+ "decoding",
460
+ "default",
461
+ "defaultChecked",
462
+ "defaultValue",
463
+ "defer",
464
+ "dir",
465
+ "disabled",
466
+ "disablePictureInPicture",
467
+ "disableRemotePlayback",
468
+ "download",
469
+ "draggable",
470
+ "encType",
471
+ "enterKeyHint",
472
+ "fetchPriority",
473
+ "form",
474
+ "formAction",
475
+ "formEncType",
476
+ "formMethod",
477
+ "formNoValidate",
478
+ "formTarget",
479
+ "frameBorder",
480
+ "headers",
481
+ "height",
482
+ "hidden",
483
+ "high",
484
+ "href",
485
+ "hrefLang",
486
+ "httpEquiv",
487
+ "inputMode",
488
+ "integrity",
489
+ "is",
490
+ "label",
491
+ "lang",
492
+ "list",
493
+ "loading",
494
+ "loop",
495
+ "low",
496
+ "max",
497
+ "maxLength",
498
+ "media",
499
+ "method",
500
+ "min",
501
+ "minLength",
502
+ "multiple",
503
+ "muted",
504
+ "name",
505
+ "noModule",
506
+ "noValidate",
507
+ "nonce",
508
+ "open",
509
+ "optimum",
510
+ "pattern",
511
+ "placeholder",
512
+ "playsInline",
513
+ "poster",
514
+ "preload",
515
+ "readOnly",
516
+ "referrerPolicy",
517
+ "rel",
518
+ "required",
519
+ "reversed",
520
+ "rows",
521
+ "rowSpan",
522
+ "sandbox",
523
+ "scope",
524
+ "scoped",
525
+ "scrolling",
526
+ "selected",
527
+ "shape",
528
+ "size",
529
+ "sizes",
530
+ "slot",
531
+ "span",
532
+ "spellCheck",
533
+ "src",
534
+ "srcDoc",
535
+ "srcLang",
536
+ "srcSet",
537
+ "start",
538
+ "step",
539
+ "summary",
540
+ "target",
541
+ "title",
542
+ "translate",
543
+ "type",
544
+ "useMap",
545
+ "value",
546
+ "width",
547
+ "wrap"
548
+ ]);
549
+ /**
550
+ * Filters props for HTML elements. Keeps valid HTML attrs, data-*, aria-*.
551
+ * Rejects unknown props and $-prefixed transient props.
552
+ */
553
+ const filterProps = (props) => {
554
+ const filtered = {};
555
+ for (const key in props) {
556
+ if (key.charCodeAt(0) === 36) continue;
557
+ if (key === "as") continue;
558
+ if (key.startsWith("data-") || key.startsWith("aria-")) {
559
+ filtered[key] = props[key];
560
+ continue;
561
+ }
562
+ if (HTML_PROPS.has(key)) filtered[key] = props[key];
563
+ }
564
+ return filtered;
565
+ };
566
+
567
+ //#endregion
568
+ //#region src/styled.ts
569
+ /**
570
+ * styled() component factory. Creates React components that inject CSS
571
+ * class names from tagged template literals.
572
+ *
573
+ * Supports:
574
+ * - styled('div')`...` and styled(Component)`...`
575
+ * - styled.div`...` (via Proxy)
576
+ * - `as` prop for polymorphic rendering
577
+ * - forwardRef for ref forwarding
578
+ * - $-prefixed transient props (not forwarded to DOM)
579
+ * - Custom shouldForwardProp for per-component prop filtering
580
+ * - Static path optimization (templates with no dynamic interpolations)
581
+ * - useInsertionEffect for safe concurrent-mode style injection
582
+ *
583
+ * CSS nesting (`&` selectors) works natively — the resolver passes CSS
584
+ * through without transformation, so `&:hover`, `&::before`, etc. work
585
+ * as-is in browsers supporting CSS Nesting (all modern browsers).
586
+ */
587
+ const IS_SERVER = typeof document === "undefined";
588
+ const getDisplayName = (tag) => typeof tag === "string" ? tag : tag.displayName || tag.name || "Component";
589
+ const mergeClassNames = (generated, user) => {
590
+ if (!user) return generated;
591
+ if (!generated) return user;
592
+ return `${generated} ${user}`;
593
+ };
594
+ const applyPropFilter = (props, customFilter) => {
595
+ if (customFilter) {
596
+ const filtered = {};
597
+ for (const key in props) if (customFilter(key)) filtered[key] = props[key];
598
+ return filtered;
599
+ }
600
+ return filterProps(props);
601
+ };
602
+ const createStyledComponent = (tag, strings, values, options) => {
603
+ const hasDynamicValues = values.some(isDynamic);
604
+ const customFilter = options?.shouldForwardProp;
605
+ if (!hasDynamicValues) {
606
+ const cssText = normalizeCSS(resolve(strings, values, {}));
607
+ const staticClassName = cssText.trim() ? sheet.insert(cssText) : "";
608
+ const staticRule = staticClassName ? `.${staticClassName}{${cssText}}` : "";
609
+ const StaticStyled = forwardRef(({ as: asProp, className: userCls, ...props }, ref) => {
610
+ const finalTag = asProp || tag;
611
+ const finalCls = mergeClassNames(staticClassName, userCls);
612
+ const el = createElement(finalTag, {
613
+ ...typeof finalTag === "string" ? applyPropFilter(props, customFilter) : props,
614
+ className: finalCls || void 0,
615
+ ref
616
+ });
617
+ if (staticRule) return createElement(Fragment, null, createElement("style", {
618
+ "data-vl": "",
619
+ dangerouslySetInnerHTML: { __html: staticRule }
620
+ }), el);
621
+ return el;
622
+ });
623
+ StaticStyled.displayName = `styled(${getDisplayName(tag)})`;
624
+ return StaticStyled;
625
+ }
626
+ const DynamicStyled = forwardRef(({ as: asProp, className: userCls, ...props }, ref) => {
627
+ const theme = useTheme();
628
+ const cssText = normalizeCSS(resolve(strings, values, {
629
+ ...props,
630
+ theme
631
+ }));
632
+ const lastCssRef = useRef("");
633
+ const lastClsRef = useRef("");
634
+ const insertedRef = useRef(false);
635
+ let className;
636
+ if (cssText === lastCssRef.current) className = lastClsRef.current;
637
+ else {
638
+ className = cssText.trim() ? sheet.getClassName(cssText) : "";
639
+ lastCssRef.current = cssText;
640
+ lastClsRef.current = className;
641
+ insertedRef.current = false;
642
+ }
643
+ if (IS_SERVER) {
644
+ if (!insertedRef.current && cssText.trim()) {
645
+ sheet.insert(cssText);
646
+ insertedRef.current = true;
647
+ }
648
+ }
649
+ const mountedRef = useRef(false);
650
+ useInsertionEffect(() => {
651
+ mountedRef.current = true;
652
+ if (!insertedRef.current && lastCssRef.current.trim()) {
653
+ sheet.insert(lastCssRef.current);
654
+ insertedRef.current = true;
655
+ }
656
+ });
657
+ const finalTag = asProp || tag;
658
+ const finalCls = mergeClassNames(className, userCls);
659
+ const el = createElement(finalTag, {
660
+ ...typeof finalTag === "string" ? applyPropFilter(props, customFilter) : props,
661
+ className: finalCls || void 0,
662
+ ref
663
+ });
664
+ if (!mountedRef.current && className) return createElement(Fragment, null, createElement("style", {
665
+ "data-vl": "",
666
+ dangerouslySetInnerHTML: { __html: `.${className}{${cssText}}` }
667
+ }), el);
668
+ return el;
669
+ });
670
+ DynamicStyled.displayName = `styled(${getDisplayName(tag)})`;
671
+ return DynamicStyled;
672
+ };
673
+ /** Factory function: styled(tag) returns a tagged template function. */
674
+ const styledFactory = (tag, options) => {
675
+ const templateFn = (strings, ...values) => createStyledComponent(tag, strings, values, options);
676
+ return templateFn;
677
+ };
678
+ /**
679
+ * Main styled export. Supports both calling conventions:
680
+ * - `styled('div')` or `styled(Component)` → returns tagged template function
681
+ * - `styled('div', { shouldForwardProp })` → with custom prop filtering
682
+ * - `styled.div` → shorthand via Proxy (no options)
683
+ */
684
+ const styled = new Proxy(styledFactory, { get(_target, prop) {
685
+ if (prop === "prototype" || prop === "$$typeof") return void 0;
686
+ return (strings, ...values) => createStyledComponent(prop, strings, values);
687
+ } });
688
+
689
+ //#endregion
690
+ export { ThemeProvider, createGlobalStyle, createSheet, css, keyframes, sheet, styled, useTheme };
691
+ //# sourceMappingURL=index.js.map
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@vitus-labs/styler",
3
+ "version": "2.0.0-alpha.0",
4
+ "license": "MIT",
5
+ "author": "Vit Bokisch <vit@bokisch.cz>",
6
+ "maintainers": [
7
+ "Vit Bokisch <vit@bokisch.cz>"
8
+ ],
9
+ "type": "module",
10
+ "sideEffects": false,
11
+ "exports": {
12
+ "source": "./src/index.ts",
13
+ "import": "./lib/index.js",
14
+ "types": "./lib/index.d.ts"
15
+ },
16
+ "types": "./lib/index.d.ts",
17
+ "files": [
18
+ "lib",
19
+ "!lib/**/*.map",
20
+ "!lib/analysis"
21
+ ],
22
+ "homepage": "https://github.com/vitus-labs/ui-system/tree/master/packages/styler",
23
+ "description": "Lightweight CSS-in-JS engine for vitus-labs packages",
24
+ "keywords": [
25
+ "vitus-labs",
26
+ "css-in-js",
27
+ "styled-components",
28
+ "css",
29
+ "styling"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/vitus-labs/ui-system",
37
+ "directory": "packages/styler"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/vitus-labs/ui-system/issues"
41
+ },
42
+ "engines": {
43
+ "node": ">= 18"
44
+ },
45
+ "scripts": {
46
+ "prepublish": "bun run build",
47
+ "build": "bun run vl_rolldown_build",
48
+ "build:watch": "bun run vl_rolldown_build-watch",
49
+ "lint": "biome check src/",
50
+ "test": "vitest run",
51
+ "test:coverage": "vitest run --coverage",
52
+ "test:watch": "vitest",
53
+ "typecheck": "tsc --noEmit"
54
+ },
55
+ "peerDependencies": {
56
+ "react": ">= 18"
57
+ },
58
+ "devDependencies": {
59
+ "@emotion/react": "^11.14.0",
60
+ "@emotion/styled": "^11.14.1",
61
+ "@vitus-labs/tools-rolldown": "^1.6.0",
62
+ "@vitus-labs/tools-typescript": "^1.6.0",
63
+ "goober": "^2.1.18",
64
+ "styled-components": "^6.3.9"
65
+ },
66
+ "gitHead": "f2221c6fe2db3e39bfe1c30dc7ff0c01e2c66dc5"
67
+ }