stoop 0.6.1 → 0.6.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.
@@ -0,0 +1,199 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ /**
4
+ * Theme Provider component and hook.
5
+ * Manages theme state, localStorage persistence, cookie sync, and centralized theme variable updates.
6
+ * Includes the useTheme hook for accessing theme management context.
7
+ *
8
+ * CLIENT-ONLY: This module uses React hooks (useState, useLayoutEffect, useCallback, useMemo)
9
+ * and MUST have "use client" directive for Next.js App Router compatibility.
10
+ */
11
+ import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState, } from "react";
12
+ import { injectAllThemes } from "../core/theme-manager";
13
+ import { isBrowser, isProduction } from "../utils/helpers";
14
+ import { getCookie, setCookie, getFromStorage, setInStorage } from "../utils/storage";
15
+ /**
16
+ * Syncs a theme value between cookie and localStorage.
17
+ *
18
+ * @param value - Theme value to sync
19
+ * @param cookieKey - Cookie key (if undefined, cookie sync is skipped)
20
+ * @param storageKey - LocalStorage key
21
+ */
22
+ function syncThemeStorage(value, cookieKey, storageKey) {
23
+ if (!isBrowser()) {
24
+ return;
25
+ }
26
+ const cookieValue = cookieKey ? getCookie(cookieKey) : null;
27
+ const localStorageResult = getFromStorage(storageKey);
28
+ const localStorageValue = localStorageResult.success ? localStorageResult.value : null;
29
+ if (cookieValue === value && localStorageValue !== value) {
30
+ setInStorage(storageKey, value);
31
+ }
32
+ if (localStorageValue === value && cookieKey && cookieValue !== value) {
33
+ setCookie(cookieKey, value);
34
+ }
35
+ }
36
+ /**
37
+ * Reads theme from cookie or localStorage, preferring cookie if available.
38
+ *
39
+ * @param cookieKey - Cookie key (if undefined, cookie is not checked)
40
+ * @param storageKey - LocalStorage key
41
+ * @param themes - Available themes map for validation
42
+ * @returns Theme name or null if not found or invalid
43
+ */
44
+ function readThemeFromStorage(cookieKey, storageKey, themes) {
45
+ if (!isBrowser()) {
46
+ return null;
47
+ }
48
+ if (cookieKey !== undefined) {
49
+ const cookieValue = getCookie(cookieKey);
50
+ if (cookieValue && themes[cookieValue]) {
51
+ return cookieValue;
52
+ }
53
+ }
54
+ const storageResult = getFromStorage(storageKey);
55
+ const stored = storageResult.success ? storageResult.value : null;
56
+ if (stored && themes[stored]) {
57
+ return stored;
58
+ }
59
+ return null;
60
+ }
61
+ /**
62
+ * Creates a Provider component for theme management.
63
+ *
64
+ * @param themes - Map of theme names to theme objects
65
+ * @param defaultTheme - Default theme object
66
+ * @param prefix - Optional prefix for CSS variable scoping
67
+ * @param globalCss - Optional global CSS object from config
68
+ * @param globalCssFunction - Optional globalCss function from createStoop
69
+ * @returns Provider component, theme context, and theme management context
70
+ *
71
+ * @remarks
72
+ * To prevent FOUC (Flash of Unstyled Content) when a user has a non-default theme stored,
73
+ * call `preloadTheme()` from your stoop instance in a script tag before React hydrates.
74
+ * Note: `preloadTheme()` takes no parameters and always preloads all configured themes.
75
+ *
76
+ * ```html
77
+ * <script>
78
+ * // Preload all themes before React renders
79
+ * stoopInstance.preloadTheme();
80
+ * </script>
81
+ * ```
82
+ */
83
+ export function createProvider(themes, defaultTheme, prefix = "stoop", globalCss, globalCssFunction) {
84
+ const ThemeContext = createContext(null);
85
+ const ThemeManagementContext = createContext(null);
86
+ const availableThemeNames = Object.keys(themes);
87
+ const firstThemeName = availableThemeNames[0] || "default";
88
+ const configGlobalStyles = globalCss && globalCssFunction ? globalCssFunction(globalCss) : undefined;
89
+ function Provider({ attribute = "data-theme", children, cookieKey, defaultTheme: defaultThemeProp, storageKey = "stoop-theme", }) {
90
+ const [themeName, setThemeNameState] = useState(defaultThemeProp || firstThemeName);
91
+ const hasHydratedRef = useRef(false);
92
+ useLayoutEffect(() => {
93
+ if (!isBrowser() || hasHydratedRef.current) {
94
+ return;
95
+ }
96
+ const stored = readThemeFromStorage(cookieKey, storageKey, themes);
97
+ if (stored) {
98
+ syncThemeStorage(stored, cookieKey, storageKey);
99
+ if (stored !== themeName) {
100
+ setThemeNameState(stored);
101
+ }
102
+ }
103
+ hasHydratedRef.current = true;
104
+ }, [cookieKey, storageKey, themes]);
105
+ useLayoutEffect(() => {
106
+ if (!isBrowser()) {
107
+ return;
108
+ }
109
+ const handleStorageChange = (e) => {
110
+ if (e.key === storageKey && e.newValue && themes[e.newValue] && e.newValue !== themeName) {
111
+ setThemeNameState(e.newValue);
112
+ syncThemeStorage(e.newValue, cookieKey, storageKey);
113
+ }
114
+ };
115
+ window.addEventListener("storage", handleStorageChange);
116
+ return () => {
117
+ window.removeEventListener("storage", handleStorageChange);
118
+ };
119
+ }, [storageKey, cookieKey, themeName, themes]);
120
+ const currentTheme = useMemo(() => {
121
+ return themes[themeName] || themes[defaultThemeProp || firstThemeName] || defaultTheme;
122
+ }, [themeName, defaultThemeProp, firstThemeName, themes, defaultTheme]);
123
+ const themesInjectedRef = useRef(false);
124
+ const globalStylesInjectedRef = useRef(false);
125
+ useLayoutEffect(() => {
126
+ if (!isBrowser() || themesInjectedRef.current) {
127
+ return;
128
+ }
129
+ injectAllThemes(themes, prefix, attribute);
130
+ themesInjectedRef.current = true;
131
+ }, [themes, prefix, attribute]);
132
+ useLayoutEffect(() => {
133
+ if (!isBrowser() || globalStylesInjectedRef.current) {
134
+ return;
135
+ }
136
+ if (configGlobalStyles) {
137
+ configGlobalStyles();
138
+ globalStylesInjectedRef.current = true;
139
+ }
140
+ }, [configGlobalStyles]);
141
+ useLayoutEffect(() => {
142
+ if (!isBrowser()) {
143
+ return;
144
+ }
145
+ if (attribute) {
146
+ document.documentElement.setAttribute(attribute, themeName);
147
+ }
148
+ }, [themeName, attribute]);
149
+ const setTheme = useCallback((newThemeName) => {
150
+ if (themes[newThemeName]) {
151
+ setThemeNameState(newThemeName);
152
+ setInStorage(storageKey, newThemeName);
153
+ syncThemeStorage(newThemeName, cookieKey, storageKey);
154
+ }
155
+ else if (!isProduction()) {
156
+ // eslint-disable-next-line no-console
157
+ console.warn(`[Stoop] Theme "${newThemeName}" not found. Available themes: ${availableThemeNames.join(", ")}`);
158
+ }
159
+ }, [storageKey, cookieKey, themes, availableThemeNames, themeName]);
160
+ const themeContextValue = useMemo(() => ({
161
+ theme: currentTheme,
162
+ themeName,
163
+ }), [currentTheme, themeName]);
164
+ const toggleTheme = useCallback(() => {
165
+ const currentIndex = availableThemeNames.indexOf(themeName);
166
+ const nextIndex = (currentIndex + 1) % availableThemeNames.length;
167
+ const newTheme = availableThemeNames[nextIndex];
168
+ setTheme(newTheme);
169
+ }, [themeName, setTheme, availableThemeNames]);
170
+ const managementContextValue = useMemo(() => ({
171
+ availableThemes: availableThemeNames,
172
+ setTheme,
173
+ theme: currentTheme,
174
+ themeName,
175
+ toggleTheme,
176
+ }), [currentTheme, themeName, setTheme, toggleTheme]);
177
+ return (_jsx(ThemeContext.Provider, { value: themeContextValue, children: _jsx(ThemeManagementContext.Provider, { value: managementContextValue, children: children }) }));
178
+ }
179
+ return {
180
+ Provider,
181
+ ThemeContext,
182
+ ThemeManagementContext,
183
+ };
184
+ }
185
+ /**
186
+ * Creates a useTheme hook for a specific theme management context.
187
+ *
188
+ * @param ThemeManagementContext - React context for theme management
189
+ * @returns Hook function that returns theme management context value
190
+ */
191
+ export function createUseThemeHook(ThemeManagementContext) {
192
+ return function useTheme() {
193
+ const context = useContext(ThemeManagementContext);
194
+ if (!context) {
195
+ throw new Error("useTheme must be used within a Provider");
196
+ }
197
+ return context;
198
+ };
199
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Shared constants used throughout the library.
3
+ * Includes cache size limits and nesting depth limits.
4
+ */
5
+ export const EMPTY_CSS = Object.freeze({});
6
+ export const MAX_CSS_CACHE_SIZE = 10000;
7
+ export const MAX_CLASS_NAME_CACHE_SIZE = 5000;
8
+ export const MAX_CSS_NESTING_DEPTH = 10;
9
+ /**
10
+ * Cache size limits for various LRU caches.
11
+ */
12
+ export const KEYFRAME_CACHE_LIMIT = 500;
13
+ export const SANITIZE_CACHE_SIZE_LIMIT = 1000;
14
+ /**
15
+ * Cookie defaults.
16
+ */
17
+ export const DEFAULT_COOKIE_MAX_AGE = 31536000; // 1 year in seconds
18
+ export const DEFAULT_COOKIE_PATH = "/";
19
+ /**
20
+ * Approved theme scales - only these scales are allowed in theme objects.
21
+ */
22
+ export const APPROVED_THEME_SCALES = [
23
+ "colors",
24
+ "opacities",
25
+ "space",
26
+ "radii",
27
+ "sizes",
28
+ "fonts",
29
+ "fontWeights",
30
+ "fontSizes",
31
+ "lineHeights",
32
+ "letterSpacings",
33
+ "shadows",
34
+ "zIndices",
35
+ "transitions",
36
+ ];
37
+ /**
38
+ * Default themeMap mapping CSS properties to theme scales.
39
+ * Covers common CSS properties for zero-config experience.
40
+ * Missing properties gracefully fallback to pattern-based auto-detection.
41
+ */
42
+ export const DEFAULT_THEME_MAP = {
43
+ accentColor: "colors",
44
+ animation: "transitions",
45
+ animationDelay: "transitions",
46
+ animationDuration: "transitions",
47
+ animationTimingFunction: "transitions",
48
+ backdropFilter: "shadows",
49
+ background: "colors",
50
+ backgroundColor: "colors",
51
+ blockSize: "sizes",
52
+ border: "colors",
53
+ borderBlockColor: "colors",
54
+ borderBlockEndColor: "colors",
55
+ borderBlockStartColor: "colors",
56
+ borderBottomColor: "colors",
57
+ borderBottomLeftRadius: "radii",
58
+ borderBottomRightRadius: "radii",
59
+ borderColor: "colors",
60
+ borderEndEndRadius: "radii",
61
+ borderEndStartRadius: "radii",
62
+ borderInlineColor: "colors",
63
+ borderInlineEndColor: "colors",
64
+ borderInlineStartColor: "colors",
65
+ borderLeftColor: "colors",
66
+ borderRadius: "radii",
67
+ borderRightColor: "colors",
68
+ borderStartEndRadius: "radii",
69
+ borderStartStartRadius: "radii",
70
+ borderTopColor: "colors",
71
+ borderTopLeftRadius: "radii",
72
+ borderTopRightRadius: "radii",
73
+ bottom: "space",
74
+ boxShadow: "shadows",
75
+ caretColor: "colors",
76
+ color: "colors",
77
+ columnGap: "space",
78
+ columnRuleColor: "colors",
79
+ fill: "colors",
80
+ filter: "shadows",
81
+ flexBasis: "sizes",
82
+ floodColor: "colors",
83
+ font: "fontSizes",
84
+ fontFamily: "fonts",
85
+ fontSize: "fontSizes",
86
+ fontWeight: "fontWeights",
87
+ gap: "space",
88
+ gridColumnGap: "space",
89
+ gridGap: "space",
90
+ gridRowGap: "space",
91
+ height: "sizes",
92
+ inlineSize: "sizes",
93
+ inset: "space",
94
+ insetBlock: "space",
95
+ insetBlockEnd: "space",
96
+ insetBlockStart: "space",
97
+ insetInline: "space",
98
+ insetInlineEnd: "space",
99
+ insetInlineStart: "space",
100
+ left: "space",
101
+ letterSpacing: "letterSpacings",
102
+ lightingColor: "colors",
103
+ lineHeight: "lineHeights",
104
+ margin: "space",
105
+ marginBlock: "space",
106
+ marginBlockEnd: "space",
107
+ marginBlockStart: "space",
108
+ marginBottom: "space",
109
+ marginInline: "space",
110
+ marginInlineEnd: "space",
111
+ marginInlineStart: "space",
112
+ marginLeft: "space",
113
+ marginRight: "space",
114
+ marginTop: "space",
115
+ maxBlockSize: "sizes",
116
+ maxHeight: "sizes",
117
+ maxInlineSize: "sizes",
118
+ maxWidth: "sizes",
119
+ minBlockSize: "sizes",
120
+ minHeight: "sizes",
121
+ minInlineSize: "sizes",
122
+ minWidth: "sizes",
123
+ opacity: "opacities",
124
+ outline: "colors",
125
+ outlineColor: "colors",
126
+ padding: "space",
127
+ paddingBlock: "space",
128
+ paddingBlockEnd: "space",
129
+ paddingBlockStart: "space",
130
+ paddingBottom: "space",
131
+ paddingInline: "space",
132
+ paddingInlineEnd: "space",
133
+ paddingInlineStart: "space",
134
+ paddingLeft: "space",
135
+ paddingRight: "space",
136
+ paddingTop: "space",
137
+ right: "space",
138
+ rowGap: "space",
139
+ size: "sizes",
140
+ stopColor: "colors",
141
+ stroke: "colors",
142
+ textDecorationColor: "colors",
143
+ textEmphasisColor: "colors",
144
+ textShadow: "shadows",
145
+ top: "space",
146
+ transition: "transitions",
147
+ transitionDelay: "transitions",
148
+ transitionDuration: "transitions",
149
+ transitionProperty: "transitions",
150
+ transitionTimingFunction: "transitions",
151
+ width: "sizes",
152
+ zIndex: "zIndices",
153
+ };
154
+ export const STOOP_COMPONENT_SYMBOL = Symbol.for("stoop.component");
@@ -0,0 +1,66 @@
1
+ /**
2
+ * CSS compilation caching system.
3
+ * Tracks compiled CSS strings and class names to prevent duplicate work.
4
+ * Implements LRU (Least Recently Used) eviction when cache size limits are exceeded.
5
+ */
6
+ import { MAX_CLASS_NAME_CACHE_SIZE, MAX_CSS_CACHE_SIZE } from "../constants";
7
+ /**
8
+ * LRU Cache implementation for class names and CSS strings.
9
+ * Automatically evicts least recently used entries when size limit is exceeded.
10
+ */
11
+ export class LRUCache extends Map {
12
+ maxSize;
13
+ constructor(maxSize) {
14
+ super();
15
+ this.maxSize = maxSize;
16
+ }
17
+ get(key) {
18
+ const value = super.get(key);
19
+ if (value !== undefined) {
20
+ super.delete(key);
21
+ super.set(key, value);
22
+ }
23
+ return value;
24
+ }
25
+ set(key, value) {
26
+ if (super.has(key)) {
27
+ super.delete(key);
28
+ }
29
+ else if (this.size >= this.maxSize) {
30
+ const firstKey = this.keys().next().value;
31
+ if (firstKey !== undefined) {
32
+ super.delete(firstKey);
33
+ }
34
+ }
35
+ super.set(key, value);
36
+ return this;
37
+ }
38
+ }
39
+ export const classNameCache = new LRUCache(MAX_CLASS_NAME_CACHE_SIZE);
40
+ export const cssStringCache = new LRUCache(MAX_CSS_CACHE_SIZE);
41
+ const injectedStylesCache = new Set();
42
+ /**
43
+ * Checks if a CSS string has been injected.
44
+ *
45
+ * @param css - CSS string to check
46
+ * @returns True if CSS has been injected
47
+ */
48
+ export function isCachedStyle(css) {
49
+ return injectedStylesCache.has(css);
50
+ }
51
+ /**
52
+ * Marks a CSS string as injected.
53
+ *
54
+ * @param css - CSS string to cache
55
+ */
56
+ export function markStyleAsCached(css) {
57
+ injectedStylesCache.add(css);
58
+ }
59
+ /**
60
+ * Clears all cached styles.
61
+ */
62
+ export function clearStyleCache() {
63
+ classNameCache.clear();
64
+ cssStringCache.clear();
65
+ injectedStylesCache.clear();
66
+ }