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.
- package/build-astro.ts +6 -2
- package/dist/build-static.js +7 -7
- package/dist/chunks/{chunk-D5E3OKSL.js → chunk-56EUSC6D.js} +5 -5
- package/dist/chunks/{chunk-3FHJUHAS.js → chunk-7NIC4I3V.js} +300 -43
- package/dist/chunks/chunk-7NIC4I3V.js.map +7 -0
- package/dist/chunks/{chunk-B2RTLDXY.js → chunk-AZQYF6KE.js} +132 -1
- package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
- package/dist/chunks/{chunk-TPQ7APVQ.js → chunk-CVLFID6V.js} +473 -73
- package/dist/chunks/chunk-CVLFID6V.js.map +7 -0
- package/dist/chunks/{chunk-NP76N4HQ.js → chunk-EDQSMAMP.js} +13 -2
- package/dist/chunks/{chunk-NP76N4HQ.js.map → chunk-EDQSMAMP.js.map} +2 -2
- package/dist/chunks/{chunk-RQSTH2BS.js → chunk-H4JSCDNW.js} +2 -2
- package/dist/chunks/{chunk-EK4KESLU.js → chunk-J23ZX5AP.js} +8 -2
- package/dist/chunks/{chunk-EK4KESLU.js.map → chunk-J23ZX5AP.js.map} +2 -2
- package/dist/chunks/{chunk-UUA5LEWF.js → chunk-LPVETICS.js} +156 -8
- package/dist/chunks/chunk-LPVETICS.js.map +7 -0
- package/dist/chunks/{chunk-BJRKEPMP.js → chunk-PQ2HRXDR.js} +5 -2
- package/dist/chunks/chunk-PQ2HRXDR.js.map +7 -0
- package/dist/chunks/{chunk-NKUV77SR.js → chunk-YWJJD5D6.js} +133 -37
- package/dist/chunks/chunk-YWJJD5D6.js.map +7 -0
- package/dist/chunks/{configService-IGJEC3MC.js → configService-VOY2MY2K.js} +3 -3
- package/dist/entries/server-router.js +9 -9
- package/dist/entries/server-router.js.map +2 -2
- package/dist/lib/client/index.js +92 -32
- package/dist/lib/client/index.js.map +3 -3
- package/dist/lib/server/index.js +14 -12
- package/dist/lib/server/index.js.map +2 -2
- package/dist/lib/shared/index.js +46 -10
- package/dist/lib/shared/index.js.map +3 -3
- package/entries/server-router.tsx +6 -2
- package/lib/client/core/ComponentBuilder.test.ts +34 -0
- package/lib/client/core/ComponentBuilder.ts +33 -4
- package/lib/client/core/builders/embedBuilder.ts +28 -7
- package/lib/client/core/builders/linkNodeBuilder.ts +28 -7
- package/lib/client/core/builders/localeListBuilder.ts +30 -11
- package/lib/client/styles/StyleInjector.ts +3 -2
- package/lib/client/templateEngine.ts +24 -0
- package/lib/client/theme.ts +4 -4
- package/lib/server/cssGenerator.test.ts +64 -1
- package/lib/server/cssGenerator.ts +48 -9
- package/lib/server/fileWatcher.test.ts +134 -0
- package/lib/server/fileWatcher.ts +100 -32
- package/lib/server/jsonLoader.ts +1 -0
- package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
- package/lib/server/providers/fileSystemCMSProvider.ts +240 -19
- package/lib/server/routes/index.ts +1 -1
- package/lib/server/routes/pages.ts +23 -1
- package/lib/server/services/cmsService.test.ts +246 -0
- package/lib/server/services/cmsService.ts +122 -5
- package/lib/server/services/configService.ts +6 -0
- package/lib/server/services/fileWatcherService.ts +17 -0
- package/lib/server/ssr/attributeBuilder.ts +41 -0
- package/lib/server/ssr/htmlGenerator.test.ts +113 -0
- package/lib/server/ssr/htmlGenerator.ts +62 -7
- package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
- package/lib/server/ssr/ssrRenderer.test.ts +564 -0
- package/lib/server/ssr/ssrRenderer.ts +228 -49
- package/lib/server/webflow/buildWebflow.ts +1 -1
- package/lib/server/websocketManager.test.ts +61 -6
- package/lib/server/websocketManager.ts +25 -1
- package/lib/shared/cssGeneration.test.ts +267 -1
- package/lib/shared/cssGeneration.ts +240 -18
- package/lib/shared/cssProperties.test.ts +275 -1
- package/lib/shared/cssProperties.ts +223 -7
- package/lib/shared/interfaces/contentProvider.ts +39 -6
- package/lib/shared/pathSecurity.ts +16 -0
- package/lib/shared/responsiveScaling.test.ts +143 -0
- package/lib/shared/responsiveScaling.ts +253 -2
- package/lib/shared/themeDefaults.test.ts +3 -3
- package/lib/shared/themeDefaults.ts +3 -3
- package/lib/shared/types/api.ts +10 -1
- package/lib/shared/types/cms.ts +46 -12
- package/lib/shared/types/index.ts +1 -0
- package/lib/shared/utilityClassConfig.ts +3 -0
- package/lib/shared/utilityClassMapper.test.ts +123 -0
- package/lib/shared/utilityClassMapper.ts +179 -8
- package/lib/shared/validation/schemas.test.ts +93 -0
- package/lib/shared/validation/schemas.ts +71 -16
- package/lib/shared/validation/validators.ts +26 -1
- package/package.json +1 -1
- package/dist/chunks/chunk-3FHJUHAS.js.map +0 -7
- package/dist/chunks/chunk-B2RTLDXY.js.map +0 -7
- package/dist/chunks/chunk-BJRKEPMP.js.map +0 -7
- package/dist/chunks/chunk-NKUV77SR.js.map +0 -7
- package/dist/chunks/chunk-TPQ7APVQ.js.map +0 -7
- package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
- /package/dist/chunks/{chunk-D5E3OKSL.js.map → chunk-56EUSC6D.js.map} +0 -0
- /package/dist/chunks/{chunk-RQSTH2BS.js.map → chunk-H4JSCDNW.js.map} +0 -0
- /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 (
|
|
261
|
-
|
|
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
|
|
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 (
|
|
272
|
-
const
|
|
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
|
-
|
|
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
|
-
*
|
|
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([
|
|
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
|
-
|
|
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
|
*/
|