meno-core 1.0.48 → 1.0.49

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 (74) hide show
  1. package/dist/build-static.js +7 -7
  2. package/dist/chunks/{chunk-UUA5LEWF.js → chunk-6IVUG7FY.js} +138 -7
  3. package/dist/chunks/chunk-6IVUG7FY.js.map +7 -0
  4. package/dist/chunks/{chunk-B2RTLDXY.js → chunk-AZQYF6KE.js} +132 -1
  5. package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
  6. package/dist/chunks/{chunk-NKUV77SR.js → chunk-CHD5UCFF.js} +21 -9
  7. package/dist/chunks/{chunk-NKUV77SR.js.map → chunk-CHD5UCFF.js.map} +2 -2
  8. package/dist/chunks/{chunk-TPQ7APVQ.js → chunk-EQYDSPBB.js} +418 -62
  9. package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
  10. package/dist/chunks/{chunk-RQSTH2BS.js → chunk-H4JSCDNW.js} +2 -2
  11. package/dist/chunks/{chunk-EK4KESLU.js → chunk-J23ZX5AP.js} +8 -2
  12. package/dist/chunks/{chunk-EK4KESLU.js.map → chunk-J23ZX5AP.js.map} +2 -2
  13. package/dist/chunks/{chunk-D5E3OKSL.js → chunk-JER5NQVM.js} +5 -5
  14. package/dist/chunks/{chunk-BJRKEPMP.js → chunk-KPU2XHOS.js} +5 -2
  15. package/dist/chunks/{chunk-BJRKEPMP.js.map → chunk-KPU2XHOS.js.map} +2 -2
  16. package/dist/chunks/{chunk-NP76N4HQ.js → chunk-LKAGAQ3M.js} +2 -2
  17. package/dist/chunks/{chunk-3FHJUHAS.js → chunk-S2CX6HFM.js} +260 -25
  18. package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
  19. package/dist/chunks/{configService-IGJEC3MC.js → configService-CCA6AIDI.js} +3 -3
  20. package/dist/entries/server-router.js +9 -9
  21. package/dist/entries/server-router.js.map +2 -2
  22. package/dist/lib/client/index.js +54 -20
  23. package/dist/lib/client/index.js.map +3 -3
  24. package/dist/lib/server/index.js +9 -9
  25. package/dist/lib/shared/index.js +46 -10
  26. package/dist/lib/shared/index.js.map +3 -3
  27. package/entries/server-router.tsx +6 -2
  28. package/lib/client/core/ComponentBuilder.ts +8 -1
  29. package/lib/client/core/builders/embedBuilder.ts +15 -2
  30. package/lib/client/core/builders/linkNodeBuilder.ts +15 -2
  31. package/lib/client/core/builders/localeListBuilder.ts +17 -6
  32. package/lib/client/styles/StyleInjector.ts +3 -2
  33. package/lib/client/theme.ts +4 -4
  34. package/lib/server/cssGenerator.test.ts +64 -1
  35. package/lib/server/cssGenerator.ts +48 -9
  36. package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
  37. package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
  38. package/lib/server/routes/index.ts +1 -1
  39. package/lib/server/routes/pages.ts +23 -1
  40. package/lib/server/services/cmsService.test.ts +246 -0
  41. package/lib/server/services/cmsService.ts +122 -5
  42. package/lib/server/services/configService.ts +5 -0
  43. package/lib/server/ssr/attributeBuilder.ts +41 -0
  44. package/lib/server/ssr/htmlGenerator.test.ts +113 -0
  45. package/lib/server/ssr/htmlGenerator.ts +51 -4
  46. package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
  47. package/lib/server/ssr/ssrRenderer.test.ts +306 -0
  48. package/lib/server/ssr/ssrRenderer.ts +182 -44
  49. package/lib/shared/cssGeneration.test.ts +267 -1
  50. package/lib/shared/cssGeneration.ts +240 -18
  51. package/lib/shared/cssProperties.test.ts +247 -1
  52. package/lib/shared/cssProperties.ts +196 -6
  53. package/lib/shared/interfaces/contentProvider.ts +39 -6
  54. package/lib/shared/pathSecurity.ts +16 -0
  55. package/lib/shared/responsiveScaling.test.ts +143 -0
  56. package/lib/shared/responsiveScaling.ts +253 -2
  57. package/lib/shared/themeDefaults.test.ts +3 -3
  58. package/lib/shared/themeDefaults.ts +3 -3
  59. package/lib/shared/types/cms.ts +28 -3
  60. package/lib/shared/types/index.ts +1 -0
  61. package/lib/shared/utilityClassConfig.ts +3 -0
  62. package/lib/shared/utilityClassMapper.test.ts +123 -0
  63. package/lib/shared/utilityClassMapper.ts +179 -8
  64. package/lib/shared/validation/schemas.ts +15 -1
  65. package/lib/shared/validation/validators.ts +26 -1
  66. package/package.json +1 -1
  67. package/dist/chunks/chunk-3FHJUHAS.js.map +0 -7
  68. package/dist/chunks/chunk-B2RTLDXY.js.map +0 -7
  69. package/dist/chunks/chunk-TPQ7APVQ.js.map +0 -7
  70. package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
  71. /package/dist/chunks/{chunk-RQSTH2BS.js.map → chunk-H4JSCDNW.js.map} +0 -0
  72. /package/dist/chunks/{chunk-D5E3OKSL.js.map → chunk-JER5NQVM.js.map} +0 -0
  73. /package/dist/chunks/{chunk-NP76N4HQ.js.map → chunk-LKAGAQ3M.js.map} +0 -0
  74. /package/dist/chunks/{configService-IGJEC3MC.js.map → configService-CCA6AIDI.js.map} +0 -0
@@ -2,12 +2,32 @@ import { describe, test, expect } from 'bun:test';
2
2
  import {
3
3
  CSS_PROPERTIES,
4
4
  CSS_PROPERTIES_DEFINITION,
5
+ CSS_PROPERTY_GROUPS,
5
6
  filterCSSProperties,
6
7
  getPropertyValues,
7
8
  filterPropertyValues,
8
- getPropertyType
9
+ getPropertyType,
10
+ VISUAL_MODE_PROPERTIES,
11
+ isVisualModeProperty,
12
+ VISUAL_MODE_RULES,
13
+ isVisualModeRowVisible,
14
+ type VisualModeRuleContext,
15
+ UNITLESS_PROPERTIES,
16
+ appendsPxByDefault,
9
17
  } from './cssProperties';
10
18
 
19
+ const baseCtx: VisualModeRuleContext = {
20
+ display: 'block',
21
+ parentDisplay: 'block',
22
+ position: 'static',
23
+ tagName: 'div',
24
+ hasPaddingLonghand: false,
25
+ hasMarginLonghand: false,
26
+ hasBackgroundImage: false,
27
+ };
28
+ const ctx = (overrides: Partial<VisualModeRuleContext> = {}): VisualModeRuleContext =>
29
+ ({ ...baseCtx, ...overrides });
30
+
11
31
  describe('cssProperties', () => {
12
32
  describe('CSS_PROPERTIES', () => {
13
33
  test('is an array of property names', () => {
@@ -249,4 +269,230 @@ describe('cssProperties', () => {
249
269
  expect(getPropertyType('padding')).toBe('string');
250
270
  });
251
271
  });
272
+
273
+ describe('VISUAL_MODE_PROPERTIES', () => {
274
+ test('contains common Webflow-style groups', () => {
275
+ expect(VISUAL_MODE_PROPERTIES['Layout']).toContain('display');
276
+ expect(VISUAL_MODE_PROPERTIES['Spacing']).toContain('padding');
277
+ expect(VISUAL_MODE_PROPERTIES['Typography']).toContain('fontSize');
278
+ });
279
+
280
+ test('every group name maps to a known CSS_PROPERTY_GROUPS group', () => {
281
+ for (const groupName of Object.keys(VISUAL_MODE_PROPERTIES)) {
282
+ expect(CSS_PROPERTY_GROUPS[groupName]).toBeDefined();
283
+ }
284
+ });
285
+
286
+ test('every property in VISUAL_MODE_PROPERTIES belongs to its declared group in CSS_PROPERTY_GROUPS', () => {
287
+ for (const [groupName, props] of Object.entries(VISUAL_MODE_PROPERTIES)) {
288
+ for (const prop of props) {
289
+ expect(CSS_PROPERTY_GROUPS[groupName]).toContain(prop);
290
+ }
291
+ }
292
+ });
293
+ });
294
+
295
+ describe('isVisualModeProperty', () => {
296
+ test('returns true for properties listed in VISUAL_MODE_PROPERTIES', () => {
297
+ expect(isVisualModeProperty('display')).toBe(true);
298
+ expect(isVisualModeProperty('padding')).toBe(true);
299
+ expect(isVisualModeProperty('color')).toBe(true);
300
+ });
301
+
302
+ test('returns false for properties not in VISUAL_MODE_PROPERTIES', () => {
303
+ expect(isVisualModeProperty('gridTemplateRows')).toBe(false);
304
+ expect(isVisualModeProperty('clipPath')).toBe(false);
305
+ expect(isVisualModeProperty('nonExistent')).toBe(false);
306
+ });
307
+ });
308
+
309
+ describe('VISUAL_MODE_RULES — flex/grid container props', () => {
310
+ test('flexDirection visible only when display is flex/inline-flex', () => {
311
+ expect(VISUAL_MODE_RULES.flexDirection(ctx({ display: 'flex' }))).toBe(true);
312
+ expect(VISUAL_MODE_RULES.flexDirection(ctx({ display: 'inline-flex' }))).toBe(true);
313
+ expect(VISUAL_MODE_RULES.flexDirection(ctx({ display: 'block' }))).toBe(false);
314
+ expect(VISUAL_MODE_RULES.flexDirection(ctx({ display: 'grid' }))).toBe(false);
315
+ });
316
+
317
+ test('justifyContent visible for flex AND grid', () => {
318
+ expect(VISUAL_MODE_RULES.justifyContent(ctx({ display: 'flex' }))).toBe(true);
319
+ expect(VISUAL_MODE_RULES.justifyContent(ctx({ display: 'grid' }))).toBe(true);
320
+ expect(VISUAL_MODE_RULES.justifyContent(ctx({ display: 'block' }))).toBe(false);
321
+ });
322
+
323
+ test('gridTemplateColumns visible only for grid/inline-grid', () => {
324
+ expect(VISUAL_MODE_RULES.gridTemplateColumns(ctx({ display: 'grid' }))).toBe(true);
325
+ expect(VISUAL_MODE_RULES.gridTemplateColumns(ctx({ display: 'inline-grid' }))).toBe(true);
326
+ expect(VISUAL_MODE_RULES.gridTemplateColumns(ctx({ display: 'flex' }))).toBe(false);
327
+ expect(VISUAL_MODE_RULES.gridTemplateColumns(ctx({ display: 'block' }))).toBe(false);
328
+ });
329
+
330
+ test('gap visible for both flex and grid containers', () => {
331
+ expect(VISUAL_MODE_RULES.gap(ctx({ display: 'flex' }))).toBe(true);
332
+ expect(VISUAL_MODE_RULES.gap(ctx({ display: 'grid' }))).toBe(true);
333
+ expect(VISUAL_MODE_RULES.gap(ctx({ display: 'block' }))).toBe(false);
334
+ });
335
+ });
336
+
337
+ describe('VISUAL_MODE_RULES — flex/grid item props (parent display)', () => {
338
+ test('flexGrow visible only when parent is flex', () => {
339
+ expect(VISUAL_MODE_RULES.flexGrow(ctx({ parentDisplay: 'flex' }))).toBe(true);
340
+ expect(VISUAL_MODE_RULES.flexGrow(ctx({ parentDisplay: 'inline-flex' }))).toBe(true);
341
+ expect(VISUAL_MODE_RULES.flexGrow(ctx({ parentDisplay: 'block' }))).toBe(false);
342
+ expect(VISUAL_MODE_RULES.flexGrow(ctx({ parentDisplay: 'grid' }))).toBe(false);
343
+ });
344
+
345
+ test('gridArea visible only when parent is grid', () => {
346
+ expect(VISUAL_MODE_RULES.gridArea(ctx({ parentDisplay: 'grid' }))).toBe(true);
347
+ expect(VISUAL_MODE_RULES.gridArea(ctx({ parentDisplay: 'inline-grid' }))).toBe(true);
348
+ expect(VISUAL_MODE_RULES.gridArea(ctx({ parentDisplay: 'flex' }))).toBe(false);
349
+ expect(VISUAL_MODE_RULES.gridArea(ctx({ parentDisplay: 'block' }))).toBe(false);
350
+ });
351
+
352
+ test('alignSelf visible when parent is flex OR grid', () => {
353
+ expect(VISUAL_MODE_RULES.alignSelf(ctx({ parentDisplay: 'flex' }))).toBe(true);
354
+ expect(VISUAL_MODE_RULES.alignSelf(ctx({ parentDisplay: 'grid' }))).toBe(true);
355
+ expect(VISUAL_MODE_RULES.alignSelf(ctx({ parentDisplay: 'block' }))).toBe(false);
356
+ });
357
+
358
+ test('item rules ignore own display — only parent display matters', () => {
359
+ // Element has display:block but parent is flex → flexGrow still visible.
360
+ expect(VISUAL_MODE_RULES.flexGrow(ctx({ display: 'block', parentDisplay: 'flex' }))).toBe(true);
361
+ // Element has display:flex but parent is block → flexGrow hidden.
362
+ expect(VISUAL_MODE_RULES.flexGrow(ctx({ display: 'flex', parentDisplay: 'block' }))).toBe(false);
363
+ });
364
+ });
365
+
366
+ describe('VISUAL_MODE_RULES — position-dependent', () => {
367
+ test('top/right/bottom/left/zIndex hidden when position is static', () => {
368
+ const c = ctx({ position: 'static' });
369
+ expect(VISUAL_MODE_RULES.top(c)).toBe(false);
370
+ expect(VISUAL_MODE_RULES.right(c)).toBe(false);
371
+ expect(VISUAL_MODE_RULES.bottom(c)).toBe(false);
372
+ expect(VISUAL_MODE_RULES.left(c)).toBe(false);
373
+ expect(VISUAL_MODE_RULES.zIndex(c)).toBe(false);
374
+ });
375
+
376
+ test('top/right/bottom/left/zIndex visible when position is non-static', () => {
377
+ for (const position of ['relative', 'absolute', 'fixed', 'sticky']) {
378
+ const c = ctx({ position });
379
+ expect(VISUAL_MODE_RULES.top(c)).toBe(true);
380
+ expect(VISUAL_MODE_RULES.zIndex(c)).toBe(true);
381
+ }
382
+ });
383
+ });
384
+
385
+ describe('VISUAL_MODE_RULES — tag-based', () => {
386
+ test('objectFit visible only for img/video', () => {
387
+ expect(VISUAL_MODE_RULES.objectFit(ctx({ tagName: 'img' }))).toBe(true);
388
+ expect(VISUAL_MODE_RULES.objectFit(ctx({ tagName: 'video' }))).toBe(true);
389
+ expect(VISUAL_MODE_RULES.objectFit(ctx({ tagName: 'div' }))).toBe(false);
390
+ });
391
+
392
+ test('listStyle visible only for ul/ol/li', () => {
393
+ expect(VISUAL_MODE_RULES.listStyle(ctx({ tagName: 'ul' }))).toBe(true);
394
+ expect(VISUAL_MODE_RULES.listStyle(ctx({ tagName: 'ol' }))).toBe(true);
395
+ expect(VISUAL_MODE_RULES.listStyle(ctx({ tagName: 'li' }))).toBe(true);
396
+ expect(VISUAL_MODE_RULES.listStyle(ctx({ tagName: 'div' }))).toBe(false);
397
+ });
398
+ });
399
+
400
+ describe('VISUAL_MODE_RULES — padding/margin shorthand-vs-longhand toggle', () => {
401
+ test('padding shown when no longhand set; longhands hidden', () => {
402
+ const c = ctx({ hasPaddingLonghand: false });
403
+ expect(VISUAL_MODE_RULES.padding(c)).toBe(true);
404
+ expect(VISUAL_MODE_RULES.paddingTop(c)).toBe(false);
405
+ expect(VISUAL_MODE_RULES.paddingRight(c)).toBe(false);
406
+ expect(VISUAL_MODE_RULES.paddingBottom(c)).toBe(false);
407
+ expect(VISUAL_MODE_RULES.paddingLeft(c)).toBe(false);
408
+ });
409
+
410
+ test('padding hidden when any longhand set; longhands shown', () => {
411
+ const c = ctx({ hasPaddingLonghand: true });
412
+ expect(VISUAL_MODE_RULES.padding(c)).toBe(false);
413
+ expect(VISUAL_MODE_RULES.paddingTop(c)).toBe(true);
414
+ expect(VISUAL_MODE_RULES.paddingRight(c)).toBe(true);
415
+ expect(VISUAL_MODE_RULES.paddingBottom(c)).toBe(true);
416
+ expect(VISUAL_MODE_RULES.paddingLeft(c)).toBe(true);
417
+ });
418
+
419
+ test('margin mirrors padding behavior', () => {
420
+ const noLong = ctx({ hasMarginLonghand: false });
421
+ expect(VISUAL_MODE_RULES.margin(noLong)).toBe(true);
422
+ expect(VISUAL_MODE_RULES.marginTop(noLong)).toBe(false);
423
+ const withLong = ctx({ hasMarginLonghand: true });
424
+ expect(VISUAL_MODE_RULES.margin(withLong)).toBe(false);
425
+ expect(VISUAL_MODE_RULES.marginTop(withLong)).toBe(true);
426
+ });
427
+ });
428
+
429
+ describe('isVisualModeRowVisible', () => {
430
+ test('returns true for properties without a rule', () => {
431
+ // width has no rule entry → always visible regardless of context.
432
+ expect(isVisualModeRowVisible('width', ctx())).toBe(true);
433
+ expect(isVisualModeRowVisible('color', ctx())).toBe(true);
434
+ expect(isVisualModeRowVisible('nonExistentProp', ctx())).toBe(true);
435
+ });
436
+
437
+ test('delegates to the rule for known properties', () => {
438
+ expect(isVisualModeRowVisible('flexDirection', ctx({ display: 'flex' }))).toBe(true);
439
+ expect(isVisualModeRowVisible('flexDirection', ctx({ display: 'block' }))).toBe(false);
440
+ expect(isVisualModeRowVisible('top', ctx({ position: 'absolute' }))).toBe(true);
441
+ expect(isVisualModeRowVisible('top', ctx({ position: 'static' }))).toBe(false);
442
+ });
443
+ });
444
+
445
+ describe('UNITLESS_PROPERTIES', () => {
446
+ test('contains the canonical unitless props', () => {
447
+ expect(UNITLESS_PROPERTIES.has('fontWeight')).toBe(true);
448
+ expect(UNITLESS_PROPERTIES.has('lineHeight')).toBe(true);
449
+ expect(UNITLESS_PROPERTIES.has('opacity')).toBe(true);
450
+ expect(UNITLESS_PROPERTIES.has('zIndex')).toBe(true);
451
+ expect(UNITLESS_PROPERTIES.has('flexGrow')).toBe(true);
452
+ expect(UNITLESS_PROPERTIES.has('order')).toBe(true);
453
+ expect(UNITLESS_PROPERTIES.has('aspectRatio')).toBe(true);
454
+ });
455
+
456
+ test('does not contain length-accepting props', () => {
457
+ expect(UNITLESS_PROPERTIES.has('width')).toBe(false);
458
+ expect(UNITLESS_PROPERTIES.has('padding')).toBe(false);
459
+ expect(UNITLESS_PROPERTIES.has('fontSize')).toBe(false);
460
+ });
461
+ });
462
+
463
+ describe('appendsPxByDefault', () => {
464
+ test('returns true for length-accepting props', () => {
465
+ expect(appendsPxByDefault('width')).toBe(true);
466
+ expect(appendsPxByDefault('height')).toBe(true);
467
+ expect(appendsPxByDefault('padding')).toBe(true);
468
+ expect(appendsPxByDefault('paddingTop')).toBe(true);
469
+ expect(appendsPxByDefault('margin')).toBe(true);
470
+ expect(appendsPxByDefault('gap')).toBe(true);
471
+ expect(appendsPxByDefault('fontSize')).toBe(true);
472
+ expect(appendsPxByDefault('borderRadius')).toBe(true);
473
+ expect(appendsPxByDefault('top')).toBe(true);
474
+ expect(appendsPxByDefault('left')).toBe(true);
475
+ });
476
+
477
+ test('returns false for unitless props', () => {
478
+ expect(appendsPxByDefault('lineHeight')).toBe(false);
479
+ expect(appendsPxByDefault('fontWeight')).toBe(false);
480
+ expect(appendsPxByDefault('opacity')).toBe(false);
481
+ expect(appendsPxByDefault('zIndex')).toBe(false);
482
+ expect(appendsPxByDefault('flexGrow')).toBe(false);
483
+ expect(appendsPxByDefault('order')).toBe(false);
484
+ expect(appendsPxByDefault('aspectRatio')).toBe(false);
485
+ });
486
+
487
+ test('returns false for keyword-only (select / boolean) props', () => {
488
+ expect(appendsPxByDefault('display')).toBe(false);
489
+ expect(appendsPxByDefault('position')).toBe(false);
490
+ expect(appendsPxByDefault('borderStyle')).toBe(false);
491
+ });
492
+
493
+ test('defaults to true for properties without a recognized unitless / keyword type', () => {
494
+ expect(appendsPxByDefault('inset')).toBe(true);
495
+ expect(appendsPxByDefault('someUnknownNewCSSProperty')).toBe(true);
496
+ });
497
+ });
252
498
  });
@@ -421,14 +421,15 @@ function getPropertyPriority(property: string): number {
421
421
  * Order determines display order in the UI
422
422
  */
423
423
  export const CSS_PROPERTY_GROUPS: Record<string, string[]> = {
424
- 'Layout': ['display', 'position', 'top', 'right', 'bottom', 'left', 'inset', 'zIndex'],
425
- 'Dimensions': ['width', 'height', 'minWidth', 'maxWidth', 'minHeight', 'maxHeight', 'aspectRatio'],
426
- 'Spacing': ['margin', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', 'gap', 'rowGap', 'columnGap'],
427
- 'Flexbox': ['flex', 'flexDirection', 'flexWrap', 'flexFlow', 'justifyContent', 'alignItems', 'alignContent', 'alignSelf', 'flexGrow', 'flexShrink', 'flexBasis', 'order'],
424
+ 'Layout': ['display'],
425
+ 'Spacing': ['margin', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'],
426
+ 'Size': ['width', 'height', 'minWidth', 'maxWidth', 'minHeight', 'maxHeight', 'aspectRatio'],
427
+ 'Position': ['position', 'top', 'right', 'bottom', 'left', 'inset', 'zIndex'],
428
+ 'Flexbox': ['flex', 'flexDirection', 'flexWrap', 'flexFlow', 'justifyContent', 'alignItems', 'alignContent', 'gap', 'rowGap', 'columnGap', 'alignSelf', 'flexGrow', 'flexShrink', 'flexBasis', 'order'],
428
429
  'Grid': ['grid', 'gridTemplateColumns', 'gridTemplateRows', 'gridTemplateAreas', 'gridGap', 'gridColumn', 'gridRow', 'gridArea', 'gridAutoFlow', 'gridAutoColumns', 'gridAutoRows', 'justifyItems', 'justifySelf', 'placeContent', 'placeItems', 'placeSelf'],
429
- 'Typography': ['fontSize', 'fontWeight', 'fontFamily', 'fontStyle', 'lineHeight', 'textAlign', 'textDecoration', 'textTransform', 'letterSpacing', 'wordSpacing', 'wordBreak', 'overflowWrap', 'textIndent', 'verticalAlign', 'color'],
430
+ 'Typography': ['fontWeight', 'fontSize', 'fontFamily', 'fontStyle', 'lineHeight', 'color', 'textAlign', 'textDecoration', 'textTransform', 'letterSpacing', 'wordSpacing', 'wordBreak', 'overflowWrap', 'textIndent', 'verticalAlign'],
430
431
  'Background': ['background', 'backgroundColor', 'backgroundImage', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat', 'opacity'],
431
- 'Borders': ['border', 'borderWidth', 'borderStyle', 'borderColor', 'borderTop', 'borderRight', 'borderBottom', 'borderLeft', 'borderRadius', 'borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius'],
432
+ 'Borders': ['borderRadius', 'borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius', 'border', 'borderWidth', 'borderStyle', 'borderColor', 'borderTop', 'borderRight', 'borderBottom', 'borderLeft'],
432
433
  'Outline': ['outline', 'outlineWidth', 'outlineStyle', 'outlineColor', 'outlineOffset'],
433
434
  'Effects': ['boxShadow', 'textShadow', 'filter', 'backdropFilter', 'transform', 'transformOrigin', 'transition', 'animation', 'backfaceVisibility', 'mixBlendMode', 'clipPath'],
434
435
  'Overflow': ['overflow', 'overflowX', 'overflowY', 'whiteSpace', 'textOverflow', 'visibility', 'content'],
@@ -452,6 +453,165 @@ export function getPropertyGroup(propertyName: string): string {
452
453
  return 'Other';
453
454
  }
454
455
 
456
+ /**
457
+ * Subset of CSS properties shown as "always-visible" rows in the visual style
458
+ * editor mode (Webflow-like). Each property here renders even when unset, so
459
+ * the user can see at a glance which properties are available and click an
460
+ * empty row to start typing a value.
461
+ *
462
+ * Keys must be group names from CSS_PROPERTY_GROUPS so the visual editor can
463
+ * reuse the same group headers as list mode.
464
+ */
465
+ export const VISUAL_MODE_PROPERTIES: Record<string, string[]> = {
466
+ 'Layout': ['display'],
467
+ 'Grid': ['gridTemplateColumns'],
468
+ 'Flexbox': ['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gap', 'flexGrow', 'flexShrink', 'flexBasis'],
469
+ 'Spacing': ['margin', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', 'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'],
470
+ 'Size': ['width', 'height', 'minWidth', 'minHeight', 'maxWidth', 'maxHeight'],
471
+ 'Position': ['position', 'top', 'right', 'bottom', 'left', 'zIndex'],
472
+ 'Typography': ['fontFamily', 'fontWeight', 'fontSize', 'lineHeight', 'color', 'letterSpacing', 'textAlign', 'textTransform', 'textDecoration'],
473
+ 'Background': ['backgroundColor', 'backgroundImage', 'backgroundSize', 'backgroundPosition', 'backgroundRepeat', 'opacity'],
474
+ 'Borders': ['borderRadius', 'borderWidth', 'borderStyle', 'borderColor'],
475
+ 'Effects': ['boxShadow', 'transform', 'transition', 'filter'],
476
+ 'Overflow': ['overflow', 'whiteSpace'],
477
+ 'Interaction': ['cursor', 'pointerEvents'],
478
+ };
479
+
480
+ const VISUAL_MODE_PROPERTIES_SET = new Set(
481
+ Object.values(VISUAL_MODE_PROPERTIES).flat()
482
+ );
483
+
484
+ /**
485
+ * True if `prop` belongs to the always-visible visual-mode list.
486
+ */
487
+ export function isVisualModeProperty(prop: string): boolean {
488
+ return VISUAL_MODE_PROPERTIES_SET.has(prop);
489
+ }
490
+
491
+ /**
492
+ * Context used by visual-mode visibility rules. Captures everything a rule
493
+ * needs to decide whether a property row is meaningful for the currently
494
+ * selected element. Built once per render in `StyleEditor`.
495
+ */
496
+ export interface VisualModeRuleContext {
497
+ /** Effective `display` of the selected element — getComputedStyle().display
498
+ * with declared (instance/inherited/effective) merge as fallback when the
499
+ * iframe hasn't loaded yet. Defaults to 'block'. */
500
+ display: string;
501
+ /** Effective `display` of the DOM parent — used for flex/grid item props
502
+ * whose applicability depends on the parent's layout mode. '' when no
503
+ * parent is reachable (root element, iframe missing). */
504
+ parentDisplay: string;
505
+ /** Effective `position` of the selected element. Defaults to 'static'. */
506
+ position: string;
507
+ /** Lowercase HTML tag name of the selected element ('div', 'img', 'ul'…). */
508
+ tagName: string;
509
+ /** True if any of paddingTop / paddingRight / paddingBottom / paddingLeft
510
+ * is set anywhere in the cascade (instance + inherited + effective).
511
+ * When true, the panel shows the four longhand rows and hides `padding`. */
512
+ hasPaddingLonghand: boolean;
513
+ /** Same as `hasPaddingLonghand` for the four `margin*` longhands. */
514
+ hasMarginLonghand: boolean;
515
+ /** True if `backgroundImage` (or the `background` shorthand) is set anywhere
516
+ * in the cascade. Drives visibility of background-image-only properties
517
+ * like `backgroundPosition` / `backgroundSize` / `backgroundRepeat`. */
518
+ hasBackgroundImage: boolean;
519
+ }
520
+
521
+ export type VisualModeRule = (ctx: VisualModeRuleContext) => boolean;
522
+
523
+ const isFlexDisplay = (d: string) => d === 'flex' || d === 'inline-flex';
524
+ const isGridDisplay = (d: string) => d === 'grid' || d === 'inline-grid';
525
+ const isListTag = (t: string) => t === 'ul' || t === 'ol' || t === 'li';
526
+ const isMediaTag = (t: string) => t === 'img' || t === 'video';
527
+
528
+ /**
529
+ * Visibility rules for the visual-mode style panel. Each entry is a predicate
530
+ * that returns true when the row should be rendered for the current element
531
+ * context. Properties without an entry are always visible. Properties that
532
+ * are *explicitly set* on the element bypass these rules (the explicit-value
533
+ * override is enforced by the caller, not here).
534
+ */
535
+ export const VISUAL_MODE_RULES: Record<string, VisualModeRule> = {
536
+ // Flex/Grid CONTAINER props — own display must be flex/grid.
537
+ flexDirection: ctx => isFlexDisplay(ctx.display),
538
+ flexWrap: ctx => isFlexDisplay(ctx.display),
539
+ justifyContent: ctx => isFlexDisplay(ctx.display) || isGridDisplay(ctx.display),
540
+ alignItems: ctx => isFlexDisplay(ctx.display) || isGridDisplay(ctx.display),
541
+ alignContent: ctx => isFlexDisplay(ctx.display) || isGridDisplay(ctx.display),
542
+ gap: ctx => isFlexDisplay(ctx.display) || isGridDisplay(ctx.display),
543
+ rowGap: ctx => isFlexDisplay(ctx.display) || isGridDisplay(ctx.display),
544
+ columnGap: ctx => isFlexDisplay(ctx.display) || isGridDisplay(ctx.display),
545
+ gridTemplateColumns: ctx => isGridDisplay(ctx.display),
546
+ gridTemplateRows: ctx => isGridDisplay(ctx.display),
547
+ gridTemplateAreas: ctx => isGridDisplay(ctx.display),
548
+ gridGap: ctx => isGridDisplay(ctx.display),
549
+ gridAutoFlow: ctx => isGridDisplay(ctx.display),
550
+ gridAutoColumns: ctx => isGridDisplay(ctx.display),
551
+ gridAutoRows: ctx => isGridDisplay(ctx.display),
552
+ justifyItems: ctx => isGridDisplay(ctx.display),
553
+ placeItems: ctx => isGridDisplay(ctx.display),
554
+ placeContent: ctx => isGridDisplay(ctx.display),
555
+
556
+ // Flex/Grid ITEM props — PARENT display must be flex/grid.
557
+ flex: ctx => isFlexDisplay(ctx.parentDisplay),
558
+ flexFlow: ctx => isFlexDisplay(ctx.parentDisplay),
559
+ flexGrow: ctx => isFlexDisplay(ctx.parentDisplay),
560
+ flexShrink: ctx => isFlexDisplay(ctx.parentDisplay),
561
+ flexBasis: ctx => isFlexDisplay(ctx.parentDisplay),
562
+ alignSelf: ctx => isFlexDisplay(ctx.parentDisplay) || isGridDisplay(ctx.parentDisplay),
563
+ justifySelf: ctx => isGridDisplay(ctx.parentDisplay),
564
+ placeSelf: ctx => isGridDisplay(ctx.parentDisplay),
565
+ order: ctx => isFlexDisplay(ctx.parentDisplay) || isGridDisplay(ctx.parentDisplay),
566
+ gridArea: ctx => isGridDisplay(ctx.parentDisplay),
567
+ gridColumn: ctx => isGridDisplay(ctx.parentDisplay),
568
+ gridRow: ctx => isGridDisplay(ctx.parentDisplay),
569
+
570
+ // Position-dependent inset properties.
571
+ top: ctx => ctx.position !== 'static',
572
+ right: ctx => ctx.position !== 'static',
573
+ bottom: ctx => ctx.position !== 'static',
574
+ left: ctx => ctx.position !== 'static',
575
+ inset: ctx => ctx.position !== 'static',
576
+ zIndex: ctx => ctx.position !== 'static',
577
+
578
+ // Tag-based.
579
+ objectFit: ctx => isMediaTag(ctx.tagName),
580
+ objectPosition: ctx => isMediaTag(ctx.tagName),
581
+ listStyle: ctx => isListTag(ctx.tagName),
582
+ listStyleType: ctx => isListTag(ctx.tagName),
583
+ listStylePosition: ctx => isListTag(ctx.tagName),
584
+
585
+ // Padding/margin shorthand-vs-longhand auto-toggle.
586
+ // When ANY longhand is set, hide shorthand; otherwise hide longhands.
587
+ padding: ctx => !ctx.hasPaddingLonghand,
588
+ paddingTop: ctx => ctx.hasPaddingLonghand,
589
+ paddingRight: ctx => ctx.hasPaddingLonghand,
590
+ paddingBottom: ctx => ctx.hasPaddingLonghand,
591
+ paddingLeft: ctx => ctx.hasPaddingLonghand,
592
+ margin: ctx => !ctx.hasMarginLonghand,
593
+ marginTop: ctx => ctx.hasMarginLonghand,
594
+ marginRight: ctx => ctx.hasMarginLonghand,
595
+ marginBottom: ctx => ctx.hasMarginLonghand,
596
+ marginLeft: ctx => ctx.hasMarginLonghand,
597
+
598
+ // Background-image-only props — meaningless without a background image.
599
+ backgroundPosition: ctx => ctx.hasBackgroundImage,
600
+ backgroundSize: ctx => ctx.hasBackgroundImage,
601
+ backgroundRepeat: ctx => ctx.hasBackgroundImage,
602
+ };
603
+
604
+ /**
605
+ * True if `prop` should be visible in the visual-mode panel given `ctx`.
606
+ * Properties without an entry in `VISUAL_MODE_RULES` default to visible.
607
+ * Callers are responsible for the explicit-value override (a property that
608
+ * the user has set must remain visible regardless of the rule outcome).
609
+ */
610
+ export function isVisualModeRowVisible(prop: string, ctx: VisualModeRuleContext): boolean {
611
+ const rule = VISUAL_MODE_RULES[prop];
612
+ return rule ? rule(ctx) : true;
613
+ }
614
+
455
615
  /**
456
616
  * Check if property matches the abbreviation pattern
457
617
  * For example, "bC" matches "backgroundColor" (b→b, C→C capital letter)
@@ -563,3 +723,33 @@ export function filterPropertyValues(propertyName: string, input: string): strin
563
723
  export function getPropertyType(propertyName: string): 'string' | 'select' | 'boolean' | 'number' | undefined {
564
724
  return CSS_PROPERTIES_DEFINITION[propertyName]?.type;
565
725
  }
726
+
727
+ /**
728
+ * CSS properties whose numeric values are unitless by spec — `font-weight: 400`,
729
+ * `line-height: 1.5`, `opacity: 0.8`, `z-index: 10`, etc. The auto-px commit
730
+ * normalizer must NOT append `px` to bare numbers for these.
731
+ */
732
+ export const UNITLESS_PROPERTIES: ReadonlySet<string> = new Set([
733
+ 'fontWeight', 'lineHeight',
734
+ 'opacity', 'fillOpacity', 'strokeOpacity', 'stopOpacity',
735
+ 'zIndex', 'order',
736
+ 'flexGrow', 'flexShrink', 'flex',
737
+ 'columnCount', 'columns', 'tabSize', 'orphans', 'widows',
738
+ 'gridRow', 'gridColumn', 'gridRowStart', 'gridRowEnd',
739
+ 'gridColumnStart', 'gridColumnEnd',
740
+ 'animationIterationCount',
741
+ 'aspectRatio',
742
+ 'scale',
743
+ ]);
744
+
745
+ /**
746
+ * Whether bare numeric values for `propertyName` should be treated as pixels at
747
+ * commit time (e.g. typing `52` for `width` becomes `52px`). Unitless props,
748
+ * `select`/`boolean` keyword props, and existing `number`-typed entries return false.
749
+ */
750
+ export function appendsPxByDefault(propertyName: string): boolean {
751
+ if (UNITLESS_PROPERTIES.has(propertyName)) return false;
752
+ const def = CSS_PROPERTIES_DEFINITION[propertyName];
753
+ if (def?.type === 'select' || def?.type === 'boolean' || def?.type === 'number') return false;
754
+ return true;
755
+ }
@@ -99,26 +99,59 @@ export interface CMSSchemaInfo {
99
99
  * CMS Provider Interface
100
100
  * Abstracts CMS data loading from the underlying storage mechanism
101
101
  * (follows same pattern as PageProvider)
102
+ *
103
+ * Draft model: each item may have a published version (`{filename}.json`)
104
+ * and/or a draft version (`{filename}.draft.json`). The published-facing
105
+ * methods (`getItems`, `getItemByFilename`, `getItemBySlug`, `getItemById`)
106
+ * return ONLY published items. Draft access goes through the `*Draft`
107
+ * methods. SSR and the static export must never read drafts.
102
108
  */
103
109
  export interface CMSProvider {
104
110
  /** Load all CMS schemas (extracted from page files with source: 'cms') */
105
111
  getAllSchemas(): Promise<Map<string, CMSSchemaInfo>>;
106
112
 
107
- /** Load all items for a collection */
113
+ /** Load all PUBLISHED items for a collection */
108
114
  getItems(collection: string): Promise<CMSItem[]>;
109
115
 
110
- /** Get single item by filename (stable identifier) */
116
+ /** Get single PUBLISHED item by filename (stable identifier) */
111
117
  getItemByFilename(collection: string, filename: string): Promise<CMSItem | null>;
112
118
 
113
- /** Get single item by slug (backward compat - maps to filename lookup) */
119
+ /** Get single PUBLISHED item by slug (backward compat - maps to filename lookup) */
114
120
  getItemBySlug(collection: string, slug: string): Promise<CMSItem | null>;
115
121
 
116
- /** Get single item by ID */
122
+ /** Get single PUBLISHED item by ID */
117
123
  getItemById(collection: string, id: string): Promise<CMSItem | null>;
118
124
 
119
- /** Save item (for editor) - saves to cms/<collection>/<_filename>.json */
125
+ /** Save item (published) - writes to cms/<collection>/<_filename>.json */
120
126
  saveItem(collection: string, item: CMSItem): Promise<void>;
121
127
 
122
- /** Delete item by filename */
128
+ /**
129
+ * Delete item by filename. Removes both the published file and any
130
+ * accompanying draft file.
131
+ */
123
132
  deleteItem(collection: string, filename: string): Promise<void>;
133
+
134
+ // ---- Draft-version methods --------------------------------------------
135
+
136
+ /** Get the draft version of an item, or null if no draft exists */
137
+ getDraft(collection: string, filename: string): Promise<CMSItem | null>;
138
+
139
+ /** List all drafts in a collection (used by Studio item list for badges) */
140
+ getAllDrafts(collection: string): Promise<CMSItem[]>;
141
+
142
+ /** Whether a draft file exists for the given filename */
143
+ hasDraft(collection: string, filename: string): Promise<boolean>;
144
+
145
+ /** Save the draft version of an item to cms/<collection>/<_filename>.draft.json */
146
+ saveDraft(collection: string, item: CMSItem): Promise<void>;
147
+
148
+ /** Discard the draft version of an item (no-op if no draft exists) */
149
+ discardDraft(collection: string, filename: string): Promise<void>;
150
+
151
+ /**
152
+ * Promote a draft to published. Reads `{filename}.draft.json`, writes its
153
+ * content to `{filename}.json`, then removes the draft file.
154
+ * Returns the newly published item. Throws if no draft exists.
155
+ */
156
+ publishDraft(collection: string, filename: string): Promise<CMSItem>;
124
157
  }
@@ -93,3 +93,19 @@ export const SAFE_IDENTIFIER_REGEX = /^[a-zA-Z0-9_-]+$/;
93
93
  export function isValidIdentifier(name: string): boolean {
94
94
  return SAFE_IDENTIFIER_REGEX.test(name);
95
95
  }
96
+
97
+ /**
98
+ * Suffix used for CMS draft files: `{filename}.draft.json`. The provider must
99
+ * never accept user-supplied filenames ending in `.draft`, because that would
100
+ * clash with the draft-suffix convention and let a user shadow another item's
101
+ * draft file.
102
+ */
103
+ export const CMS_DRAFT_SUFFIX = '.draft';
104
+
105
+ /**
106
+ * True when a user-supplied CMS filename collides with the reserved `.draft`
107
+ * suffix. Use as a hard reject in CMS provider write paths.
108
+ */
109
+ export function isReservedDraftFilename(filename: string): boolean {
110
+ return filename.endsWith(CMS_DRAFT_SUFFIX);
111
+ }