meno-core 1.0.24 → 1.0.26

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 (79) hide show
  1. package/build-static.ts +9 -5
  2. package/entries/client-router.tsx +83 -1
  3. package/entries/server-router.tsx +6 -2
  4. package/lib/client/core/builders/embedBuilder.ts +2 -1
  5. package/lib/client/core/builders/listBuilder.ts +1 -1
  6. package/lib/client/hmr/HMRManager.tsx +12 -17
  7. package/lib/client/hooks/useVariables.ts +101 -0
  8. package/lib/client/index.ts +1 -0
  9. package/lib/client/meno-filter/bindings.ts +1 -0
  10. package/lib/client/routing/RouteLoader.test.ts +1 -1
  11. package/lib/client/routing/RouteLoader.ts +1 -1
  12. package/lib/client/scripts/ScriptExecutor.ts +13 -4
  13. package/lib/client/services/PrefetchService.ts +1 -1
  14. package/lib/client/styles/UtilityClassCollector.ts +109 -13
  15. package/lib/client/templateEngine.ts +61 -3
  16. package/lib/server/__integration__/cms-integration.test.ts +2 -2
  17. package/lib/server/cssGenerator.ts +91 -3
  18. package/lib/server/fileWatcher.ts +81 -3
  19. package/lib/server/index.ts +8 -3
  20. package/lib/server/migrateTemplates.ts +22 -0
  21. package/lib/server/projectContext.ts +3 -1
  22. package/lib/server/providers/fileSystemCMSProvider.test.ts +6 -7
  23. package/lib/server/providers/fileSystemCMSProvider.ts +6 -11
  24. package/lib/server/providers/fileSystemPageProvider.ts +86 -40
  25. package/lib/server/routes/api/colors.test.ts +103 -0
  26. package/lib/server/routes/api/colors.ts +10 -42
  27. package/lib/server/routes/api/core-routes.ts +42 -2
  28. package/lib/server/routes/api/enums.test.ts +53 -0
  29. package/lib/server/routes/api/enums.ts +16 -0
  30. package/lib/server/routes/api/pages.ts +3 -2
  31. package/lib/server/routes/api/variables.test.ts +74 -0
  32. package/lib/server/routes/api/variables.ts +32 -0
  33. package/lib/server/routes/index.ts +1 -1
  34. package/lib/server/routes/pages.ts +4 -4
  35. package/lib/server/services/CachedConfigLoader.ts +55 -0
  36. package/lib/server/services/ColorService.test.ts +216 -0
  37. package/lib/server/services/ColorService.ts +11 -30
  38. package/lib/server/services/EnumService.test.ts +192 -0
  39. package/lib/server/services/EnumService.ts +118 -0
  40. package/lib/server/services/VariableService.test.ts +183 -0
  41. package/lib/server/services/VariableService.ts +100 -0
  42. package/lib/server/services/cmsService.test.ts +11 -11
  43. package/lib/server/services/configService.test.ts +1 -95
  44. package/lib/server/services/configService.ts +0 -27
  45. package/lib/server/services/fileWatcherService.ts +18 -2
  46. package/lib/server/services/pageService.ts +140 -2
  47. package/lib/server/ssr/htmlGenerator.ts +35 -7
  48. package/lib/server/ssr/imageMetadata.ts +5 -1
  49. package/lib/server/ssr/jsCollector.ts +3 -2
  50. package/lib/server/ssr/ssrRenderer.test.ts +129 -121
  51. package/lib/server/ssr/ssrRenderer.ts +131 -271
  52. package/lib/server/websocketManager.ts +36 -0
  53. package/lib/shared/colorConversions.test.ts +140 -0
  54. package/lib/shared/colorConversions.ts +181 -0
  55. package/lib/shared/constants.test.ts +1 -1
  56. package/lib/shared/constants.ts +26 -1
  57. package/lib/shared/cssGeneration.ts +56 -4
  58. package/lib/shared/cssProperties.ts +22 -0
  59. package/lib/shared/fontLoader.ts +4 -0
  60. package/lib/shared/gradientUtils.test.ts +183 -0
  61. package/lib/shared/gradientUtils.ts +184 -0
  62. package/lib/shared/index.ts +6 -0
  63. package/lib/shared/libraryLoader.ts +42 -0
  64. package/lib/shared/registry/nodeTypes/EmbedNodeType.ts +6 -4
  65. package/lib/shared/registry/nodeTypes/ListNodeType.ts +4 -32
  66. package/lib/shared/responsiveScaling.ts +3 -3
  67. package/lib/shared/treePathUtils.ts +8 -0
  68. package/lib/shared/types/api.ts +1 -1
  69. package/lib/shared/types/colors.ts +10 -0
  70. package/lib/shared/types/components.ts +4 -0
  71. package/lib/shared/types/index.ts +21 -0
  72. package/lib/shared/types/styles.ts +10 -0
  73. package/lib/shared/types/variables.test.ts +132 -0
  74. package/lib/shared/types/variables.ts +215 -0
  75. package/lib/shared/utilityClassConfig.ts +4 -0
  76. package/lib/shared/validation/propValidator.ts +3 -2
  77. package/lib/shared/validation/schemas.ts +53 -47
  78. package/package.json +1 -1
  79. package/templates/index-router.html +1 -1
package/build-static.ts CHANGED
@@ -30,6 +30,7 @@ import type { SlugMap } from "./lib/shared/slugTranslator";
30
30
  import { buildItemUrl } from "./lib/shared/itemTemplateUtils";
31
31
  import { generateMiddleware, generateTrackFunction, generateResultsFunction } from "./lib/server/ab/generateFunctions";
32
32
  import { generateTrackingScript } from "./lib/server/ab/trackingScript";
33
+ import { migrateTemplatesDirectory } from "./lib/server/migrateTemplates";
33
34
 
34
35
  /**
35
36
  * Collect build errors for error overlay
@@ -387,7 +388,7 @@ async function generateStaticDataFiles(
387
388
  }
388
389
 
389
390
  /**
390
- * Build CMS templates from pages/templates/ directory
391
+ * Build CMS templates from root templates/ directory
391
392
  */
392
393
  async function buildCMSTemplates(
393
394
  templatesDir: string,
@@ -566,7 +567,7 @@ async function buildCMSTemplates(
566
567
 
567
568
  console.error(`❌ Error processing ${file}:`, error);
568
569
  buildErrors.push({
569
- file: `pages/templates/${file}`,
570
+ file: `templates/${file}`,
570
571
  message: errorMessage,
571
572
  type: errorMessage.includes('minification') || errorMessage.includes('minify') ? 'minify' : 'cms',
572
573
  });
@@ -664,6 +665,9 @@ export async function buildStaticPages(): Promise<void> {
664
665
  const i18nConfig = await loadI18nConfig();
665
666
  console.log(`🌐 Locales: ${i18nConfig.locales.map(l => l.code).join(", ")} (default: ${i18nConfig.defaultLocale})\n`);
666
667
 
668
+ // Auto-migrate pages/templates/ → templates/ if needed
669
+ await migrateTemplatesDirectory();
670
+
667
671
  // Clean dist directory (removes editor files, old HTML)
668
672
  cleanDist();
669
673
 
@@ -770,7 +774,7 @@ export async function buildStaticPages(): Promise<void> {
770
774
  console.log(`✅ Loaded ${components.size} global component(s)\n`);
771
775
 
772
776
  // Initialize CMS service for CMSList rendering
773
- const cmsProvider = new FileSystemCMSProvider(projectPaths.pages(), projectPaths.cms());
777
+ const cmsProvider = new FileSystemCMSProvider(projectPaths.templates(), projectPaths.cms());
774
778
  const cmsService = new CMSService(cmsProvider);
775
779
  await cmsService.initialize();
776
780
  console.log(`✅ CMS service initialized\n`);
@@ -947,8 +951,8 @@ export async function buildStaticPages(): Promise<void> {
947
951
  }
948
952
  }
949
953
 
950
- // Build CMS templates from pages/templates/
951
- const templatesDir = join(pagesDir, 'templates');
954
+ // Build CMS templates from root templates/ directory
955
+ const templatesDir = projectPaths.templates();
952
956
  const staticCollections = new Map<string, ClientDataCollection>();
953
957
  const cmsResult = await buildCMSTemplates(
954
958
  templatesDir,
@@ -7,6 +7,7 @@ import type { PrefetchConfig } from "../lib/shared/types/prefetch";
7
7
  declare global {
8
8
  interface Window {
9
9
  __hmrColorsInitialized?: boolean;
10
+ __hmrVariablesInitialized?: boolean;
10
11
  __MENO_CONFIG__?: {
11
12
  prefetch?: Partial<PrefetchConfig>;
12
13
  };
@@ -91,8 +92,89 @@ async function injectUpdatedThemeCSS() {
91
92
  }
92
93
  }
93
94
 
94
- // Initialize HMR colors listener
95
+ // Setup HMR variables update listener immediately on app load
96
+ function setupVariablesHMR() {
97
+ if (typeof window === 'undefined') return;
98
+
99
+ if (window.__hmrVariablesInitialized) return;
100
+ window.__hmrVariablesInitialized = true;
101
+
102
+ document.addEventListener('hmr-variables-update', async () => {
103
+ await injectUpdatedVariablesCSS();
104
+ });
105
+ }
106
+
107
+ // Fetch and inject updated variables CSS
108
+ async function injectUpdatedVariablesCSS() {
109
+ try {
110
+ const response = await fetch('/api/variables-css');
111
+ if (!response.ok) return;
112
+
113
+ const css = await response.text();
114
+
115
+ let styleTag = document.getElementById('hmr-css-variables');
116
+ if (!styleTag) {
117
+ styleTag = document.createElement('style');
118
+ styleTag.id = 'hmr-css-variables';
119
+ document.head.appendChild(styleTag);
120
+ }
121
+
122
+ styleTag.textContent = css;
123
+ } catch (error) {
124
+ // Silently fail - not critical if CSS injection doesn't work
125
+ }
126
+ }
127
+
128
+ // Setup HMR fonts update listener immediately on app load
129
+ function setupFontsHMR() {
130
+ if (typeof window === 'undefined') return;
131
+
132
+ if ((window as any).__hmrFontsCSSInitialized) return;
133
+ (window as any).__hmrFontsCSSInitialized = true;
134
+
135
+ document.addEventListener('hmr-fonts-update', async () => {
136
+ await injectUpdatedFontsCSS();
137
+ });
138
+ }
139
+
140
+ // Fetch and inject updated fonts CSS
141
+ async function injectUpdatedFontsCSS() {
142
+ try {
143
+ const response = await fetch('/api/fonts-css');
144
+ if (!response.ok) return;
145
+
146
+ const css = await response.text();
147
+
148
+ let styleTag = document.getElementById('hmr-fonts-css');
149
+ if (!styleTag) {
150
+ styleTag = document.createElement('style');
151
+ styleTag.id = 'hmr-fonts-css';
152
+ document.head.appendChild(styleTag);
153
+ }
154
+
155
+ styleTag.textContent = css;
156
+ } catch (error) {
157
+ // Silently fail
158
+ }
159
+ }
160
+
161
+ // Setup HMR libraries update listener - triggers full page reload
162
+ function setupLibrariesHMR() {
163
+ if (typeof window === 'undefined') return;
164
+
165
+ if ((window as any).__hmrLibrariesInitialized) return;
166
+ (window as any).__hmrLibrariesInitialized = true;
167
+
168
+ document.addEventListener('hmr-libraries-update', () => {
169
+ location.reload();
170
+ });
171
+ }
172
+
173
+ // Initialize HMR listeners
95
174
  setupColorsHMR();
175
+ setupVariablesHMR();
176
+ setupFontsHMR();
177
+ setupLibrariesHMR();
96
178
 
97
179
  // Render app with HMR support and prefetching enabled
98
180
  const rootElement = document.getElementById('root');
@@ -14,16 +14,20 @@ import { FileSystemPageProvider } from '../lib/server/providers/fileSystemPagePr
14
14
  import { FileSystemCMSProvider } from '../lib/server/providers/fileSystemCMSProvider';
15
15
  import { configService } from '../lib/server/services/configService';
16
16
  import { projectPaths } from '../lib/server/projectContext';
17
+ import { migrateTemplatesDirectory } from '../lib/server/migrateTemplates';
18
+
19
+ // Auto-migrate pages/templates/ → templates/ if needed
20
+ await migrateTemplatesDirectory();
17
21
 
18
22
  // Initialize services
19
23
  const pageCache = new PageCache();
20
- const pageProvider = new FileSystemPageProvider(projectPaths.pages());
24
+ const pageProvider = new FileSystemPageProvider(projectPaths.pages(), projectPaths.templates());
21
25
  const pageService = new PageService(pageCache, pageProvider);
22
26
  const componentService = new ComponentService();
23
27
  const wsManager = new WebSocketManager();
24
28
 
25
29
  // Initialize CMS services
26
- const cmsProvider = new FileSystemCMSProvider(projectPaths.pages(), projectPaths.cms());
30
+ const cmsProvider = new FileSystemCMSProvider(projectPaths.templates(), projectPaths.cms());
27
31
  const cmsService = new CMSService(cmsProvider);
28
32
 
29
33
  // Initialize file watcher with CMS service for template change detection
@@ -54,7 +54,8 @@ export function buildEmbed(
54
54
  const { key, elementPath, parentComponentName, componentContext, componentRootPath, cmsItemIndexPath } = ctx;
55
55
 
56
56
  // Process templates in html property before sanitization (matching SSR behavior)
57
- let htmlContent = node.html;
57
+ // Mappings should already be resolved by processStructure, this is just a type safety guard
58
+ let htmlContent = typeof node.html === 'string' ? node.html : '';
58
59
 
59
60
  // Process item template strings (for List context) - e.g., {{item.icon}}, {{feature.title}}
60
61
  if (ctx.templateContext && hasItemTemplates(htmlContent)) {
@@ -224,7 +224,7 @@ export function buildList(
224
224
  const label = isCollectionMode ? 'CMS List' : 'List';
225
225
 
226
226
  // Use configurable tag (defaults to 'div') - null means fragment mode (no wrapper)
227
- const tag = node.tag === false ? null : (node.tag || 'div');
227
+ const tag = typeof node.tag === 'string' ? node.tag : null;
228
228
 
229
229
  if (!source && !sourceIsResolved) {
230
230
  // No source - render empty container with placeholder
@@ -3,7 +3,7 @@
3
3
  * Manages Hot Module Replacement WebSocket connection, status tracking, and visual indicators.
4
4
  */
5
5
 
6
- import { createElement as h, useState, useEffect, useRef, useCallback } from "react";
6
+ import { createElement as h, useState, useEffect, useRef } from "react";
7
7
  import type { ReactElement } from "react";
8
8
  import { HMRWebSocket } from "../hmrWebSocket";
9
9
 
@@ -65,17 +65,6 @@ export function HMRManager({
65
65
 
66
66
  useEffect(() => { onReloadRef.current = onReload; }, [onReload]);
67
67
 
68
- // Helper function to show HMR indicator
69
- const showHMRIndicator = useCallback(() => {
70
- const indicator = document.getElementById('hmr-indicator');
71
- if (indicator) {
72
- indicator.style.display = 'block';
73
- setTimeout(() => {
74
- indicator.style.display = 'none';
75
- }, 2000);
76
- }
77
- }, []);
78
-
79
68
  // Define message handler - reads from refs to always get latest values
80
69
  const createMessageHandler = () => (data: any) => {
81
70
  if (data.type === 'hmr:update') {
@@ -97,8 +86,15 @@ export function HMRManager({
97
86
  } else if (data.type === 'hmr:colors-update') {
98
87
  // Dispatch custom event to notify color hooks of the update
99
88
  document.dispatchEvent(new CustomEvent('hmr-colors-update'));
100
- // Show HMR indicator
101
- showHMRIndicator();
89
+ } else if (data.type === 'hmr:variables-update') {
90
+ // Dispatch custom event to notify variable hooks of the update
91
+ document.dispatchEvent(new CustomEvent('hmr-variables-update'));
92
+ } else if (data.type === 'hmr:fonts-update') {
93
+ // Dispatch custom event to notify font CSS injection
94
+ document.dispatchEvent(new CustomEvent('hmr-fonts-update'));
95
+ } else if (data.type === 'hmr:libraries-update') {
96
+ // Dispatch custom event to trigger full page reload for library changes
97
+ document.dispatchEvent(new CustomEvent('hmr-libraries-update'));
102
98
  }
103
99
  };
104
100
 
@@ -160,14 +156,13 @@ export function HMRManager({
160
156
  });
161
157
 
162
158
  import.meta.hot.on('bun:afterUpdate', () => {
163
- // Show visual indicator
164
- showHMRIndicator();
159
+ // HMR update complete
165
160
  });
166
161
 
167
162
  import.meta.hot.on('bun:error', () => {
168
163
  });
169
164
  }
170
- }, [showHMRIndicator]);
165
+ }, []);
171
166
 
172
167
  // Render indicators
173
168
  return h(HMRIndicator, { status });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Hook for fetching and caching CSS variables (variables.json)
3
+ */
4
+
5
+ import { useState, useEffect, useRef } from 'react';
6
+ import type { CSSVariable } from '../../shared/types/variables';
7
+
8
+ let cachedVariables: CSSVariable[] | null = null;
9
+ let hmrCallbacks: Set<() => void> = new Set();
10
+
11
+ // Setup HMR listener immediately when module loads
12
+ function initializeHMRListener() {
13
+ if (typeof window === 'undefined') return;
14
+
15
+ // Only setup once
16
+ if ((window as any).__hmrVariablesInitialized) return;
17
+ (window as any).__hmrVariablesInitialized = true;
18
+
19
+ // Listen for custom HMR events from HMRManager
20
+ document.addEventListener('hmr-variables-update', () => {
21
+ // Clear cache
22
+ cachedVariables = null;
23
+
24
+ // Notify all listeners to refresh their data
25
+ hmrCallbacks.forEach(callback => callback());
26
+ });
27
+ }
28
+
29
+ // Initialize immediately
30
+ if (typeof window !== 'undefined') {
31
+ initializeHMRListener();
32
+ }
33
+
34
+ export function useVariables() {
35
+ const [variables, setVariables] = useState<CSSVariable[] | null>(cachedVariables);
36
+ const [loading, setLoading] = useState(!cachedVariables);
37
+ const [error, setError] = useState<Error | null>(null);
38
+ const callbackRef = useRef<(() => void) | null>(null);
39
+
40
+ useEffect(() => {
41
+ // Create a callback to refresh variables when HMR update is received
42
+ const refreshCallback = async () => {
43
+ try {
44
+ const response = await fetch('/api/variables-status');
45
+ if (!response.ok) {
46
+ throw new Error('Failed to fetch variables');
47
+ }
48
+ const data = await response.json() as { status: string; config: { variables: CSSVariable[] } };
49
+ cachedVariables = data.config.variables;
50
+ setVariables(cachedVariables);
51
+ setError(null);
52
+ } catch (err) {
53
+ setError(err instanceof Error ? err : new Error('Unknown error'));
54
+ }
55
+ };
56
+
57
+ // Register callback for HMR updates
58
+ callbackRef.current = refreshCallback;
59
+ hmrCallbacks.add(refreshCallback);
60
+
61
+ // Return cached variables immediately
62
+ if (cachedVariables) {
63
+ setVariables(cachedVariables);
64
+ setLoading(false);
65
+ return () => {
66
+ if (callbackRef.current) {
67
+ hmrCallbacks.delete(callbackRef.current);
68
+ }
69
+ };
70
+ }
71
+
72
+ // Fetch variables
73
+ const fetchVariables = async () => {
74
+ try {
75
+ const response = await fetch('/api/variables-status');
76
+ if (!response.ok) {
77
+ throw new Error('Failed to fetch variables');
78
+ }
79
+ const data = await response.json() as { status: string; config: { variables: CSSVariable[] } };
80
+ cachedVariables = data.config.variables;
81
+ setVariables(cachedVariables);
82
+ setError(null);
83
+ } catch (err) {
84
+ setError(err instanceof Error ? err : new Error('Unknown error'));
85
+ setVariables(null);
86
+ } finally {
87
+ setLoading(false);
88
+ }
89
+ };
90
+
91
+ fetchVariables();
92
+
93
+ return () => {
94
+ if (callbackRef.current) {
95
+ hmrCallbacks.delete(callbackRef.current);
96
+ }
97
+ };
98
+ }, []);
99
+
100
+ return { variables, loading, error };
101
+ }
@@ -52,6 +52,7 @@ export * from './navigation';
52
52
  // Hooks
53
53
  export * from './hooks/useColorVariables';
54
54
  export * from './hooks/usePropertyAutocomplete';
55
+ export * from './hooks/useVariables';
55
56
 
56
57
  // Template engine
57
58
  export * from './templateEngine';
@@ -118,6 +118,7 @@ export function bindFilterControls(instance: FilterInstance): void {
118
118
  filter.addFilter(field, value);
119
119
  }
120
120
  }
121
+
121
122
  };
122
123
 
123
124
  btn.addEventListener('click', handler);
@@ -584,7 +584,7 @@ describe('RouteLoader', () => {
584
584
  config.prefetchService = mockPrefetchService as any;
585
585
  routeLoader = new RouteLoader(config);
586
586
 
587
- // Mock fetch - should NOT be called for /api/yaml since we have cache
587
+ // Mock fetch - should NOT be called for /api/page-content since we have cache
588
588
  mockFetch
589
589
  .mockResolvedValueOnce({
590
590
  ok: true,
@@ -169,7 +169,7 @@ export class RouteLoader {
169
169
  return tree;
170
170
  }
171
171
 
172
- const response = await fetch(`${API_ROUTES.YAML}?page=${encodeURIComponent(pathWithoutLocale)}`, {
172
+ const response = await fetch(`${API_ROUTES.PAGE_CONTENT}?page=${encodeURIComponent(pathWithoutLocale)}`, {
173
173
  cache: 'no-store',
174
174
  signal: abortController.signal,
175
175
  });
@@ -123,10 +123,11 @@ export class ScriptExecutor {
123
123
  const wrappedJS = `(function() {
124
124
  // Component: ${componentName} (defineVars)
125
125
  try {
126
- var elements = document.querySelectorAll('[data-component="${componentName}"]');
126
+ var elements = document.querySelectorAll('[data-component~="${componentName}"]');
127
127
  elements.forEach(function(el) {
128
128
  var propsStr = el.getAttribute('data-props');
129
- var props = propsStr ? JSON.parse(propsStr) : {};
129
+ var allProps = propsStr ? JSON.parse(propsStr) : {};
130
+ var props = allProps["${componentName}"] || {};
130
131
  (function(el, props) {
131
132
  ${destructure}
132
133
  ${js}
@@ -275,7 +276,7 @@ export class ScriptExecutor {
275
276
  const js = component.component.javascript;
276
277
  const destructure = generateDestructure(defineVars, component.component.interface);
277
278
 
278
- // Update data-props attribute
279
+ // Update data-props attribute (keyed by component name)
279
280
  const varsToExpose = defineVars === true
280
281
  ? Object.keys(component.component.interface || {})
281
282
  : defineVars;
@@ -286,7 +287,15 @@ export class ScriptExecutor {
286
287
  propsForJS[varName] = newProps[varName];
287
288
  }
288
289
  }
289
- element.setAttribute('data-props', JSON.stringify(propsForJS));
290
+
291
+ // Preserve existing keyed props from other components
292
+ let allProps: Record<string, unknown> = {};
293
+ const existingStr = element.getAttribute('data-props');
294
+ if (existingStr) {
295
+ try { allProps = JSON.parse(existingStr); } catch {}
296
+ }
297
+ allProps[componentName] = propsForJS;
298
+ element.setAttribute('data-props', JSON.stringify(allProps));
290
299
 
291
300
  // Execute JS for this element only
292
301
  const wrappedJS = `(function(el, props) {
@@ -134,7 +134,7 @@ export class PrefetchService {
134
134
  try {
135
135
  // Fetch page data (same endpoint RouteLoader uses)
136
136
  const response = await fetch(
137
- `${API_ROUTES.YAML}?page=${encodeURIComponent(path)}`,
137
+ `${API_ROUTES.PAGE_CONTENT}?page=${encodeURIComponent(path)}`,
138
138
  {
139
139
  signal: abortController.signal,
140
140
  // Allow browser caching for prefetched content
@@ -7,18 +7,103 @@
7
7
  * CSS is injected eagerly during React's render phase (inside collect()),
8
8
  * eliminating the micro-gap between DOM commit and CSS injection (FOUC).
9
9
  * This is the same pattern CSS-in-JS libraries (Emotion, styled-components) use.
10
+ *
11
+ * All injected CSS is kept sorted by CSS property precedence so that shorthand
12
+ * properties (border) always appear before longhands (border-color), regardless
13
+ * of which render batch introduced each class.
10
14
  */
11
15
 
12
- import { generateSingleClassCSS } from '../../shared/cssGeneration';
16
+ import { generateSingleClassCSS, sortClassesByPropertyOrder, generateRuleForClass } from '../../shared/cssGeneration';
13
17
  import { getCachedBreakpointConfig, getCachedResponsiveScalesConfig } from '../responsiveStyleResolver';
14
- import { DEFAULT_BREAKPOINTS } from '../../shared/breakpoints';
18
+ import { DEFAULT_BREAKPOINTS, getBreakpointValues } from '../../shared/breakpoints';
19
+ import type { BreakpointConfig } from '../../shared/breakpoints';
15
20
  import { DEFAULT_RESPONSIVE_SCALES } from '../../shared/responsiveScaling';
16
21
 
22
+ /**
23
+ * Build a map from responsive prefix (e.g. 't', 'mob') to breakpoint value.
24
+ * Same logic as generateSingleClassCSS / generateUtilityCSS.
25
+ */
26
+ function buildResponsivePrefixMap(breakpoints: BreakpointConfig): Record<string, number> {
27
+ const breakpointValues = getBreakpointValues(breakpoints);
28
+ const map: Record<string, number> = {};
29
+ for (const [breakpointName, breakpointValue] of Object.entries(breakpointValues)) {
30
+ let prefix = breakpointName.charAt(0).toLowerCase();
31
+ if (breakpointName.toLowerCase() === 'mobile') {
32
+ prefix = 'mob';
33
+ }
34
+ map[prefix] = breakpointValue;
35
+ }
36
+ return map;
37
+ }
38
+
39
+ /**
40
+ * Get the responsive breakpoint value for a class, or 0 if it's not responsive.
41
+ * A class is responsive if it starts with a known breakpoint prefix and the
42
+ * remainder generates a valid CSS rule (same heuristic as generateSingleClassCSS).
43
+ */
44
+ function getClassBreakpointValue(
45
+ className: string,
46
+ prefixMap: Record<string, number>
47
+ ): number {
48
+ for (const prefix of Object.keys(prefixMap)) {
49
+ if (className.startsWith(`${prefix}-`) && className.length > prefix.length + 1) {
50
+ const potentialClass = className.substring(prefix.length + 1);
51
+ const rule = generateRuleForClass(potentialClass);
52
+ if (rule && !potentialClass.match(/^(auto|0|[\d.]+px|[\d.]+p)$/)) {
53
+ return prefixMap[prefix];
54
+ }
55
+ }
56
+ }
57
+ return 0;
58
+ }
59
+
60
+ /**
61
+ * Sort utility classes with breakpoint-aware ordering:
62
+ * 1. Non-responsive classes first, sorted by CSS property order (shorthand before longhand)
63
+ * 2. Responsive classes after, sorted by breakpoint value descending (largest first),
64
+ * then by property order within each breakpoint group.
65
+ *
66
+ * This matches the SSR output from generateUtilityCSS which sorts responsive
67
+ * @media blocks by breakpoint value descending so that smaller breakpoints
68
+ * (mobile 540px) can correctly override larger ones (tablet 1024px) in the cascade.
69
+ */
70
+ function sortClassesWithBreakpointOrder(
71
+ classes: Iterable<string>,
72
+ breakpoints: BreakpointConfig
73
+ ): string[] {
74
+ const prefixMap = buildResponsivePrefixMap(breakpoints);
75
+ const arr = Array.from(classes);
76
+
77
+ // Pre-compute sort keys for each class
78
+ const keys = arr.map(cls => {
79
+ const bpValue = getClassBreakpointValue(cls, prefixMap);
80
+ return { cls, isResponsive: bpValue > 0 ? 1 : 0, bpValue };
81
+ });
82
+
83
+ // Get property-order sorted array to derive per-class indices
84
+ const propertySorted = sortClassesByPropertyOrder(arr);
85
+ const propertyOrderIndex = new Map<string, number>();
86
+ for (let i = 0; i < propertySorted.length; i++) {
87
+ propertyOrderIndex.set(propertySorted[i], i);
88
+ }
89
+
90
+ keys.sort((a, b) => {
91
+ // Non-responsive (0) before responsive (1)
92
+ if (a.isResponsive !== b.isResponsive) return a.isResponsive - b.isResponsive;
93
+ // Within responsive: larger breakpoint first (descending)
94
+ if (a.bpValue !== b.bpValue) return b.bpValue - a.bpValue;
95
+ // Within same group: property order
96
+ return (propertyOrderIndex.get(a.cls) ?? Infinity) - (propertyOrderIndex.get(b.cls) ?? Infinity);
97
+ });
98
+
99
+ return keys.map(k => k.cls);
100
+ }
101
+
17
102
  class UtilityClassCollectorImpl {
18
103
  private classes: Set<string> = new Set();
19
104
 
20
- /** Tracks classes with CSS already injected into the DOM */
21
- private injectedClasses: Set<string> = new Set();
105
+ /** Map of injected class names their generated CSS text */
106
+ private injectedRules: Map<string, string> = new Map();
22
107
 
23
108
  /** Cached reference to <style id="utility-css"> */
24
109
  private styleEl: HTMLStyleElement | null = null;
@@ -53,34 +138,45 @@ class UtilityClassCollectorImpl {
53
138
  * Collect utility class names from a render pass and eagerly inject CSS.
54
139
  * Called by ComponentBuilder and builder modules after computing style classes.
55
140
  * CSS is injected synchronously during render — before React commits the DOM.
141
+ *
142
+ * When new classes are added, the full style content is rebuilt in sorted order
143
+ * so that shorthand properties (border) always precede longhands (border-color),
144
+ * even when they arrive in different render batches.
56
145
  */
57
146
  collect(classNames: string[]): void {
58
147
  const breakpointConfig = getCachedBreakpointConfig() || DEFAULT_BREAKPOINTS;
59
148
  const responsiveScalesConfig = getCachedResponsiveScalesConfig() || DEFAULT_RESPONSIVE_SCALES;
60
- const cssParts: string[] = [];
61
149
 
150
+ let hasNew = false;
62
151
  for (const name of classNames) {
63
152
  this.classes.add(name);
64
153
 
65
- if (this.injectedClasses.has(name)) continue;
66
- this.injectedClasses.add(name);
154
+ if (this.injectedRules.has(name)) continue;
67
155
 
68
156
  const css = generateSingleClassCSS(name, breakpointConfig, responsiveScalesConfig);
69
- if (css) cssParts.push(css);
157
+ if (css) {
158
+ this.injectedRules.set(name, css);
159
+ hasNew = true;
160
+ }
70
161
  }
71
162
 
72
- // Batch all new CSS into a single text node append
73
- if (cssParts.length > 0) {
163
+ if (hasNew) {
164
+ // Rebuild style element with all rules sorted by property order.
165
+ // This ensures shorthands always precede longhands regardless of
166
+ // which collect() batch introduced each class.
167
+ const sorted = sortClassesWithBreakpointOrder(this.injectedRules.keys(), breakpointConfig);
74
168
  const styleEl = this.ensureStyleElement();
75
169
  if (styleEl) {
76
- styleEl.appendChild(document.createTextNode(cssParts.join('\n')));
170
+ styleEl.textContent = sorted
171
+ .map(name => this.injectedRules.get(name)!)
172
+ .join('\n');
77
173
  }
78
174
  }
79
175
  }
80
176
 
81
177
  /**
82
178
  * Clear collected classes for route change.
83
- * Preserves injectedClasses and style tag — old page CSS stays
179
+ * Preserves injectedRules and style tag — old page CSS stays
84
180
  * (needed during transition via previousComponentTree).
85
181
  */
86
182
  clear(): void {
@@ -93,7 +189,7 @@ class UtilityClassCollectorImpl {
93
189
  */
94
190
  destroy(): void {
95
191
  this.classes.clear();
96
- this.injectedClasses.clear();
192
+ this.injectedRules.clear();
97
193
  if (this.styleEl && this.styleEl.isConnected) {
98
194
  this.styleEl.remove();
99
195
  }