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.
- package/build-astro.ts +2 -2
- package/dist/build-static.js +7 -7
- package/dist/chunks/{chunk-UUA5LEWF.js → chunk-6IVUG7FY.js} +138 -7
- package/dist/chunks/chunk-6IVUG7FY.js.map +7 -0
- package/dist/chunks/{chunk-XSWR3QLI.js → chunk-AZQYF6KE.js} +261 -130
- package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
- package/dist/chunks/{chunk-47UNLQUU.js → chunk-CHD5UCFF.js} +57 -12
- package/dist/chunks/chunk-CHD5UCFF.js.map +7 -0
- package/dist/chunks/{chunk-FGUZOYJX.js → chunk-EQYDSPBB.js} +435 -131
- package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
- package/dist/chunks/{chunk-IF3RATBY.js → chunk-H4JSCDNW.js} +2 -2
- package/dist/chunks/{chunk-KITQJYZV.js → chunk-J23ZX5AP.js} +40 -4
- package/dist/chunks/chunk-J23ZX5AP.js.map +7 -0
- package/dist/chunks/{chunk-LJFB5EBT.js → chunk-JER5NQVM.js} +5 -5
- package/dist/chunks/{chunk-ZTKHJQ2Z.js → chunk-KPU2XHOS.js} +5 -2
- package/dist/chunks/{chunk-ZTKHJQ2Z.js.map → chunk-KPU2XHOS.js.map} +2 -2
- package/dist/chunks/{chunk-BCLGRZ3U.js → chunk-LKAGAQ3M.js} +2 -2
- package/dist/chunks/{chunk-FED5MME6.js → chunk-S2CX6HFM.js} +262 -26
- package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
- package/dist/chunks/{configService-DYCUEURL.js → configService-CCA6AIDI.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 +64 -20
- package/dist/lib/client/index.js.map +3 -3
- package/dist/lib/server/index.js +1737 -296
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +50 -10
- package/dist/lib/shared/index.js.map +3 -3
- package/entries/server-router.tsx +6 -2
- package/lib/client/core/ComponentBuilder.test.ts +17 -0
- package/lib/client/core/ComponentBuilder.ts +25 -1
- package/lib/client/core/builders/embedBuilder.ts +15 -2
- package/lib/client/core/builders/linkNodeBuilder.ts +15 -2
- package/lib/client/core/builders/localeListBuilder.ts +17 -6
- package/lib/client/styles/StyleInjector.ts +3 -2
- 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/index.ts +1 -1
- package/lib/server/jsonLoader.test.ts +0 -17
- package/lib/server/jsonLoader.ts +0 -81
- package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
- package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
- package/lib/server/routes/api/variables.ts +4 -2
- 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 +5 -0
- package/lib/server/ssr/attributeBuilder.ts +41 -0
- package/lib/server/ssr/htmlGenerator.test.ts +114 -2
- package/lib/server/ssr/htmlGenerator.ts +53 -6
- package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
- package/lib/server/ssr/ssrRenderer.test.ts +362 -1
- package/lib/server/ssr/ssrRenderer.ts +216 -72
- package/lib/server/utils/jsonLineMapper.test.ts +53 -1
- package/lib/server/utils/jsonLineMapper.ts +43 -3
- package/lib/server/webflow/buildWebflow.ts +343 -123
- package/lib/server/webflow/index.ts +1 -0
- package/lib/server/webflow/nodeToWebflow.test.ts +3170 -0
- package/lib/server/webflow/nodeToWebflow.ts +2141 -129
- package/lib/server/webflow/styleMapper.test.ts +389 -0
- package/lib/server/webflow/styleMapper.ts +517 -63
- package/lib/server/webflow/templateWrapper.ts +49 -0
- package/lib/server/webflow/types.ts +218 -18
- package/lib/shared/cssGeneration.test.ts +267 -1
- package/lib/shared/cssGeneration.ts +240 -18
- package/lib/shared/cssProperties.test.ts +247 -1
- package/lib/shared/cssProperties.ts +196 -6
- package/lib/shared/elementClassName.test.ts +15 -0
- package/lib/shared/elementClassName.ts +7 -3
- package/lib/shared/interfaces/contentProvider.ts +39 -6
- package/lib/shared/pathSecurity.ts +16 -0
- package/lib/shared/registry/nodeTypes/ListNodeType.ts +1 -1
- 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/cms.ts +28 -3
- package/lib/shared/types/index.ts +2 -0
- package/lib/shared/types/variables.ts +37 -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.ts +15 -1
- package/lib/shared/validation/validators.ts +26 -1
- package/package.json +1 -1
- package/dist/chunks/chunk-47UNLQUU.js.map +0 -7
- package/dist/chunks/chunk-FED5MME6.js.map +0 -7
- package/dist/chunks/chunk-FGUZOYJX.js.map +0 -7
- package/dist/chunks/chunk-KITQJYZV.js.map +0 -7
- package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
- package/dist/chunks/chunk-XSWR3QLI.js.map +0 -7
- /package/dist/chunks/{chunk-IF3RATBY.js.map → chunk-H4JSCDNW.js.map} +0 -0
- /package/dist/chunks/{chunk-LJFB5EBT.js.map → chunk-JER5NQVM.js.map} +0 -0
- /package/dist/chunks/{chunk-BCLGRZ3U.js.map → chunk-LKAGAQ3M.js.map} +0 -0
- /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'
|
|
425
|
-
'
|
|
426
|
-
'
|
|
427
|
-
'
|
|
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': ['
|
|
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': ['
|
|
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
|
-
//
|
|
42
|
-
|
|
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 (
|
|
125
|
+
/** Save item (published) - writes to cms/<collection>/<_filename>.json */
|
|
120
126
|
saveItem(collection: string, item: CMSItem): Promise<void>;
|
|
121
127
|
|
|
122
|
-
/**
|
|
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
|
+
}
|
|
@@ -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
|
});
|