meno-core 1.0.48 → 1.0.50

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 (89) hide show
  1. package/build-astro.ts +6 -2
  2. package/dist/build-static.js +7 -7
  3. package/dist/chunks/{chunk-D5E3OKSL.js → chunk-56EUSC6D.js} +5 -5
  4. package/dist/chunks/{chunk-3FHJUHAS.js → chunk-7NIC4I3V.js} +300 -43
  5. package/dist/chunks/chunk-7NIC4I3V.js.map +7 -0
  6. package/dist/chunks/{chunk-B2RTLDXY.js → chunk-AZQYF6KE.js} +132 -1
  7. package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
  8. package/dist/chunks/{chunk-TPQ7APVQ.js → chunk-CVLFID6V.js} +473 -73
  9. package/dist/chunks/chunk-CVLFID6V.js.map +7 -0
  10. package/dist/chunks/{chunk-NP76N4HQ.js → chunk-EDQSMAMP.js} +13 -2
  11. package/dist/chunks/{chunk-NP76N4HQ.js.map → chunk-EDQSMAMP.js.map} +2 -2
  12. package/dist/chunks/{chunk-RQSTH2BS.js → chunk-H4JSCDNW.js} +2 -2
  13. package/dist/chunks/{chunk-EK4KESLU.js → chunk-J23ZX5AP.js} +8 -2
  14. package/dist/chunks/{chunk-EK4KESLU.js.map → chunk-J23ZX5AP.js.map} +2 -2
  15. package/dist/chunks/{chunk-UUA5LEWF.js → chunk-LPVETICS.js} +156 -8
  16. package/dist/chunks/chunk-LPVETICS.js.map +7 -0
  17. package/dist/chunks/{chunk-BJRKEPMP.js → chunk-PQ2HRXDR.js} +5 -2
  18. package/dist/chunks/chunk-PQ2HRXDR.js.map +7 -0
  19. package/dist/chunks/{chunk-NKUV77SR.js → chunk-YWJJD5D6.js} +133 -37
  20. package/dist/chunks/chunk-YWJJD5D6.js.map +7 -0
  21. package/dist/chunks/{configService-IGJEC3MC.js → configService-VOY2MY2K.js} +3 -3
  22. package/dist/entries/server-router.js +9 -9
  23. package/dist/entries/server-router.js.map +2 -2
  24. package/dist/lib/client/index.js +92 -32
  25. package/dist/lib/client/index.js.map +3 -3
  26. package/dist/lib/server/index.js +14 -12
  27. package/dist/lib/server/index.js.map +2 -2
  28. package/dist/lib/shared/index.js +46 -10
  29. package/dist/lib/shared/index.js.map +3 -3
  30. package/entries/server-router.tsx +6 -2
  31. package/lib/client/core/ComponentBuilder.test.ts +34 -0
  32. package/lib/client/core/ComponentBuilder.ts +33 -4
  33. package/lib/client/core/builders/embedBuilder.ts +28 -7
  34. package/lib/client/core/builders/linkNodeBuilder.ts +28 -7
  35. package/lib/client/core/builders/localeListBuilder.ts +30 -11
  36. package/lib/client/styles/StyleInjector.ts +3 -2
  37. package/lib/client/templateEngine.ts +24 -0
  38. package/lib/client/theme.ts +4 -4
  39. package/lib/server/cssGenerator.test.ts +64 -1
  40. package/lib/server/cssGenerator.ts +48 -9
  41. package/lib/server/fileWatcher.test.ts +134 -0
  42. package/lib/server/fileWatcher.ts +100 -32
  43. package/lib/server/jsonLoader.ts +1 -0
  44. package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
  45. package/lib/server/providers/fileSystemCMSProvider.ts +240 -19
  46. package/lib/server/routes/index.ts +1 -1
  47. package/lib/server/routes/pages.ts +23 -1
  48. package/lib/server/services/cmsService.test.ts +246 -0
  49. package/lib/server/services/cmsService.ts +122 -5
  50. package/lib/server/services/configService.ts +6 -0
  51. package/lib/server/services/fileWatcherService.ts +17 -0
  52. package/lib/server/ssr/attributeBuilder.ts +41 -0
  53. package/lib/server/ssr/htmlGenerator.test.ts +113 -0
  54. package/lib/server/ssr/htmlGenerator.ts +62 -7
  55. package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
  56. package/lib/server/ssr/ssrRenderer.test.ts +564 -0
  57. package/lib/server/ssr/ssrRenderer.ts +228 -49
  58. package/lib/server/webflow/buildWebflow.ts +1 -1
  59. package/lib/server/websocketManager.test.ts +61 -6
  60. package/lib/server/websocketManager.ts +25 -1
  61. package/lib/shared/cssGeneration.test.ts +267 -1
  62. package/lib/shared/cssGeneration.ts +240 -18
  63. package/lib/shared/cssProperties.test.ts +275 -1
  64. package/lib/shared/cssProperties.ts +223 -7
  65. package/lib/shared/interfaces/contentProvider.ts +39 -6
  66. package/lib/shared/pathSecurity.ts +16 -0
  67. package/lib/shared/responsiveScaling.test.ts +143 -0
  68. package/lib/shared/responsiveScaling.ts +253 -2
  69. package/lib/shared/themeDefaults.test.ts +3 -3
  70. package/lib/shared/themeDefaults.ts +3 -3
  71. package/lib/shared/types/api.ts +10 -1
  72. package/lib/shared/types/cms.ts +46 -12
  73. package/lib/shared/types/index.ts +1 -0
  74. package/lib/shared/utilityClassConfig.ts +3 -0
  75. package/lib/shared/utilityClassMapper.test.ts +123 -0
  76. package/lib/shared/utilityClassMapper.ts +179 -8
  77. package/lib/shared/validation/schemas.test.ts +93 -0
  78. package/lib/shared/validation/schemas.ts +71 -16
  79. package/lib/shared/validation/validators.ts +26 -1
  80. package/package.json +1 -1
  81. package/dist/chunks/chunk-3FHJUHAS.js.map +0 -7
  82. package/dist/chunks/chunk-B2RTLDXY.js.map +0 -7
  83. package/dist/chunks/chunk-BJRKEPMP.js.map +0 -7
  84. package/dist/chunks/chunk-NKUV77SR.js.map +0 -7
  85. package/dist/chunks/chunk-TPQ7APVQ.js.map +0 -7
  86. package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
  87. /package/dist/chunks/{chunk-D5E3OKSL.js.map → chunk-56EUSC6D.js.map} +0 -0
  88. /package/dist/chunks/{chunk-RQSTH2BS.js.map → chunk-H4JSCDNW.js.map} +0 -0
  89. /package/dist/chunks/{configService-IGJEC3MC.js.map → configService-VOY2MY2K.js.map} +0 -0
@@ -1,5 +1,7 @@
1
1
  import { describe, test, expect } from 'bun:test';
2
2
  import { stylesToClasses, responsiveStylesToClasses, classToStyle, classesToStyles } from './utilityClassMapper';
3
+ import { getStyleValue, clearRegistry } from './styleValueRegistry';
4
+ import type { ResponsiveScales } from './responsiveScaling';
3
5
 
4
6
  describe('utilityClassMapper', () => {
5
7
  describe('stylesToClasses', () => {
@@ -93,6 +95,127 @@ describe('utilityClassMapper', () => {
93
95
  expect(responsiveStylesToClasses(null)).toEqual([]);
94
96
  expect(responsiveStylesToClasses(undefined)).toEqual([]);
95
97
  });
98
+
99
+ describe('fluid mode — mobile-as-MIN consumption', () => {
100
+ const fluidScales: ResponsiveScales = {
101
+ enabled: true,
102
+ mode: 'fluid',
103
+ baseReference: 16,
104
+ fluidRange: { min: 320, max: 1700 },
105
+ fontSize: { tablet: 0.8, mobile: 0.5 },
106
+ padding: { tablet: 0.75, mobile: 0.5 },
107
+ };
108
+
109
+ test('consumes scalable mobile override into base clamp; no mob- class emitted', () => {
110
+ clearRegistry();
111
+ const classes = responsiveStylesToClasses(
112
+ { base: { fontSize: '150px' }, mobile: { fontSize: '94px' } },
113
+ { fluidActive: true, responsiveScales: fluidScales }
114
+ );
115
+ // base became a hashed class wrapping clamp(94px, ..., 150px)
116
+ const hashed = classes.find((c) => /^fs-h[0-9a-z]+$/.test(c));
117
+ expect(hashed).toBeDefined();
118
+ expect(getStyleValue(hashed!)).toMatch(/^clamp\(94px,/);
119
+ expect(getStyleValue(hashed!)).toContain(', 150px)');
120
+ // no .mob-fs-* emitted
121
+ expect(classes.some((c) => c.startsWith('mob-fs-'))).toBe(false);
122
+ });
123
+
124
+ test('mobile-only non-scalable property (display) stays as mob- class', () => {
125
+ clearRegistry();
126
+ const classes = responsiveStylesToClasses(
127
+ {
128
+ base: { fontSize: '150px' },
129
+ mobile: { fontSize: '94px', display: 'none' },
130
+ },
131
+ { fluidActive: true, responsiveScales: fluidScales }
132
+ );
133
+ // fontSize consumed into base clamp
134
+ const hashed = classes.find((c) => /^fs-h[0-9a-z]+$/.test(c));
135
+ expect(hashed).toBeDefined();
136
+ expect(getStyleValue(hashed!)).toMatch(/^clamp\(94px,/);
137
+ // display:none survives on mobile
138
+ expect(classes).toContain('mob-h');
139
+ });
140
+
141
+ test('tablet override stays as .t-* class (unchanged behavior)', () => {
142
+ clearRegistry();
143
+ const classes = responsiveStylesToClasses(
144
+ {
145
+ base: { fontSize: '150px' },
146
+ tablet: { fontSize: '120px' },
147
+ mobile: { fontSize: '94px' },
148
+ },
149
+ { fluidActive: true, responsiveScales: fluidScales }
150
+ );
151
+ expect(classes).toContain('t-fs-120px');
152
+ // mobile consumed
153
+ expect(classes.some((c) => c.startsWith('mob-fs-'))).toBe(false);
154
+ });
155
+
156
+ test('no mobile override → base passes through unchanged (no consumption)', () => {
157
+ clearRegistry();
158
+ const classes = responsiveStylesToClasses(
159
+ { base: { fontSize: '150px' } },
160
+ { fluidActive: true, responsiveScales: fluidScales }
161
+ );
162
+ expect(classes).toContain('fs-150px');
163
+ expect(classes.some((c) => /^fs-h/.test(c))).toBe(false);
164
+ });
165
+
166
+ test('mismatched units (rem vs px) → falls back, mob- class still emitted', () => {
167
+ clearRegistry();
168
+ const classes = responsiveStylesToClasses(
169
+ { base: { fontSize: '10rem' }, mobile: { fontSize: '94px' } },
170
+ { fluidActive: true, responsiveScales: fluidScales }
171
+ );
172
+ expect(classes).toContain('fs-10rem');
173
+ expect(classes).toContain('mob-fs-94px');
174
+ });
175
+
176
+ test('non-scalable mobile override (display) is not consumed', () => {
177
+ clearRegistry();
178
+ const classes = responsiveStylesToClasses(
179
+ { base: { display: 'flex' }, mobile: { display: 'block' } },
180
+ { fluidActive: true, responsiveScales: fluidScales }
181
+ );
182
+ expect(classes).toContain('f');
183
+ expect(classes).toContain('mob-b');
184
+ });
185
+
186
+ test('fluidActive off → no consumption, mob- class emitted', () => {
187
+ clearRegistry();
188
+ const classes = responsiveStylesToClasses(
189
+ { base: { fontSize: '150px' }, mobile: { fontSize: '94px' } },
190
+ { fluidActive: false, responsiveScales: fluidScales }
191
+ );
192
+ expect(classes).toContain('fs-150px');
193
+ expect(classes).toContain('mob-fs-94px');
194
+ });
195
+
196
+ test('fluidActive on but responsiveScales disabled → no consumption', () => {
197
+ clearRegistry();
198
+ const classes = responsiveStylesToClasses(
199
+ { base: { fontSize: '150px' }, mobile: { fontSize: '94px' } },
200
+ { fluidActive: true, responsiveScales: { ...fluidScales, enabled: false } }
201
+ );
202
+ expect(classes).toContain('fs-150px');
203
+ expect(classes).toContain('mob-fs-94px');
204
+ });
205
+
206
+ test('clamp math: MIN=94px, MAX=150px, fluidRange 320–1700', () => {
207
+ clearRegistry();
208
+ const classes = responsiveStylesToClasses(
209
+ { base: { fontSize: '150px' }, mobile: { fontSize: '94px' } },
210
+ { fluidActive: true, responsiveScales: fluidScales }
211
+ );
212
+ const hashed = classes.find((c) => /^fs-h[0-9a-z]+$/.test(c))!;
213
+ const value = String(getStyleValue(hashed));
214
+ // slope = (150-94)/(1700-320) = 56/1380 ≈ 0.04058 px/px → slopeVw ≈ 4.058vw
215
+ // intercept = 94 - 0.04058*320 = 94 - 12.9855 ≈ 81.0145px
216
+ expect(value).toMatch(/clamp\(94px,\s*81\.0145px\s*\+\s*4\.058vw,\s*150px\)/);
217
+ });
218
+ });
96
219
  });
97
220
 
98
221
  describe('classToStyle', () => {
@@ -13,7 +13,14 @@ import {
13
13
  gradientPresets,
14
14
  borderPresets,
15
15
  } from './utilityClassConfig';
16
- import { registerStyleValue, registerDynamicStyle } from './styleValueRegistry';
16
+ import { registerStyleValue, registerDynamicStyle, getStyleValue } from './styleValueRegistry';
17
+ import {
18
+ SCALABLE_CSS_PROPERTIES,
19
+ buildFluidClampWithExplicitMin,
20
+ parseValue,
21
+ DEFAULT_FLUID_RANGE,
22
+ type ResponsiveScales,
23
+ } from './responsiveScaling';
17
24
 
18
25
  // Function name abbreviations for shorter class names
19
26
  const functionAbbreviations: Record<string, string> = {
@@ -54,6 +61,34 @@ function sanitizeClassValue(value: string): string {
54
61
  .replace(/[^\w-]/g, ''); // remove other special chars
55
62
  }
56
63
 
64
+ /**
65
+ * 32-bit FNV-1a hash, encoded as base36 (6-7 chars). Deterministic — same
66
+ * input always produces the same hash, so duplicate complex values across
67
+ * elements share a single class definition.
68
+ */
69
+ function shortHash(input: string): string {
70
+ let hash = 0x811c9dc5;
71
+ for (let i = 0; i < input.length; i++) {
72
+ hash ^= input.charCodeAt(i);
73
+ hash = Math.imul(hash, 0x01000193);
74
+ }
75
+ return (hash >>> 0).toString(36);
76
+ }
77
+
78
+ /**
79
+ * True if a class name contains characters illegal in CSS selectors. We
80
+ * generate dynamic class names from CSS values; complex values (especially
81
+ * Webflow `clamp(2*1rem, ((..2)/(90 - 20)*20)*1rem + ..*100vw, 4*1rem)`)
82
+ * end up with `*`, `(`, `)`, `+`, `,` baked into the class name, none of
83
+ * which are valid in CSS selectors. Browser silently drops the rule and
84
+ * the styled property reverts to UA defaults. Hash-fallback gives us a
85
+ * short, valid-CSS class name, with the actual value resolved via the
86
+ * style-value registry at CSS generation time.
87
+ */
88
+ function hasIllegalClassChars(className: string): boolean {
89
+ return /[^\w-]/.test(className);
90
+ }
91
+
57
92
  /**
58
93
  * Converts a CSS property value to a utility class name
59
94
  * Example: { prop: "padding", value: "10px" } → "p-10px"
@@ -191,6 +226,18 @@ function propertyValueToClass(prop: string, value: string | number): string | nu
191
226
  className = `${prefix}-${stringValue}`;
192
227
  }
193
228
 
229
+ // Hash-fallback for class names with illegal CSS selector chars. Webflow's
230
+ // fluid `clamp(2*1rem, ((2 - .5)/70*20)*1rem + ..*100vw, 4*1rem)` would
231
+ // otherwise produce `pt-cl2*1rem-((2-.5)/70*20)*1rem+..*100vw-4*1rem`,
232
+ // with `*`, `(`, `)`, `+`, `,` — none of which are valid in CSS selectors.
233
+ // Browser drops the rule, the styled element reverts to defaults
234
+ // (paddings collapse to 0, etc.). Short FNV-1a hash + style-value
235
+ // registry preserves the original value for the CSS generator without
236
+ // baking it into the selector.
237
+ if (hasIllegalClassChars(className)) {
238
+ className = `${prefix}-h${shortHash(stringValue)}`;
239
+ }
240
+
194
241
  // Register original value for CSS generation (single registration point)
195
242
  registerStyleValue(className, value);
196
243
  return className;
@@ -245,10 +292,95 @@ export function stylesToClasses(
245
292
  * Also handles flat merged style objects:
246
293
  * Example: { padding: "10px", display: "flex" } → ["p-10px", "flex"]
247
294
  */
295
+ /** Values that don't make sense in a fluid container pattern. Mirrors cssGeneration.ts. */
296
+ const CONTAINER_RESERVED_VALUES = new Set(['auto', 'inherit', 'initial', 'unset', '']);
297
+
298
+ /**
299
+ * Detect "container intent" (width === maxWidth) on a flat StyleObject and
300
+ * rewrite into the fluid container pattern. Used by `responsiveStylesToClasses`
301
+ * when its caller signals fluid mode is active.
302
+ *
303
+ * Mirrors `applyContainerPattern` in cssGeneration.ts — kept as a private
304
+ * helper here to avoid a cross-module import in this hot path.
305
+ */
306
+ function transformContainerIntent(style: StyleObject): StyleObject {
307
+ const w = style.width;
308
+ const mw = style.maxWidth;
309
+ if (w == null || mw == null) return style;
310
+ if (typeof w !== 'string' || typeof mw !== 'string') return style;
311
+ if (w !== mw) return style;
312
+ if (CONTAINER_RESERVED_VALUES.has(w.trim())) return style;
313
+ return {
314
+ ...style,
315
+ width: 'calc(100% - var(--site-margin) * 2)',
316
+ marginLeft: 'auto',
317
+ marginRight: 'auto',
318
+ };
319
+ }
320
+
321
+ /**
322
+ * In fluid mode, consume per-node mobile overrides for scalable properties
323
+ * into the base's `clamp()` value. The mobile value becomes the clamp's MIN
324
+ * (small-viewport endpoint), the base stays as MAX. The mobile property is
325
+ * then stripped from the mobile slice so no `.mob-*` @media rule is emitted
326
+ * for it — avoiding the jump at the mobile breakpoint that a hard override
327
+ * would create.
328
+ *
329
+ * Non-scalable mobile properties (display, flex-direction, etc.) are left
330
+ * alone. Tablet is also untouched — explicit per-tablet overrides keep their
331
+ * @media behavior for now.
332
+ *
333
+ * Bails out when units mismatch, parsing fails, or the registered values
334
+ * aren't plain numeric+unit strings — those cases fall back to today's
335
+ * behavior (mobile emits its own .mob-* rule).
336
+ */
337
+ function consumeMobileIntoBaseClamp(
338
+ base: StyleObject,
339
+ mobile: StyleObject,
340
+ responsiveScales: ResponsiveScales
341
+ ): { patchedBase: StyleObject; patchedMobile: StyleObject } {
342
+ const fluidRange = responsiveScales.fluidRange ?? DEFAULT_FLUID_RANGE;
343
+ const patchedBase: StyleObject = { ...base };
344
+ const patchedMobile: StyleObject = { ...mobile };
345
+
346
+ for (const prop of Object.keys(base)) {
347
+ if (!SCALABLE_CSS_PROPERTIES.has(prop)) continue;
348
+ const mobileRaw = mobile[prop];
349
+ if (mobileRaw == null || mobileRaw === '') continue;
350
+
351
+ const baseRaw = base[prop];
352
+ if (baseRaw == null || baseRaw === '') continue;
353
+ if (typeof baseRaw !== 'string' && typeof baseRaw !== 'number') continue;
354
+ if (typeof mobileRaw !== 'string' && typeof mobileRaw !== 'number') continue;
355
+
356
+ const baseParsed = parseValue(String(baseRaw));
357
+ const mobileParsed = parseValue(String(mobileRaw));
358
+ if (!baseParsed || !mobileParsed) continue;
359
+ if (baseParsed.unit !== mobileParsed.unit) continue;
360
+ if (baseParsed.unit === '%' || baseParsed.unit === 'em') continue;
361
+
362
+ const clampValue = buildFluidClampWithExplicitMin(
363
+ mobileParsed.value,
364
+ baseParsed.value,
365
+ baseParsed.unit,
366
+ fluidRange.min,
367
+ fluidRange.max
368
+ );
369
+
370
+ patchedBase[prop] = clampValue;
371
+ delete patchedMobile[prop];
372
+ }
373
+
374
+ return { patchedBase, patchedMobile };
375
+ }
376
+
248
377
  export function responsiveStylesToClasses(
249
- styles: StyleObject | ResponsiveStyleObject | null | undefined
378
+ styles: StyleObject | ResponsiveStyleObject | null | undefined,
379
+ options?: { fluidActive?: boolean; responsiveScales?: ResponsiveScales }
250
380
  ): string[] {
251
381
  if (!styles) return [];
382
+ const fluidActive = options?.fluidActive === true;
383
+ const responsiveScales = options?.responsiveScales;
252
384
 
253
385
  const classes: string[] = [];
254
386
 
@@ -256,25 +388,53 @@ export function responsiveStylesToClasses(
256
388
  if ('base' in styles || 'tablet' in styles || 'mobile' in styles) {
257
389
  const responsiveStyles = styles as ResponsiveStyleObject;
258
390
 
391
+ // In fluid mode, fold per-node scalable mobile overrides into the base's
392
+ // clamp() so they become the small-viewport endpoint instead of a hard
393
+ // @media jump. Falls back to original base/mobile when prerequisites
394
+ // aren't met.
395
+ let baseSource = responsiveStyles.base;
396
+ let mobileSource = responsiveStyles.mobile;
397
+ if (fluidActive && responsiveScales?.enabled === true && baseSource && mobileSource) {
398
+ const { patchedBase, patchedMobile } = consumeMobileIntoBaseClamp(
399
+ baseSource,
400
+ mobileSource,
401
+ responsiveScales
402
+ );
403
+ baseSource = patchedBase;
404
+ mobileSource = patchedMobile;
405
+ }
406
+
259
407
  // Base styles (no prefix)
260
- if (responsiveStyles.base) {
261
- classes.push(...stylesToClasses(responsiveStyles.base));
408
+ if (baseSource) {
409
+ const base = fluidActive
410
+ ? transformContainerIntent(baseSource)
411
+ : baseSource;
412
+ classes.push(...stylesToClasses(base));
262
413
  }
263
414
 
264
415
  // Tablet styles (t- prefix)
265
416
  if (responsiveStyles.tablet) {
266
- const tabletClasses = stylesToClasses(responsiveStyles.tablet);
417
+ const tablet = fluidActive
418
+ ? transformContainerIntent(responsiveStyles.tablet)
419
+ : responsiveStyles.tablet;
420
+ const tabletClasses = stylesToClasses(tablet);
267
421
  classes.push(...tabletClasses.map((cls) => `t-${cls}`));
268
422
  }
269
423
 
270
424
  // Mobile styles (mob- prefix to avoid conflict with margin)
271
- if (responsiveStyles.mobile) {
272
- const mobileClasses = stylesToClasses(responsiveStyles.mobile);
425
+ if (mobileSource && Object.keys(mobileSource).length > 0) {
426
+ const mobile = fluidActive
427
+ ? transformContainerIntent(mobileSource)
428
+ : mobileSource;
429
+ const mobileClasses = stylesToClasses(mobile);
273
430
  classes.push(...mobileClasses.map((cls) => `mob-${cls}`));
274
431
  }
275
432
  } else {
276
433
  // Flat style object - treat all as base classes
277
- classes.push(...stylesToClasses(styles as StyleObject));
434
+ const flat = fluidActive
435
+ ? transformContainerIntent(styles as StyleObject)
436
+ : (styles as StyleObject);
437
+ classes.push(...stylesToClasses(flat));
278
438
  }
279
439
 
280
440
  return classes;
@@ -319,6 +479,17 @@ export function classToStyle(className: string): { prop: string; value: string }
319
479
 
320
480
  if (!prop) return null;
321
481
 
482
+ // Hash-fallback class — value is `h<base36>`, the real CSS value lives in
483
+ // the style-value registry (set when the class was minted from a complex
484
+ // `clamp()` / `calc()` value that wouldn't fit in a CSS selector). Pull
485
+ // the real value back out so editor reads see the original expression.
486
+ if (/^h[0-9a-z]+$/.test(value)) {
487
+ const registered = getStyleValue(cleanClass);
488
+ if (registered != null) {
489
+ return { prop, value: String(registered) };
490
+ }
491
+ }
492
+
322
493
  // Handle CSS variables
323
494
  if (value.includes('background') || value.includes('text') || value.includes('border')) {
324
495
  return { prop, value: `var(--${value})` };
@@ -28,6 +28,99 @@ describe('Schema Validation', () => {
28
28
  }
29
29
  });
30
30
 
31
+ test('prop default accepts an _i18n value object', () => {
32
+ // Regression: the SSR pipeline resolves `_i18n` objects in prop defaults
33
+ // (see resolveI18nInProps) and CLAUDE.md tells AI to write them. The
34
+ // schema must accept this shape — otherwise the editor's component-load
35
+ // route rejects the file with a misleading JSONPage error.
36
+ const propDef = {
37
+ type: 'string',
38
+ default: { _i18n: true, en: 'Hello', pl: 'Cześć' },
39
+ };
40
+
41
+ const result = PropDefinitionSchema.safeParse(propDef);
42
+ expect(result.success).toBe(true);
43
+ });
44
+
45
+ test('list prop default accepts _i18n value objects on item fields', () => {
46
+ const propDef = {
47
+ type: 'list',
48
+ itemSchema: { title: { type: 'string' } },
49
+ default: [
50
+ { title: { _i18n: true, en: 'Hello', pl: 'Cześć' } },
51
+ ],
52
+ };
53
+
54
+ const result = PropDefinitionSchema.safeParse(propDef);
55
+ expect(result.success).toBe(true);
56
+ });
57
+
58
+ test('HTML node accepts an _i18n object as direct children', () => {
59
+ // Regression: previously the validator rejected `{type: "node", tag:
60
+ // "h1", children: {_i18n: true, en: "Blog", pl: "Blog"}}` with a
61
+ // misleading JSONPage error. After widening ComponentNodeSchema this
62
+ // shape validates and the runtime resolves it to a locale-string.
63
+ const page = {
64
+ root: {
65
+ type: 'node',
66
+ tag: 'h1',
67
+ children: { _i18n: true, en: 'Blog', pl: 'Blog' },
68
+ },
69
+ };
70
+ const result = PageDataSchema.safeParse(page);
71
+ expect(result.success).toBe(true);
72
+ });
73
+
74
+ test('HTML node accepts an _i18n object inside attribute values', () => {
75
+ const page = {
76
+ root: {
77
+ type: 'node',
78
+ tag: 'img',
79
+ attributes: {
80
+ src: '/x.png',
81
+ alt: { _i18n: true, en: 'Photo', pl: 'Zdjęcie' },
82
+ },
83
+ },
84
+ };
85
+ const result = PageDataSchema.safeParse(page);
86
+ expect(result.success).toBe(true);
87
+ });
88
+
89
+ test('HTML node accepts an _i18n object inside a children array', () => {
90
+ const page = {
91
+ root: {
92
+ type: 'node',
93
+ tag: 'p',
94
+ children: [
95
+ { _i18n: true, en: 'Hello', pl: 'Cześć' },
96
+ ' literal ',
97
+ { type: 'node', tag: 'span', children: 'static' },
98
+ ],
99
+ },
100
+ };
101
+ const result = PageDataSchema.safeParse(page);
102
+ expect(result.success).toBe(true);
103
+ });
104
+
105
+ test('component file with localized prop defaults validates as PageData', () => {
106
+ // End-to-end regression: a real component shape written by AI with
107
+ // localized defaults must pass PageDataSchema so the editor can open it.
108
+ const componentFile = {
109
+ component: {
110
+ interface: {
111
+ title: {
112
+ type: 'string',
113
+ default: { _i18n: true, en: 'From the blog', pl: 'Z bloga' },
114
+ },
115
+ },
116
+ structure: { type: 'node', tag: 'section', children: [] },
117
+ },
118
+ };
119
+
120
+ const result = PageDataSchema.safeParse(componentFile);
121
+ expect(result.success).toBe(true);
122
+ });
123
+
31
124
  test('valid select prop definition', () => {
32
125
  const propDef = {
33
126
  type: 'select',
@@ -23,11 +23,39 @@ export const BasePropTypeSchema = z.enum(['string', 'select', 'boolean', 'number
23
23
  export const PropTypeSchema = z.enum(['string', 'select', 'boolean', 'number', 'link', 'file', 'rich-text', 'embed', 'list']);
24
24
 
25
25
  /**
26
- * Base prop definition schema (for non-list props)
26
+ * `_i18n` value object schema. Used wherever a localizable string can appear:
27
+ * - Prop defaults (`BasePropDefinitionSchema.default`)
28
+ * - Node `children` (text content on `type: "node"`, `"link"`, `"component"`,
29
+ * and basic list nodes)
30
+ * - Attribute values (`attributes` records on every node type)
31
+ *
32
+ * The runtime resolves these objects to the active locale's string at the
33
+ * earliest dispatch point in both the SSR renderer (`renderNode`) and the
34
+ * client builder (`ComponentBuilder.buildComponent`). The schema accepts the
35
+ * shape so the editor's load path doesn't reject files that contain it.
36
+ */
37
+ export const I18nValueObjectSchema = z.object({
38
+ _i18n: z.literal(true),
39
+ }).passthrough();
40
+
41
+ /**
42
+ * Base prop definition schema (for non-list props).
43
+ * `default` accepts a literal value (string/number/boolean/link) OR an
44
+ * `_i18n` value object — the SSR pipeline resolves the latter to the active
45
+ * locale's string via `resolveI18nInProps` at render time, so localized prop
46
+ * defaults are first-class. The `I18nOrStringSchema` proper is defined later
47
+ * in this file; we inline the matching shape here to keep the validator
48
+ * tolerant of localized defaults.
27
49
  */
28
50
  export const BasePropDefinitionSchema = z.object({
29
51
  type: BasePropTypeSchema,
30
- default: z.union([z.string(), z.number(), z.boolean(), z.object({ href: z.string(), target: z.string().optional() })]).optional(),
52
+ default: z.union([
53
+ z.string(),
54
+ z.number(),
55
+ z.boolean(),
56
+ z.object({ href: z.string(), target: z.string().optional() }),
57
+ I18nValueObjectSchema,
58
+ ]).optional(),
31
59
  options: z.array(z.string()).readonly().optional(),
32
60
  enumName: z.string().optional(), // For 'select' type: reference to project-level enum
33
61
  accept: z.string().optional(), // For 'file' type: MIME pattern like "image/*", "video/*"
@@ -51,7 +79,16 @@ export const ListItemSchemaSchema = z.record(z.string(), BasePropDefinitionSchem
51
79
  export const ListPropDefinitionSchema = z.object({
52
80
  type: z.literal('list'),
53
81
  itemSchema: ListItemSchemaSchema,
54
- default: z.array(z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.object({ href: z.string(), target: z.string().optional() }), z.null()]))).optional(),
82
+ // List-item field values can be any primitive, a link object, an `_i18n`
83
+ // object (resolved by the SSR pipeline), or null.
84
+ default: z.array(z.record(z.string(), z.union([
85
+ z.string(),
86
+ z.number(),
87
+ z.boolean(),
88
+ z.object({ href: z.string(), target: z.string().optional() }),
89
+ I18nValueObjectSchema,
90
+ z.null(),
91
+ ]))).optional(),
55
92
  }).passthrough();
56
93
 
57
94
  /**
@@ -175,8 +212,9 @@ export const InteractiveStylesSchema = z.array(InteractiveStyleRuleSchema);
175
212
  export const SlotMarkerSchema: z.ZodType<any> = z.lazy(() => z.object({
176
213
  type: z.literal(NODE_TYPE.SLOT),
177
214
  default: z.union([
178
- z.array(z.union([ComponentNodeSchema, z.string()])),
215
+ z.array(z.union([ComponentNodeSchema, z.string(), I18nValueObjectSchema])),
179
216
  z.string(),
217
+ I18nValueObjectSchema,
180
218
  ]).optional(),
181
219
  }).passthrough());
182
220
 
@@ -197,11 +235,12 @@ export const HtmlNodeSchema: z.ZodType<any> = z.lazy(() => z.object({
197
235
  style: StyleValueSchema.optional(),
198
236
  interactiveStyles: InteractiveStylesSchema.optional(),
199
237
  generateElementClass: z.boolean().optional(),
200
- attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
238
+ attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), I18nValueObjectSchema])).optional(),
201
239
  props: z.record(z.string(), z.any()).optional(), // Allow props for backward compatibility
202
240
  children: z.union([
203
- z.array(z.union([ComponentNodeSchema, z.string()])),
241
+ z.array(z.union([ComponentNodeSchema, z.string(), I18nValueObjectSchema])),
204
242
  z.string(),
243
+ I18nValueObjectSchema,
205
244
  ]).optional(),
206
245
  }).passthrough());
207
246
 
@@ -217,10 +256,11 @@ export const ComponentInstanceNodeSchema: z.ZodType<any> = z.lazy(() => z.object
217
256
  style: StyleValueSchema.optional(),
218
257
  interactiveStyles: InteractiveStylesSchema.optional(),
219
258
  generateElementClass: z.boolean().optional(),
220
- attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
259
+ attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), I18nValueObjectSchema])).optional(),
221
260
  children: z.union([
222
- z.array(z.union([ComponentNodeSchema, z.string()])),
261
+ z.array(z.union([ComponentNodeSchema, z.string(), I18nValueObjectSchema])),
223
262
  z.string(),
263
+ I18nValueObjectSchema,
224
264
  ]).optional(),
225
265
  }).passthrough());
226
266
 
@@ -229,13 +269,13 @@ export const ComponentInstanceNodeSchema: z.ZodType<any> = z.lazy(() => z.object
229
269
  */
230
270
  export const EmbedNodeSchema: z.ZodType<any> = z.lazy(() => z.object({
231
271
  type: z.literal(NODE_TYPE.EMBED),
232
- html: z.union([z.string(), HtmlMappingSchema]),
272
+ html: z.union([z.string(), HtmlMappingSchema, I18nValueObjectSchema]),
233
273
  label: z.string().optional(),
234
274
  if: IfConditionSchema.optional(),
235
275
  style: StyleValueSchema.optional(),
236
276
  interactiveStyles: InteractiveStylesSchema.optional(),
237
277
  generateElementClass: z.boolean().optional(),
238
- attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
278
+ attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), I18nValueObjectSchema])).optional(),
239
279
  }).passthrough());
240
280
 
241
281
  /**
@@ -243,16 +283,17 @@ export const EmbedNodeSchema: z.ZodType<any> = z.lazy(() => z.object({
243
283
  */
244
284
  export const LinkNodeSchema: z.ZodType<any> = z.lazy(() => z.object({
245
285
  type: z.literal(NODE_TYPE.LINK),
246
- href: z.union([z.string(), LinkMappingSchema]),
286
+ href: z.union([z.string(), LinkMappingSchema, I18nValueObjectSchema]),
247
287
  label: z.string().optional(),
248
288
  if: IfConditionSchema.optional(),
249
289
  style: StyleValueSchema.optional(),
250
290
  interactiveStyles: InteractiveStylesSchema.optional(),
251
291
  generateElementClass: z.boolean().optional(),
252
- attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
292
+ attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), I18nValueObjectSchema])).optional(),
253
293
  children: z.union([
254
- z.array(z.union([ComponentNodeSchema, z.string()])),
294
+ z.array(z.union([ComponentNodeSchema, z.string(), I18nValueObjectSchema])),
255
295
  z.string(),
296
+ I18nValueObjectSchema,
256
297
  ]).optional(),
257
298
  }).passthrough());
258
299
 
@@ -273,7 +314,7 @@ export const LocaleListNodeSchema: z.ZodType<any> = z.lazy(() => z.object({
273
314
  showSeparator: z.boolean().optional(),
274
315
  showFlag: z.boolean().optional(),
275
316
  flagStyle: StyleValueSchema.optional(),
276
- attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
317
+ attributes: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), I18nValueObjectSchema])).optional(),
277
318
  }).passthrough());
278
319
 
279
320
  /**
@@ -298,7 +339,7 @@ export const ListNodeSchemaBasic: z.ZodType<any> = z.lazy(() => z.object({
298
339
  offset: z.number().optional(),
299
340
  excludeCurrentItem: z.boolean().optional(),
300
341
  emitTemplate: z.boolean().optional(),
301
- children: z.array(z.union([ComponentNodeSchema, z.string()])).optional(),
342
+ children: z.array(z.union([ComponentNodeSchema, z.string(), I18nValueObjectSchema])).optional(),
302
343
  }).passthrough());
303
344
 
304
345
  /**
@@ -553,7 +594,7 @@ export const CMSSchemaSchema = z.object({
553
594
  }).passthrough();
554
595
 
555
596
  /**
556
- * CMS item schema (content entry)
597
+ * CMS item schema (content entry, strict — used at publish/save time).
557
598
  */
558
599
  export const CMSItemSchema = z.object({
559
600
  _id: z.string(),
@@ -562,6 +603,20 @@ export const CMSItemSchema = z.object({
562
603
  _createdAt: z.string().optional(),
563
604
  }).passthrough();
564
605
 
606
+ /**
607
+ * CMS draft item schema (loose — used at draft-write time).
608
+ *
609
+ * Drafts can be partial work-in-progress, so even system-managed identifiers
610
+ * are optional at this stage. Strict validation runs when promoting the draft
611
+ * to published (see `publishDraft` in `FileSystemCMSProvider`).
612
+ */
613
+ export const CMSDraftItemSchema = z.object({
614
+ _id: z.string().optional(),
615
+ _slug: z.string().optional(),
616
+ _filename: z.string().optional(),
617
+ _createdAt: z.string().optional(),
618
+ }).passthrough();
619
+
565
620
  /**
566
621
  * CMS filter condition schema
567
622
  */