meno-core 1.0.19 → 1.0.21
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/.claude/settings.local.json +7 -0
- package/lib/client/core/ComponentBuilder.test.ts +68 -56
- package/lib/client/core/ComponentBuilder.ts +6 -4
- package/lib/client/core/builders/embedBuilder.ts +10 -1
- package/lib/client/core/builders/index.ts +6 -2
- package/lib/client/core/builders/{cmsListBuilder.ts → listBuilder.ts} +227 -95
- package/lib/client/responsiveStyleResolver.test.ts +12 -12
- package/lib/client/responsiveStyleResolver.ts +19 -7
- package/lib/client/routing/Router.tsx +35 -7
- package/lib/client/templateEngine.test.ts +126 -0
- package/lib/client/templateEngine.ts +53 -13
- package/lib/server/jsonLoader.test.ts +4 -1
- package/lib/server/jsonLoader.ts +64 -15
- package/lib/server/services/configService.ts +68 -13
- package/lib/server/ssr/attributeBuilder.ts +8 -0
- package/lib/server/ssr/index.ts +1 -1
- package/lib/server/ssr/ssrRenderer.ts +245 -111
- package/lib/server/ssrRenderer.test.ts +197 -3
- package/lib/server/validateStyleCoverage.ts +14 -17
- package/lib/shared/breakpoints.test.ts +210 -23
- package/lib/shared/breakpoints.ts +124 -17
- package/lib/shared/constants.test.ts +1 -1
- package/lib/shared/constants.ts +5 -1
- package/lib/shared/cssGeneration.test.ts +17 -0
- package/lib/shared/cssGeneration.ts +49 -12
- package/lib/shared/index.ts +3 -0
- package/lib/shared/itemTemplateUtils.test.ts +44 -2
- package/lib/shared/itemTemplateUtils.ts +15 -2
- package/lib/shared/nodeUtils.ts +23 -4
- package/lib/shared/registry/BaseNodeTypeRegistry.test.ts +2 -2
- package/lib/shared/registry/nodeTypes/ListNodeType.ts +186 -0
- package/lib/shared/registry/nodeTypes/SlotMarkerType.ts +6 -0
- package/lib/shared/registry/nodeTypes/index.ts +6 -5
- package/lib/shared/responsiveScaling.test.ts +87 -0
- package/lib/shared/responsiveScaling.ts +33 -29
- package/lib/shared/responsiveStyleUtils.test.ts +7 -7
- package/lib/shared/responsiveStyleUtils.ts +22 -16
- package/lib/shared/styleNodeUtils.ts +5 -5
- package/lib/shared/styleValueRegistry.ts +60 -5
- package/lib/shared/tree/PathBuilder.ts +3 -3
- package/lib/shared/treePathUtils.ts +7 -5
- package/lib/shared/types/cms.ts +4 -57
- package/lib/shared/types/components.ts +45 -4
- package/lib/shared/types/index.ts +13 -0
- package/lib/shared/utilityClassConfig.ts +14 -0
- package/lib/shared/utilityClassMapper.ts +43 -2
- package/lib/shared/validation/propValidator.ts +9 -1
- package/lib/shared/validation/schemas.ts +60 -14
- package/package.json +1 -1
- package/lib/shared/registry/nodeTypes/CMSListNodeType.ts +0 -109
|
@@ -626,6 +626,132 @@ describe("Template Engine - processStructure", () => {
|
|
|
626
626
|
});
|
|
627
627
|
});
|
|
628
628
|
|
|
629
|
+
describe("Template Engine - Slot Default Values", () => {
|
|
630
|
+
const mockComponentDef: StructuredComponentDefinition = {
|
|
631
|
+
interface: {},
|
|
632
|
+
structure: { type: 'node' as const, tag: 'div' }
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const createContext = (props: Record<string, unknown>): TemplateContext => ({
|
|
636
|
+
props,
|
|
637
|
+
componentDef: mockComponentDef
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test("should render slot default when no instance children provided", () => {
|
|
641
|
+
const structure = [
|
|
642
|
+
{ type: "node", tag: "div", children: [
|
|
643
|
+
{ type: "node", tag: "h1", children: "Header" },
|
|
644
|
+
{
|
|
645
|
+
type: "slot",
|
|
646
|
+
default: [
|
|
647
|
+
{ type: "node", tag: "p", children: "Default paragraph" }
|
|
648
|
+
]
|
|
649
|
+
},
|
|
650
|
+
{ type: "node", tag: "footer", children: "Footer" }
|
|
651
|
+
]}
|
|
652
|
+
] as unknown as ComponentNode[];
|
|
653
|
+
|
|
654
|
+
const result = processStructure(structure, createContext({}), undefined, undefined);
|
|
655
|
+
expect(result).toBeInstanceOf(Array);
|
|
656
|
+
|
|
657
|
+
const [root] = result as ComponentNode[];
|
|
658
|
+
expect(root.tag).toBe("div");
|
|
659
|
+
expect(root.children).toBeInstanceOf(Array);
|
|
660
|
+
|
|
661
|
+
const children = root.children as ComponentNode[];
|
|
662
|
+
expect(children).toHaveLength(3);
|
|
663
|
+
expect(children[0].tag).toBe("h1");
|
|
664
|
+
expect((children[1] as any).tag).toBe("p"); // Default content
|
|
665
|
+
expect((children[1] as any).children).toEqual(["Default paragraph"]);
|
|
666
|
+
expect(children[2].tag).toBe("footer");
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
test("should render instance children instead of slot default when provided", () => {
|
|
670
|
+
const structure = [
|
|
671
|
+
{ type: "node", tag: "div", children: [
|
|
672
|
+
{ type: "node", tag: "h1", children: "Header" },
|
|
673
|
+
{
|
|
674
|
+
type: "slot",
|
|
675
|
+
default: [
|
|
676
|
+
{ type: "node", tag: "p", children: "Default paragraph" }
|
|
677
|
+
]
|
|
678
|
+
},
|
|
679
|
+
{ type: "node", tag: "footer", children: "Footer" }
|
|
680
|
+
]}
|
|
681
|
+
] as unknown as ComponentNode[];
|
|
682
|
+
|
|
683
|
+
const instanceChildren = [
|
|
684
|
+
{ type: "node", tag: "span", children: "Custom content" }
|
|
685
|
+
] as ComponentNode[];
|
|
686
|
+
|
|
687
|
+
const result = processStructure(structure, createContext({}), undefined, instanceChildren);
|
|
688
|
+
expect(result).toBeInstanceOf(Array);
|
|
689
|
+
|
|
690
|
+
const [root] = result as ComponentNode[];
|
|
691
|
+
const children = root.children as ComponentNode[];
|
|
692
|
+
|
|
693
|
+
expect(children).toHaveLength(3);
|
|
694
|
+
expect(children[0].tag).toBe("h1");
|
|
695
|
+
expect((children[1] as any).tag).toBe("span"); // Instance content, not default
|
|
696
|
+
expect((children[1] as any).children).toEqual(["Custom content"]);
|
|
697
|
+
expect(children[2].tag).toBe("footer");
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test("should handle string default for slot", () => {
|
|
701
|
+
const structure = [
|
|
702
|
+
{ type: "node", tag: "div", children: [
|
|
703
|
+
{
|
|
704
|
+
type: "slot",
|
|
705
|
+
default: "Default text content"
|
|
706
|
+
}
|
|
707
|
+
]}
|
|
708
|
+
] as unknown as ComponentNode[];
|
|
709
|
+
|
|
710
|
+
const result = processStructure(structure, createContext({}), undefined, undefined);
|
|
711
|
+
const [root] = result as ComponentNode[];
|
|
712
|
+
const children = root.children as string[];
|
|
713
|
+
|
|
714
|
+
expect(children).toContain("Default text content");
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test("should render nothing when no instance children and no default", () => {
|
|
718
|
+
const structure = [
|
|
719
|
+
{ type: "node", tag: "div", children: [
|
|
720
|
+
{ type: "node", tag: "h1", children: "Header" },
|
|
721
|
+
{ type: "slot" }, // No default
|
|
722
|
+
{ type: "node", tag: "footer", children: "Footer" }
|
|
723
|
+
]}
|
|
724
|
+
] as unknown as ComponentNode[];
|
|
725
|
+
|
|
726
|
+
const result = processStructure(structure, createContext({}), undefined, undefined);
|
|
727
|
+
const [root] = result as ComponentNode[];
|
|
728
|
+
const children = root.children as ComponentNode[];
|
|
729
|
+
|
|
730
|
+
expect(children).toHaveLength(2); // Only h1 and footer, slot is removed
|
|
731
|
+
expect(children[0].tag).toBe("h1");
|
|
732
|
+
expect(children[1].tag).toBe("footer");
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test("should process templates in slot default content", () => {
|
|
736
|
+
const structure = [
|
|
737
|
+
{ type: "node", tag: "div", children: [
|
|
738
|
+
{
|
|
739
|
+
type: "slot",
|
|
740
|
+
default: [
|
|
741
|
+
{ type: "node", tag: "p", children: "Hello {{name}}" }
|
|
742
|
+
]
|
|
743
|
+
}
|
|
744
|
+
]}
|
|
745
|
+
] as unknown as ComponentNode[];
|
|
746
|
+
|
|
747
|
+
const result = processStructure(structure, createContext({ name: "World" }), undefined, undefined);
|
|
748
|
+
const [root] = result as ComponentNode[];
|
|
749
|
+
const children = root.children as ComponentNode[];
|
|
750
|
+
|
|
751
|
+
expect((children[0] as any).children).toEqual(["Hello World"]);
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
|
|
629
755
|
describe("Template Engine - normalizeStyle", () => {
|
|
630
756
|
test("should normalize flat style objects", () => {
|
|
631
757
|
const style = {
|
|
@@ -10,7 +10,7 @@ import { isResponsiveStyle } from '../shared/styleUtils';
|
|
|
10
10
|
import { normalizeStyle as normalizeStyleShared, mergeResponsiveStyles } from '../shared/responsiveStyleUtils';
|
|
11
11
|
import { DEFAULT_BREAKPOINTS } from '../shared/breakpoints';
|
|
12
12
|
import { NODE_TYPE } from '../shared/constants';
|
|
13
|
-
import { isValidNodeType, isComponentNode, isHtmlNode, isSlotMarker, isEmbedNode, isLocaleListNode, isLinkNode, isCMSListNode } from '../shared/nodeUtils';
|
|
13
|
+
import { isValidNodeType, isComponentNode, isHtmlNode, isSlotMarker, isEmbedNode, isLocaleListNode, isLinkNode, isCMSListNode, isListNode } from '../shared/nodeUtils';
|
|
14
14
|
import { applyStylesToNode } from '../shared/styleNodeUtils';
|
|
15
15
|
import { isRichTextMarker, richTextMarkerToHtml } from '../shared/propResolver';
|
|
16
16
|
import { isTiptapDocument, tiptapToHtml } from '../shared/richtext';
|
|
@@ -379,8 +379,16 @@ export function processStructure(
|
|
|
379
379
|
const processedChild = processStructure(child, context, viewportWidth, instanceChildren, preserveResponsiveStyles, depth + 1);
|
|
380
380
|
addProcessedItemToArray(processedChild, processed);
|
|
381
381
|
}
|
|
382
|
+
} else if ('default' in item && (item as any).default !== undefined) {
|
|
383
|
+
// Fallback: use slot's default content if no instance children provided
|
|
384
|
+
const defaultContent = (item as any).default;
|
|
385
|
+
const defaultsArray = Array.isArray(defaultContent) ? defaultContent : [defaultContent];
|
|
386
|
+
for (const defaultChild of defaultsArray) {
|
|
387
|
+
const processedChild = processStructure(defaultChild, context, viewportWidth, undefined, preserveResponsiveStyles, depth + 1);
|
|
388
|
+
addProcessedItemToArray(processedChild, processed);
|
|
389
|
+
}
|
|
382
390
|
}
|
|
383
|
-
// If no instance children, marker renders nothing (skip it)
|
|
391
|
+
// If no instance children AND no default, marker renders nothing (skip it)
|
|
384
392
|
} else {
|
|
385
393
|
// Regular item - process normally
|
|
386
394
|
const processedItem = processStructure(item, context, viewportWidth, instanceChildren, preserveResponsiveStyles, depth + 1);
|
|
@@ -393,7 +401,7 @@ export function processStructure(
|
|
|
393
401
|
if (typeof structure === 'object' && !Array.isArray(structure) && structure !== null) {
|
|
394
402
|
// Check if this is a slot marker (shouldn't happen here since we handle it in array processing, but guard anyway)
|
|
395
403
|
if (isSlotMarker(structure)) {
|
|
396
|
-
// This shouldn't happen in object processing, but if it does, return instance children
|
|
404
|
+
// This shouldn't happen in object processing, but if it does, return instance children or default
|
|
397
405
|
if (instanceChildren) {
|
|
398
406
|
const processed: Array<ComponentNode | string> = [];
|
|
399
407
|
const childrenArray = Array.isArray(instanceChildren) ? instanceChildren : [instanceChildren];
|
|
@@ -403,6 +411,17 @@ export function processStructure(
|
|
|
403
411
|
}
|
|
404
412
|
return processed.length === 1 ? processed[0] : processed;
|
|
405
413
|
}
|
|
414
|
+
// Fallback: use slot's default content
|
|
415
|
+
if ('default' in structure && (structure as any).default !== undefined) {
|
|
416
|
+
const defaultContent = (structure as any).default;
|
|
417
|
+
const processed: Array<ComponentNode | string> = [];
|
|
418
|
+
const defaultsArray = Array.isArray(defaultContent) ? defaultContent : [defaultContent];
|
|
419
|
+
for (const defaultChild of defaultsArray) {
|
|
420
|
+
const processedChild = processStructure(defaultChild, context, viewportWidth, undefined, preserveResponsiveStyles, depth + 1);
|
|
421
|
+
addProcessedItemToArray(processedChild, processed);
|
|
422
|
+
}
|
|
423
|
+
return processed.length === 1 ? processed[0] : processed;
|
|
424
|
+
}
|
|
406
425
|
return null;
|
|
407
426
|
}
|
|
408
427
|
|
|
@@ -452,11 +471,12 @@ export function processStructure(
|
|
|
452
471
|
processed = {
|
|
453
472
|
type: NODE_TYPE.LOCALE_LIST,
|
|
454
473
|
} as any;
|
|
455
|
-
} else if (preservedType === NODE_TYPE.
|
|
456
|
-
// Handle
|
|
474
|
+
} else if (preservedType === NODE_TYPE.LIST || (preservedType as string) === 'cms-list') {
|
|
475
|
+
// Handle list nodes (unified - handles both prop and collection source types)
|
|
476
|
+
// Also supports legacy 'cms-list' type for migration
|
|
457
477
|
processed = {
|
|
458
|
-
type: NODE_TYPE.
|
|
459
|
-
|
|
478
|
+
type: NODE_TYPE.LIST,
|
|
479
|
+
source: '',
|
|
460
480
|
children: [] as Array<ComponentNode | string>
|
|
461
481
|
} as any;
|
|
462
482
|
} else {
|
|
@@ -480,7 +500,8 @@ export function processStructure(
|
|
|
480
500
|
processed.children = [processedChildren as ComponentNode | string];
|
|
481
501
|
}
|
|
482
502
|
} else if (key === 'tag') {
|
|
483
|
-
|
|
503
|
+
// Handle tag for HTML nodes and list nodes
|
|
504
|
+
if (isHtmlNode(processed) || isListNode(processed)) {
|
|
484
505
|
if (typeof value === 'string') {
|
|
485
506
|
// Process template in tag (e.g., "h{{size}}" -> "h1")
|
|
486
507
|
const evalContext: Record<string, unknown> = { ...getGlobalTemplateContext(), ...context.props };
|
|
@@ -488,9 +509,9 @@ export function processStructure(
|
|
|
488
509
|
Object.assign(evalContext, context.componentDef as Record<string, unknown>);
|
|
489
510
|
}
|
|
490
511
|
// Use processCodeTemplates to handle partial templates like "h{{size}}"
|
|
491
|
-
processed.tag = processCodeTemplates(value, evalContext);
|
|
512
|
+
(processed as any).tag = processCodeTemplates(value, evalContext);
|
|
492
513
|
} else {
|
|
493
|
-
processed.tag = String(value);
|
|
514
|
+
(processed as any).tag = String(value);
|
|
494
515
|
}
|
|
495
516
|
}
|
|
496
517
|
} else if (key === 'component') {
|
|
@@ -502,10 +523,29 @@ export function processStructure(
|
|
|
502
523
|
}
|
|
503
524
|
}
|
|
504
525
|
} else if (key === 'html') {
|
|
505
|
-
// Handle html property for embed nodes
|
|
526
|
+
// Handle html property for embed nodes - process templates like {{propName}}
|
|
506
527
|
if (preservedType === NODE_TYPE.EMBED) {
|
|
507
528
|
if (typeof value === 'string') {
|
|
508
|
-
(
|
|
529
|
+
// Build evaluation context from props (same as tag processing)
|
|
530
|
+
const evalContext: Record<string, unknown> = { ...getGlobalTemplateContext(), ...context.props };
|
|
531
|
+
if (context.componentDef && typeof context.componentDef === 'object') {
|
|
532
|
+
Object.assign(evalContext, context.componentDef as Record<string, unknown>);
|
|
533
|
+
}
|
|
534
|
+
// Add parent cms-list item context for nested template resolution
|
|
535
|
+
const itemContext = (context as Record<string, unknown>).itemContext as Record<string, unknown> | undefined;
|
|
536
|
+
if (itemContext) {
|
|
537
|
+
Object.assign(evalContext, itemContext);
|
|
538
|
+
}
|
|
539
|
+
// Check if entire string is a complete template {{expr}}
|
|
540
|
+
if (/^\{\{.+\}\}$/.test(value) && !hasItemTemplates(value)) {
|
|
541
|
+
const result = evaluateTemplate(value, evalContext);
|
|
542
|
+
(processed as any).html = result === undefined || result === null ? '' : String(result);
|
|
543
|
+
} else if (hasTemplates(value) && !hasItemTemplates(value)) {
|
|
544
|
+
// Use processCodeTemplates to handle partial templates
|
|
545
|
+
(processed as any).html = processCodeTemplates(value, evalContext);
|
|
546
|
+
} else {
|
|
547
|
+
(processed as any).html = value;
|
|
548
|
+
}
|
|
509
549
|
} else {
|
|
510
550
|
(processed as any).html = String(value);
|
|
511
551
|
}
|
|
@@ -718,7 +758,7 @@ export function processStructure(
|
|
|
718
758
|
if (isComponentNode(processed)) {
|
|
719
759
|
processed.props = processed.props || {};
|
|
720
760
|
processed.props.style = resolvedStyle as ResponsiveStyleObject;
|
|
721
|
-
} else if (isHtmlNode(processed) || isEmbedNode(processed) || isLocaleListNode(processed) || isLinkNode(processed) ||
|
|
761
|
+
} else if (isHtmlNode(processed) || isEmbedNode(processed) || isLocaleListNode(processed) || isLinkNode(processed) || isListNode(processed)) {
|
|
722
762
|
processed.style = resolvedStyle as ResponsiveStyleObject;
|
|
723
763
|
}
|
|
724
764
|
} else {
|
|
@@ -66,7 +66,10 @@ describe('jsonLoader', () => {
|
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
test('returns cached config after set', () => {
|
|
69
|
-
const customConfig = {
|
|
69
|
+
const customConfig = {
|
|
70
|
+
mobile: { breakpoint: 500, previewPoint: 375 },
|
|
71
|
+
tablet: { breakpoint: 900, previewPoint: 768 }
|
|
72
|
+
};
|
|
70
73
|
setBreakpointConfig(customConfig);
|
|
71
74
|
const config = getBreakpointConfig();
|
|
72
75
|
expect(config).toEqual(customConfig);
|
package/lib/server/jsonLoader.ts
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
import { existsSync, readdirSync } from 'fs';
|
|
7
7
|
import type { z } from 'zod';
|
|
8
8
|
import type { ComponentDefinition } from '../shared/types';
|
|
9
|
-
import type { BreakpointConfig } from '../shared/breakpoints';
|
|
10
|
-
import { DEFAULT_BREAKPOINTS } from '../shared/breakpoints';
|
|
11
|
-
import type { ResponsiveScales } from '../shared/responsiveScaling';
|
|
9
|
+
import type { BreakpointConfig, BreakpointConfigInput, BreakpointEntry } from '../shared/breakpoints';
|
|
10
|
+
import { DEFAULT_BREAKPOINTS, normalizeBreakpointConfig } from '../shared/breakpoints';
|
|
11
|
+
import type { ResponsiveScales, BreakpointScales } from '../shared/responsiveScaling';
|
|
12
12
|
import { DEFAULT_RESPONSIVE_SCALES } from '../shared/responsiveScaling';
|
|
13
13
|
import type { I18nConfig } from '../shared/types/components';
|
|
14
14
|
import { DEFAULT_I18N_CONFIG, migrateI18nConfig } from '../shared/i18n';
|
|
@@ -211,26 +211,38 @@ export async function loadBreakpointConfig(): Promise<BreakpointConfig> {
|
|
|
211
211
|
try {
|
|
212
212
|
const configContent = await loadJSONFile(projectPaths.config());
|
|
213
213
|
if (configContent) {
|
|
214
|
-
const config = parseJSON<{ breakpoints?:
|
|
215
|
-
|
|
214
|
+
const config = parseJSON<{ breakpoints?: BreakpointConfigInput }>(configContent);
|
|
215
|
+
|
|
216
216
|
if (config.breakpoints && typeof config.breakpoints === 'object') {
|
|
217
217
|
// Preserve all breakpoints from config, filtering out invalid values
|
|
218
|
-
const
|
|
218
|
+
const validInput: BreakpointConfigInput = {};
|
|
219
219
|
for (const [key, value] of Object.entries(config.breakpoints)) {
|
|
220
220
|
if (typeof value === 'number' && value > 0) {
|
|
221
|
-
|
|
221
|
+
// Legacy format: number
|
|
222
|
+
validInput[key] = value;
|
|
223
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
224
|
+
// New format: object with breakpoint and optional previewPoint
|
|
225
|
+
const entry = value as BreakpointEntry;
|
|
226
|
+
if (typeof entry.breakpoint === 'number' && entry.breakpoint > 0) {
|
|
227
|
+
validInput[key] = {
|
|
228
|
+
breakpoint: entry.breakpoint,
|
|
229
|
+
previewPoint: typeof entry.previewPoint === 'number' && entry.previewPoint > 0
|
|
230
|
+
? entry.previewPoint
|
|
231
|
+
: entry.breakpoint,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
222
234
|
}
|
|
223
235
|
}
|
|
224
|
-
|
|
225
|
-
// If we have valid breakpoints, return them; otherwise fall back to defaults
|
|
226
|
-
if (Object.keys(
|
|
227
|
-
return
|
|
236
|
+
|
|
237
|
+
// If we have valid breakpoints, return them normalized; otherwise fall back to defaults
|
|
238
|
+
if (Object.keys(validInput).length > 0) {
|
|
239
|
+
return normalizeBreakpointConfig(validInput);
|
|
228
240
|
}
|
|
229
241
|
}
|
|
230
242
|
}
|
|
231
243
|
} catch (error) {
|
|
232
244
|
}
|
|
233
|
-
|
|
245
|
+
|
|
234
246
|
return { ...DEFAULT_BREAKPOINTS };
|
|
235
247
|
}
|
|
236
248
|
|
|
@@ -251,8 +263,28 @@ export function setBreakpointConfig(config: BreakpointConfig): void {
|
|
|
251
263
|
cachedBreakpoints = config;
|
|
252
264
|
}
|
|
253
265
|
|
|
266
|
+
/**
|
|
267
|
+
* Deep merge scale categories, preserving user-defined breakpoints
|
|
268
|
+
* while filling in missing values from defaults
|
|
269
|
+
*/
|
|
270
|
+
function mergeScaleCategory(
|
|
271
|
+
userScales: BreakpointScales | undefined,
|
|
272
|
+
defaultScales: BreakpointScales | undefined
|
|
273
|
+
): BreakpointScales | undefined {
|
|
274
|
+
if (!userScales && !defaultScales) return undefined;
|
|
275
|
+
if (!userScales) return defaultScales ? { ...defaultScales } : undefined;
|
|
276
|
+
if (!defaultScales) return { ...userScales };
|
|
277
|
+
|
|
278
|
+
// User scales take precedence, but include defaults for breakpoints not specified
|
|
279
|
+
return {
|
|
280
|
+
...defaultScales,
|
|
281
|
+
...userScales,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
254
285
|
/**
|
|
255
286
|
* Load and validate responsive scales configuration from project.config.json
|
|
287
|
+
* Supports dynamic breakpoints - scales are keyed by breakpoint name
|
|
256
288
|
*/
|
|
257
289
|
export async function loadResponsiveScalesConfig(): Promise<ResponsiveScales> {
|
|
258
290
|
try {
|
|
@@ -261,10 +293,27 @@ export async function loadResponsiveScalesConfig(): Promise<ResponsiveScales> {
|
|
|
261
293
|
const config = parseJSON<{ responsiveScales?: Partial<ResponsiveScales> }>(configContent);
|
|
262
294
|
|
|
263
295
|
if (config.responsiveScales && typeof config.responsiveScales === 'object') {
|
|
264
|
-
//
|
|
296
|
+
// Deep merge scale categories to preserve user breakpoint definitions
|
|
297
|
+
// while filling in missing values from defaults
|
|
265
298
|
const scales: ResponsiveScales = {
|
|
266
|
-
|
|
267
|
-
|
|
299
|
+
enabled: config.responsiveScales.enabled ?? DEFAULT_RESPONSIVE_SCALES.enabled,
|
|
300
|
+
baseReference: config.responsiveScales.baseReference ?? DEFAULT_RESPONSIVE_SCALES.baseReference,
|
|
301
|
+
fontSize: mergeScaleCategory(
|
|
302
|
+
config.responsiveScales.fontSize as BreakpointScales | undefined,
|
|
303
|
+
DEFAULT_RESPONSIVE_SCALES.fontSize
|
|
304
|
+
),
|
|
305
|
+
padding: mergeScaleCategory(
|
|
306
|
+
config.responsiveScales.padding as BreakpointScales | undefined,
|
|
307
|
+
DEFAULT_RESPONSIVE_SCALES.padding
|
|
308
|
+
),
|
|
309
|
+
margin: mergeScaleCategory(
|
|
310
|
+
config.responsiveScales.margin as BreakpointScales | undefined,
|
|
311
|
+
DEFAULT_RESPONSIVE_SCALES.margin
|
|
312
|
+
),
|
|
313
|
+
gap: mergeScaleCategory(
|
|
314
|
+
config.responsiveScales.gap as BreakpointScales | undefined,
|
|
315
|
+
DEFAULT_RESPONSIVE_SCALES.gap
|
|
316
|
+
),
|
|
268
317
|
};
|
|
269
318
|
|
|
270
319
|
return scales;
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
* the project.config.json file once and exposes typed sections.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { BreakpointConfig } from '../../shared/breakpoints';
|
|
10
|
-
import { DEFAULT_BREAKPOINTS } from '../../shared/breakpoints';
|
|
11
|
-
import type { ResponsiveScales } from '../../shared/responsiveScaling';
|
|
9
|
+
import type { BreakpointConfig, BreakpointConfigInput, BreakpointEntry } from '../../shared/breakpoints';
|
|
10
|
+
import { DEFAULT_BREAKPOINTS, normalizeBreakpointConfig } from '../../shared/breakpoints';
|
|
11
|
+
import type { ResponsiveScales, BreakpointScales } from '../../shared/responsiveScaling';
|
|
12
12
|
import { DEFAULT_RESPONSIVE_SCALES } from '../../shared/responsiveScaling';
|
|
13
13
|
import type { I18nConfig } from '../../shared/types/components';
|
|
14
14
|
import type { LibrariesConfig, JSLibraryConfig, CSSLibraryConfig } from '../../shared/types/libraries';
|
|
@@ -28,7 +28,7 @@ export interface IconsConfig {
|
|
|
28
28
|
* Raw project config structure from project.config.json
|
|
29
29
|
*/
|
|
30
30
|
interface RawProjectConfig {
|
|
31
|
-
breakpoints?:
|
|
31
|
+
breakpoints?: BreakpointConfigInput;
|
|
32
32
|
responsiveScales?: Partial<ResponsiveScales>;
|
|
33
33
|
i18n?: unknown;
|
|
34
34
|
icons?: IconsConfig;
|
|
@@ -84,23 +84,40 @@ export class ConfigService {
|
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
86
|
* Get breakpoint configuration
|
|
87
|
-
* Returns validated breakpoints
|
|
87
|
+
* Returns validated and normalized breakpoints (always object format)
|
|
88
|
+
* Supports both legacy format { tablet: 1024 } and new format { tablet: { breakpoint: 1024, previewPoint: 768 } }
|
|
88
89
|
*/
|
|
89
90
|
getBreakpoints(): BreakpointConfig {
|
|
90
91
|
if (!this.config?.breakpoints || typeof this.config.breakpoints !== 'object') {
|
|
91
92
|
return { ...DEFAULT_BREAKPOINTS };
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
// Validate breakpoint values
|
|
95
|
-
const
|
|
95
|
+
// Validate breakpoint values before normalization
|
|
96
|
+
const validInput: BreakpointConfigInput = {};
|
|
96
97
|
for (const [key, value] of Object.entries(this.config.breakpoints)) {
|
|
97
98
|
if (typeof value === 'number' && value > 0) {
|
|
98
|
-
|
|
99
|
+
// Legacy format: number
|
|
100
|
+
validInput[key] = value;
|
|
101
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
102
|
+
// New format: object with breakpoint and optional previewPoint
|
|
103
|
+
const entry = value as BreakpointEntry;
|
|
104
|
+
if (typeof entry.breakpoint === 'number' && entry.breakpoint > 0) {
|
|
105
|
+
validInput[key] = {
|
|
106
|
+
breakpoint: entry.breakpoint,
|
|
107
|
+
previewPoint: typeof entry.previewPoint === 'number' && entry.previewPoint > 0
|
|
108
|
+
? entry.previewPoint
|
|
109
|
+
: entry.breakpoint,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
99
112
|
}
|
|
100
113
|
}
|
|
101
114
|
|
|
102
|
-
// Return
|
|
103
|
-
|
|
115
|
+
// Return normalized breakpoints or defaults if none valid
|
|
116
|
+
if (Object.keys(validInput).length === 0) {
|
|
117
|
+
return { ...DEFAULT_BREAKPOINTS };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return normalizeBreakpointConfig(validInput);
|
|
104
121
|
}
|
|
105
122
|
|
|
106
123
|
/**
|
|
@@ -115,18 +132,56 @@ export class ConfigService {
|
|
|
115
132
|
return migrateI18nConfig(this.config.i18n);
|
|
116
133
|
}
|
|
117
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Deep merge scale categories, preserving user-defined breakpoints
|
|
137
|
+
* while filling in missing values from defaults
|
|
138
|
+
*/
|
|
139
|
+
private mergeScaleCategory(
|
|
140
|
+
userScales: BreakpointScales | undefined,
|
|
141
|
+
defaultScales: BreakpointScales | undefined
|
|
142
|
+
): BreakpointScales | undefined {
|
|
143
|
+
if (!userScales && !defaultScales) return undefined;
|
|
144
|
+
if (!userScales) return defaultScales ? { ...defaultScales } : undefined;
|
|
145
|
+
if (!defaultScales) return { ...userScales };
|
|
146
|
+
|
|
147
|
+
// User scales take precedence, but include defaults for breakpoints not specified
|
|
148
|
+
return {
|
|
149
|
+
...defaultScales,
|
|
150
|
+
...userScales,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
118
154
|
/**
|
|
119
155
|
* Get responsive scales configuration
|
|
120
|
-
*
|
|
156
|
+
* Supports dynamic breakpoints - scales are keyed by breakpoint name
|
|
157
|
+
* Deep merges scale categories to preserve user breakpoint definitions
|
|
121
158
|
*/
|
|
122
159
|
getResponsiveScales(): ResponsiveScales {
|
|
123
160
|
if (!this.config?.responsiveScales || typeof this.config.responsiveScales !== 'object') {
|
|
124
161
|
return { ...DEFAULT_RESPONSIVE_SCALES };
|
|
125
162
|
}
|
|
126
163
|
|
|
164
|
+
const userScales = this.config.responsiveScales;
|
|
165
|
+
|
|
127
166
|
return {
|
|
128
|
-
|
|
129
|
-
|
|
167
|
+
enabled: userScales.enabled ?? DEFAULT_RESPONSIVE_SCALES.enabled,
|
|
168
|
+
baseReference: userScales.baseReference ?? DEFAULT_RESPONSIVE_SCALES.baseReference,
|
|
169
|
+
fontSize: this.mergeScaleCategory(
|
|
170
|
+
userScales.fontSize as BreakpointScales | undefined,
|
|
171
|
+
DEFAULT_RESPONSIVE_SCALES.fontSize
|
|
172
|
+
),
|
|
173
|
+
padding: this.mergeScaleCategory(
|
|
174
|
+
userScales.padding as BreakpointScales | undefined,
|
|
175
|
+
DEFAULT_RESPONSIVE_SCALES.padding
|
|
176
|
+
),
|
|
177
|
+
margin: this.mergeScaleCategory(
|
|
178
|
+
userScales.margin as BreakpointScales | undefined,
|
|
179
|
+
DEFAULT_RESPONSIVE_SCALES.margin
|
|
180
|
+
),
|
|
181
|
+
gap: this.mergeScaleCategory(
|
|
182
|
+
userScales.gap as BreakpointScales | undefined,
|
|
183
|
+
DEFAULT_RESPONSIVE_SCALES.gap
|
|
184
|
+
),
|
|
130
185
|
};
|
|
131
186
|
}
|
|
132
187
|
|
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
* Escape HTML special characters to prevent XSS
|
|
8
8
|
*/
|
|
9
9
|
export function escapeHtml(unsafe: string): string {
|
|
10
|
+
// Handle non-string values defensively
|
|
11
|
+
if (typeof unsafe !== 'string') {
|
|
12
|
+
if (unsafe === null || unsafe === undefined) {
|
|
13
|
+
return '';
|
|
14
|
+
}
|
|
15
|
+
// Convert arrays/objects/numbers to string
|
|
16
|
+
unsafe = String(unsafe);
|
|
17
|
+
}
|
|
10
18
|
return unsafe
|
|
11
19
|
.replace(/&/g, '&')
|
|
12
20
|
.replace(/</g, '<')
|
package/lib/server/ssr/index.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
// Main rendering functions
|
|
17
|
-
export { renderPageSSR, extractPageMeta, generateMetaTags } from './ssrRenderer';
|
|
17
|
+
export { renderPageSSR, extractPageMeta, generateMetaTags, buildComponentHTML } from './ssrRenderer';
|
|
18
18
|
export type { CMSContext, PageMeta } from './ssrRenderer';
|
|
19
19
|
export { generateSSRHTML } from './htmlGenerator';
|
|
20
20
|
export type { SSRHTMLResult, GenerateSSRHTMLOptions } from './htmlGenerator';
|