meno-core 1.0.47 → 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 (97) hide show
  1. package/build-astro.ts +2 -2
  2. package/dist/build-static.js +7 -7
  3. package/dist/chunks/{chunk-UUA5LEWF.js → chunk-6IVUG7FY.js} +138 -7
  4. package/dist/chunks/chunk-6IVUG7FY.js.map +7 -0
  5. package/dist/chunks/{chunk-XSWR3QLI.js → chunk-AZQYF6KE.js} +261 -130
  6. package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
  7. package/dist/chunks/{chunk-47UNLQUU.js → chunk-CHD5UCFF.js} +57 -12
  8. package/dist/chunks/chunk-CHD5UCFF.js.map +7 -0
  9. package/dist/chunks/{chunk-FGUZOYJX.js → chunk-EQYDSPBB.js} +435 -131
  10. package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
  11. package/dist/chunks/{chunk-IF3RATBY.js → chunk-H4JSCDNW.js} +2 -2
  12. package/dist/chunks/{chunk-KITQJYZV.js → chunk-J23ZX5AP.js} +40 -4
  13. package/dist/chunks/chunk-J23ZX5AP.js.map +7 -0
  14. package/dist/chunks/{chunk-LJFB5EBT.js → chunk-JER5NQVM.js} +5 -5
  15. package/dist/chunks/{chunk-ZTKHJQ2Z.js → chunk-KPU2XHOS.js} +5 -2
  16. package/dist/chunks/{chunk-ZTKHJQ2Z.js.map → chunk-KPU2XHOS.js.map} +2 -2
  17. package/dist/chunks/{chunk-BCLGRZ3U.js → chunk-LKAGAQ3M.js} +2 -2
  18. package/dist/chunks/{chunk-FED5MME6.js → chunk-S2CX6HFM.js} +262 -26
  19. package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
  20. package/dist/chunks/{configService-DYCUEURL.js → configService-CCA6AIDI.js} +3 -3
  21. package/dist/entries/server-router.js +9 -9
  22. package/dist/entries/server-router.js.map +2 -2
  23. package/dist/lib/client/index.js +64 -20
  24. package/dist/lib/client/index.js.map +3 -3
  25. package/dist/lib/server/index.js +1737 -296
  26. package/dist/lib/server/index.js.map +4 -4
  27. package/dist/lib/shared/index.js +50 -10
  28. package/dist/lib/shared/index.js.map +3 -3
  29. package/entries/server-router.tsx +6 -2
  30. package/lib/client/core/ComponentBuilder.test.ts +17 -0
  31. package/lib/client/core/ComponentBuilder.ts +25 -1
  32. package/lib/client/core/builders/embedBuilder.ts +15 -2
  33. package/lib/client/core/builders/linkNodeBuilder.ts +15 -2
  34. package/lib/client/core/builders/localeListBuilder.ts +17 -6
  35. package/lib/client/styles/StyleInjector.ts +3 -2
  36. package/lib/client/theme.ts +4 -4
  37. package/lib/server/cssGenerator.test.ts +64 -1
  38. package/lib/server/cssGenerator.ts +48 -9
  39. package/lib/server/index.ts +1 -1
  40. package/lib/server/jsonLoader.test.ts +0 -17
  41. package/lib/server/jsonLoader.ts +0 -81
  42. package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
  43. package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
  44. package/lib/server/routes/api/variables.ts +4 -2
  45. package/lib/server/routes/index.ts +1 -1
  46. package/lib/server/routes/pages.ts +23 -1
  47. package/lib/server/services/cmsService.test.ts +246 -0
  48. package/lib/server/services/cmsService.ts +122 -5
  49. package/lib/server/services/configService.ts +5 -0
  50. package/lib/server/ssr/attributeBuilder.ts +41 -0
  51. package/lib/server/ssr/htmlGenerator.test.ts +114 -2
  52. package/lib/server/ssr/htmlGenerator.ts +53 -6
  53. package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
  54. package/lib/server/ssr/ssrRenderer.test.ts +362 -1
  55. package/lib/server/ssr/ssrRenderer.ts +216 -72
  56. package/lib/server/utils/jsonLineMapper.test.ts +53 -1
  57. package/lib/server/utils/jsonLineMapper.ts +43 -3
  58. package/lib/server/webflow/buildWebflow.ts +343 -123
  59. package/lib/server/webflow/index.ts +1 -0
  60. package/lib/server/webflow/nodeToWebflow.test.ts +3170 -0
  61. package/lib/server/webflow/nodeToWebflow.ts +2141 -129
  62. package/lib/server/webflow/styleMapper.test.ts +389 -0
  63. package/lib/server/webflow/styleMapper.ts +517 -63
  64. package/lib/server/webflow/templateWrapper.ts +49 -0
  65. package/lib/server/webflow/types.ts +218 -18
  66. package/lib/shared/cssGeneration.test.ts +267 -1
  67. package/lib/shared/cssGeneration.ts +240 -18
  68. package/lib/shared/cssProperties.test.ts +247 -1
  69. package/lib/shared/cssProperties.ts +196 -6
  70. package/lib/shared/elementClassName.test.ts +15 -0
  71. package/lib/shared/elementClassName.ts +7 -3
  72. package/lib/shared/interfaces/contentProvider.ts +39 -6
  73. package/lib/shared/pathSecurity.ts +16 -0
  74. package/lib/shared/registry/nodeTypes/ListNodeType.ts +1 -1
  75. package/lib/shared/responsiveScaling.test.ts +143 -0
  76. package/lib/shared/responsiveScaling.ts +253 -2
  77. package/lib/shared/themeDefaults.test.ts +3 -3
  78. package/lib/shared/themeDefaults.ts +3 -3
  79. package/lib/shared/types/cms.ts +28 -3
  80. package/lib/shared/types/index.ts +2 -0
  81. package/lib/shared/types/variables.ts +37 -0
  82. package/lib/shared/utilityClassConfig.ts +3 -0
  83. package/lib/shared/utilityClassMapper.test.ts +123 -0
  84. package/lib/shared/utilityClassMapper.ts +179 -8
  85. package/lib/shared/validation/schemas.ts +15 -1
  86. package/lib/shared/validation/validators.ts +26 -1
  87. package/package.json +1 -1
  88. package/dist/chunks/chunk-47UNLQUU.js.map +0 -7
  89. package/dist/chunks/chunk-FED5MME6.js.map +0 -7
  90. package/dist/chunks/chunk-FGUZOYJX.js.map +0 -7
  91. package/dist/chunks/chunk-KITQJYZV.js.map +0 -7
  92. package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
  93. package/dist/chunks/chunk-XSWR3QLI.js.map +0 -7
  94. /package/dist/chunks/{chunk-IF3RATBY.js.map → chunk-H4JSCDNW.js.map} +0 -0
  95. /package/dist/chunks/{chunk-LJFB5EBT.js.map → chunk-JER5NQVM.js.map} +0 -0
  96. /package/dist/chunks/{chunk-BCLGRZ3U.js.map → chunk-LKAGAQ3M.js.map} +0 -0
  97. /package/dist/chunks/{configService-DYCUEURL.js.map → configService-CCA6AIDI.js.map} +0 -0
@@ -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
+ }
@@ -164,6 +164,21 @@ describe("generateElementClassName", () => {
164
164
 
165
165
  expect(className1).not.toBe(className2);
166
166
  });
167
+
168
+ test("should generate different hashes for sibling paths (only last index differs)", () => {
169
+ // Regression: a 5-char hash truncated from the high-order digits
170
+ // collapsed to one value when only the final path index changed,
171
+ // making every set of siblings share a class in the Webflow export.
172
+ const base = ["Layout", [0, 0, 1, 0, 0]] as const;
173
+ const siblings = [0, 1, 2, 3, 4].map((i) =>
174
+ generateElementClassName({
175
+ fileType: "component",
176
+ fileName: base[0],
177
+ path: [...base[1], i],
178
+ })
179
+ );
180
+ expect(new Set(siblings).size).toBe(siblings.length);
181
+ });
167
182
  });
168
183
 
169
184
  describe("Component-relative paths", () => {
@@ -33,13 +33,17 @@ export interface ElementClassContext {
33
33
  * Simple djb2 hash function that works in browser and server
34
34
  * Returns a short alphanumeric string
35
35
  */
36
- function shortHash(input: string): string {
36
+ export function shortHash(input: string): string {
37
37
  let hash = 5381;
38
38
  for (let i = 0; i < input.length; i++) {
39
39
  hash = ((hash << 5) + hash) ^ input.charCodeAt(i);
40
40
  }
41
- // Convert to base36 (alphanumeric) and take first 5 chars
42
- return Math.abs(hash).toString(36).slice(0, 5);
41
+ // Take the LAST 5 base36 chars: the low-order digits change with every
42
+ // input byte, so sibling paths that share a long prefix and differ only
43
+ // in their final index still produce distinct hashes. Slicing from the
44
+ // start kept the high-order digits, which barely move when only the
45
+ // last char of the input changes — every set of siblings collided.
46
+ return Math.abs(hash).toString(36).slice(-5);
43
47
  }
44
48
 
45
49
  /**
@@ -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
+ }
@@ -107,7 +107,7 @@ export const ListNodeType = createNodeType({
107
107
  },
108
108
 
109
109
  treeDisplay: {
110
- icon: 'HTML_ELEMENT',
110
+ icon: 'ARRAY',
111
111
  getLabel: (node) => {
112
112
  const listNode = node as ListNode;
113
113
  const sourceType = listNode.sourceType || 'prop';
@@ -7,6 +7,13 @@ import {
7
7
  scalePropertyValue,
8
8
  getResponsiveValues,
9
9
  DEFAULT_RESPONSIVE_SCALES,
10
+ DEFAULT_FLUID_RANGE,
11
+ DEFAULT_SITE_MARGIN,
12
+ buildFluidClamp,
13
+ buildFluidClampWithExplicitMin,
14
+ buildFluidPropertyValue,
15
+ buildSiteMarginClamp,
16
+ getSmallestBreakpointName,
10
17
  type ResponsiveScales,
11
18
  type CSSPropertyType,
12
19
  type BreakpointScales,
@@ -381,5 +388,141 @@ describe('responsiveScaling', () => {
381
388
  mobile: 0.4,
382
389
  });
383
390
  });
391
+
392
+ test('should default mode to "breakpoints" and provide fluidRange', () => {
393
+ expect(DEFAULT_RESPONSIVE_SCALES.mode).toBe('breakpoints');
394
+ expect(DEFAULT_RESPONSIVE_SCALES.fluidRange).toEqual({ min: 320, max: 1440 });
395
+ expect(DEFAULT_FLUID_RANGE).toEqual({ min: 320, max: 1440 });
396
+ });
397
+ });
398
+
399
+ describe('buildFluidClamp', () => {
400
+ test('px input — produces clamp(MIN, intercept + slope*100vw, MAX)', () => {
401
+ // base 32, scale 0.75 → MIN = 32 + (32-16)*(0.75-1) = 32 - 4 = 28
402
+ // slope_px = (32-28)/(1440-320) = 4/1120 ≈ 0.003571
403
+ // intercept_px = 28 - 0.003571*320 ≈ 26.857
404
+ // slope_vw = slope_px*100 ≈ 0.357
405
+ const result = buildFluidClamp(32, 'px', 0.75, 320, 1440);
406
+ expect(result.startsWith('clamp(28px,')).toBe(true);
407
+ expect(result.endsWith(', 32px)')).toBe(true);
408
+ expect(result).toContain('vw');
409
+ });
410
+
411
+ test('returns plain value (no clamp) when MIN === MAX', () => {
412
+ // 16 with scale 0.5 stays 16 because <= baseReference.
413
+ expect(buildFluidClamp(16, 'px', 0.5, 320, 1440)).toBe('16px');
414
+ });
415
+
416
+ test('returns plain value when scale === 1', () => {
417
+ expect(buildFluidClamp(32, 'px', 1, 320, 1440)).toBe('32px');
418
+ });
419
+
420
+ test('rem input — emits a clamp() with rem unit on both bounds', () => {
421
+ // calculateResponsiveValue rounds, so we use a large enough rem value that
422
+ // MIN and MAX stay distinct after rounding. base=8rem, scale=0.5, baseRef=1
423
+ // → MIN = round(8 + (8-1)*(-0.5)) = round(4.5) = 5
424
+ const result = buildFluidClamp(8, 'rem', 0.5, 20, 90, 1.0);
425
+ expect(result.startsWith('clamp(5rem,')).toBe(true);
426
+ expect(result.endsWith(', 8rem)')).toBe(true);
427
+ expect(result).toContain('vw');
428
+ });
429
+ });
430
+
431
+ describe('buildFluidClampWithExplicitMin', () => {
432
+ test('px input — MIN=94, MAX=150, fluidRange 320–1700', () => {
433
+ // slope_px = (150-94)/(1700-320) = 56/1380 ≈ 0.04058 → slope_vw ≈ 4.058
434
+ // intercept = 94 - 0.04058*320 ≈ 81.0145
435
+ const result = buildFluidClampWithExplicitMin(94, 150, 'px', 320, 1700);
436
+ expect(result).toMatch(/^clamp\(94px,\s*81\.0145px\s*\+\s*4\.058vw,\s*150px\)$/);
437
+ });
438
+
439
+ test('returns plain MAX when MIN === MAX', () => {
440
+ expect(buildFluidClampWithExplicitMin(50, 50, 'px', 320, 1700)).toBe('50px');
441
+ });
442
+
443
+ test('returns plain MAX when vpMin === vpMax (degenerate range)', () => {
444
+ expect(buildFluidClampWithExplicitMin(40, 80, 'px', 1000, 1000)).toBe('80px');
445
+ });
446
+
447
+ test('rem unit — bounds emitted in rem', () => {
448
+ const result = buildFluidClampWithExplicitMin(2, 4, 'rem', 320, 1440);
449
+ expect(result.startsWith('clamp(2rem,')).toBe(true);
450
+ expect(result.endsWith(', 4rem)')).toBe(true);
451
+ });
452
+
453
+ test('mobile larger than desktop (negative slope) still produces a valid clamp', () => {
454
+ // The function doesn't enforce min<max; it just interpolates. Useful when
455
+ // an author wants the value to shrink as viewport grows.
456
+ const result = buildFluidClampWithExplicitMin(200, 100, 'px', 320, 1700);
457
+ expect(result.startsWith('clamp(200px,')).toBe(true);
458
+ expect(result.endsWith(', 100px)')).toBe(true);
459
+ });
460
+ });
461
+
462
+ describe('buildFluidPropertyValue', () => {
463
+ test('single px value → single clamp()', () => {
464
+ const out = buildFluidPropertyValue('32px', 0.75, 320, 1440);
465
+ expect(out).not.toBeNull();
466
+ expect(out!.startsWith('clamp(')).toBe(true);
467
+ });
468
+
469
+ test('multi-value padding "20px 40px" → both tokens scaled', () => {
470
+ const out = buildFluidPropertyValue('20px 40px', 0.5, 320, 1440);
471
+ expect(out).not.toBeNull();
472
+ const parts = out!.split(' ');
473
+ // The two tokens get joined by space — but each clamp() also has spaces inside.
474
+ // Check both clamp(...) substrings present.
475
+ expect((out!.match(/clamp\(/g) ?? []).length).toBe(2);
476
+ });
477
+
478
+ test('"%" and "em" pass through verbatim', () => {
479
+ expect(buildFluidPropertyValue('50%', 0.5, 320, 1440)).toBeNull();
480
+ expect(buildFluidPropertyValue('1.5em', 0.5, 320, 1440)).toBeNull();
481
+ });
482
+
483
+ test('"auto" pass through (no scalable token) → null', () => {
484
+ expect(buildFluidPropertyValue('auto', 0.5, 320, 1440)).toBeNull();
485
+ });
486
+
487
+ test('mixed "20px auto" → first token clamped, second kept', () => {
488
+ const out = buildFluidPropertyValue('20px auto', 0.5, 320, 1440);
489
+ expect(out).not.toBeNull();
490
+ expect(out).toContain('clamp(');
491
+ expect(out!.endsWith(' auto')).toBe(true);
492
+ });
493
+ });
494
+
495
+ describe('buildSiteMarginClamp', () => {
496
+ test('emits clamp with px on both bounds', () => {
497
+ const out = buildSiteMarginClamp({ min: 16, max: 32 }, { min: 320, max: 1440 });
498
+ expect(out.startsWith('clamp(16px,')).toBe(true);
499
+ expect(out.endsWith(', 32px)')).toBe(true);
500
+ expect(out).toContain('vw');
501
+ });
502
+
503
+ test('returns plain px when min === max', () => {
504
+ expect(buildSiteMarginClamp({ min: 16, max: 16 }, { min: 320, max: 1440 }))
505
+ .toBe('16px');
506
+ });
507
+
508
+ test('default DEFAULT_SITE_MARGIN is 16/32', () => {
509
+ expect(DEFAULT_SITE_MARGIN).toEqual({ min: 16, max: 32 });
510
+ });
511
+ });
512
+
513
+ describe('getSmallestBreakpointName', () => {
514
+ test('picks the entry with smallest breakpoint value', () => {
515
+ const bp = {
516
+ tablet: { breakpoint: 1024 },
517
+ mobile: { breakpoint: 540 },
518
+ small: { breakpoint: 360 },
519
+ };
520
+ expect(getSmallestBreakpointName(bp)).toBe('small');
521
+ });
522
+
523
+ test('returns null for empty / undefined', () => {
524
+ expect(getSmallestBreakpointName(undefined)).toBeNull();
525
+ expect(getSmallestBreakpointName({})).toBeNull();
526
+ });
384
527
  });
385
528
  });