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.
Files changed (50) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/lib/client/core/ComponentBuilder.test.ts +68 -56
  3. package/lib/client/core/ComponentBuilder.ts +6 -4
  4. package/lib/client/core/builders/embedBuilder.ts +10 -1
  5. package/lib/client/core/builders/index.ts +6 -2
  6. package/lib/client/core/builders/{cmsListBuilder.ts → listBuilder.ts} +227 -95
  7. package/lib/client/responsiveStyleResolver.test.ts +12 -12
  8. package/lib/client/responsiveStyleResolver.ts +19 -7
  9. package/lib/client/routing/Router.tsx +35 -7
  10. package/lib/client/templateEngine.test.ts +126 -0
  11. package/lib/client/templateEngine.ts +53 -13
  12. package/lib/server/jsonLoader.test.ts +4 -1
  13. package/lib/server/jsonLoader.ts +64 -15
  14. package/lib/server/services/configService.ts +68 -13
  15. package/lib/server/ssr/attributeBuilder.ts +8 -0
  16. package/lib/server/ssr/index.ts +1 -1
  17. package/lib/server/ssr/ssrRenderer.ts +245 -111
  18. package/lib/server/ssrRenderer.test.ts +197 -3
  19. package/lib/server/validateStyleCoverage.ts +14 -17
  20. package/lib/shared/breakpoints.test.ts +210 -23
  21. package/lib/shared/breakpoints.ts +124 -17
  22. package/lib/shared/constants.test.ts +1 -1
  23. package/lib/shared/constants.ts +5 -1
  24. package/lib/shared/cssGeneration.test.ts +17 -0
  25. package/lib/shared/cssGeneration.ts +49 -12
  26. package/lib/shared/index.ts +3 -0
  27. package/lib/shared/itemTemplateUtils.test.ts +44 -2
  28. package/lib/shared/itemTemplateUtils.ts +15 -2
  29. package/lib/shared/nodeUtils.ts +23 -4
  30. package/lib/shared/registry/BaseNodeTypeRegistry.test.ts +2 -2
  31. package/lib/shared/registry/nodeTypes/ListNodeType.ts +186 -0
  32. package/lib/shared/registry/nodeTypes/SlotMarkerType.ts +6 -0
  33. package/lib/shared/registry/nodeTypes/index.ts +6 -5
  34. package/lib/shared/responsiveScaling.test.ts +87 -0
  35. package/lib/shared/responsiveScaling.ts +33 -29
  36. package/lib/shared/responsiveStyleUtils.test.ts +7 -7
  37. package/lib/shared/responsiveStyleUtils.ts +22 -16
  38. package/lib/shared/styleNodeUtils.ts +5 -5
  39. package/lib/shared/styleValueRegistry.ts +60 -5
  40. package/lib/shared/tree/PathBuilder.ts +3 -3
  41. package/lib/shared/treePathUtils.ts +7 -5
  42. package/lib/shared/types/cms.ts +4 -57
  43. package/lib/shared/types/components.ts +45 -4
  44. package/lib/shared/types/index.ts +13 -0
  45. package/lib/shared/utilityClassConfig.ts +14 -0
  46. package/lib/shared/utilityClassMapper.ts +43 -2
  47. package/lib/shared/validation/propValidator.ts +9 -1
  48. package/lib/shared/validation/schemas.ts +60 -14
  49. package/package.json +1 -1
  50. 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.CMS_LIST) {
456
- // Handle cms-list nodes - they have collection, limit, offset, filter, sort, style, children
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.CMS_LIST,
459
- collection: '',
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
- if (isHtmlNode(processed)) {
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
- (processed as any).html = value;
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) || isCMSListNode(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 = { mobile: 500, tablet: 900 };
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);
@@ -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?: Record<string, number> }>(configContent);
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 breakpoints: BreakpointConfig = {};
218
+ const validInput: BreakpointConfigInput = {};
219
219
  for (const [key, value] of Object.entries(config.breakpoints)) {
220
220
  if (typeof value === 'number' && value > 0) {
221
- breakpoints[key] = value;
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(breakpoints).length > 0) {
227
- return breakpoints;
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
- // Merge with defaults to fill in missing values
296
+ // Deep merge scale categories to preserve user breakpoint definitions
297
+ // while filling in missing values from defaults
265
298
  const scales: ResponsiveScales = {
266
- ...DEFAULT_RESPONSIVE_SCALES,
267
- ...config.responsiveScales,
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?: Record<string, number>;
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 or defaults
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 breakpoints: BreakpointConfig = {};
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
- breakpoints[key] = value;
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 validated breakpoints or defaults if none valid
103
- return Object.keys(breakpoints).length > 0 ? breakpoints : { ...DEFAULT_BREAKPOINTS };
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
- * Merges with defaults for any missing values
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
- ...DEFAULT_RESPONSIVE_SCALES,
129
- ...this.config.responsiveScales,
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, '&amp;')
12
20
  .replace(/</g, '&lt;')
@@ -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';