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
@@ -3,7 +3,7 @@
3
3
  * Converts JSON component structures to HTML strings for SEO-friendly initial page loads
4
4
  */
5
5
 
6
- import type { ComponentNode, ComponentDefinition, JSONPage, CMSListNode, CMSItem, EmbedNode, LinkNode, LocaleListNode, CMSFilterCondition } from '../../shared/types';
6
+ import type { ComponentNode, ComponentDefinition, JSONPage, CMSItem, EmbedNode, LinkNode, LocaleListNode, CMSFilterCondition } from '../../shared/types';
7
7
  import type { TemplateContext, NestedCMSListConfig } from '../../shared/types/cms';
8
8
  import { processItemTemplate, processItemPropsTemplate, hasItemTemplates, buildTemplateContext, resolveItemsTemplate, resolveTemplateRawValue, addItemUrl, getNestedValue, type ValueResolver } from '../../shared/itemTemplateUtils';
9
9
  import { singularize } from '../../shared/types/cms';
@@ -15,7 +15,8 @@ import { loadBreakpointConfig, loadI18nConfig } from '../jsonLoader';
15
15
  import type { I18nConfig } from '../../shared/types/components';
16
16
  import { extractLocaleFromPath, DEFAULT_I18N_CONFIG, resolveI18nValue, buildLocalizedPath, isI18nValue } from '../../shared/i18n';
17
17
  import { NODE_TYPE } from '../../shared/constants';
18
- import { isComponentNode, isHtmlNode, isLinkNode, isEmbedNode, isLocaleListNode, isCMSListNode, markAsSlotContent, isBooleanMapping } from '../../shared/nodeUtils';
18
+ import { isComponentNode, isHtmlNode, isLinkNode, isEmbedNode, isLocaleListNode, isListNode, markAsSlotContent, isBooleanMapping } from '../../shared/nodeUtils';
19
+ import type { ListNode } from '../../shared/registry/nodeTypes/ListNodeType';
19
20
  import type { CMSService } from '../services/cmsService';
20
21
  import { extractAttributesFromNode } from '../../shared/attributeNodeUtils';
21
22
  import { SSRRegistry } from '../../shared/registry/SSRRegistry';
@@ -218,8 +219,9 @@ const ssrComponentRegistry = new SSRRegistry();
218
219
 
219
220
  /**
220
221
  * Build HTML string from component node (server-side)
222
+ * Exported for component preview rendering
221
223
  */
222
- async function buildComponentHTML(
224
+ export async function buildComponentHTML(
223
225
  node: ComponentNode | ComponentNode[] | string | number | null | undefined,
224
226
  globalComponents: Record<string, ComponentDefinition> = {},
225
227
  pageComponents: Record<string, ComponentDefinition> = {},
@@ -275,25 +277,29 @@ async function buildComponentHTML(
275
277
  }
276
278
 
277
279
  /**
278
- * Render a nested CMS list as a placeholder for client-side hydration.
279
- * Used when parent cms-list has emitTemplate: true.
280
+ * Render a nested list (collection mode) as a placeholder for client-side hydration.
281
+ * Used when parent list has emitTemplate: true.
280
282
  * The placeholder contains the configuration and template for MenoFilter to hydrate.
281
283
  */
282
- async function renderNestedCMSListPlaceholder(
283
- node: CMSListNode,
284
+ async function renderNestedListPlaceholder(
285
+ node: ListNode,
284
286
  ctx: SSRContext
285
287
  ): Promise<string> {
288
+ // Get source - handle both string and pre-resolved array sources
289
+ const sourceValue = node.source || (node as any).collection;
290
+ const sourceStr = typeof sourceValue === 'string' ? sourceValue : '';
291
+
286
292
  // Track this collection for client-side data injection
287
- if (ctx.neededCollections) {
288
- ctx.neededCollections.add(node.collection);
293
+ if (ctx.neededCollections && sourceStr) {
294
+ ctx.neededCollections.add(sourceStr);
289
295
  }
290
296
 
291
297
  // Build configuration for client-side hydration
292
298
  const config: NestedCMSListConfig = {
293
- collection: node.collection,
299
+ collection: sourceStr,
294
300
  itemAs: node.itemAs,
295
301
  items: node.items,
296
- filter: node.filter,
302
+ filter: node.filter as CMSFilterCondition | CMSFilterCondition[] | Record<string, unknown> | undefined,
297
303
  sort: node.sort,
298
304
  limit: node.limit,
299
305
  offset: node.offset,
@@ -315,7 +321,7 @@ async function renderNestedCMSListPlaceholder(
315
321
  const classes: string[] = [];
316
322
  if (node.style) {
317
323
  const nodeStyle = node.style as ResponsiveStyleObject;
318
- validateStyleCoverage(nodeStyle, 'nested-cms-list');
324
+ validateStyleCoverage(nodeStyle, 'nested-list');
319
325
  classes.push(...responsiveStylesToClasses(nodeStyle));
320
326
  }
321
327
  const classAttr = classes.length > 0
@@ -326,108 +332,103 @@ async function renderNestedCMSListPlaceholder(
326
332
  const configJson = escapeHtml(JSON.stringify(config));
327
333
 
328
334
  // Emit placeholder with embedded config and template
329
- return `<div${classAttr} data-cms-list-nested="true" data-collection="${escapeHtml(node.collection)}" data-cms-config="${configJson}">` +
335
+ return `<div${classAttr} data-cms-list-nested="true" data-collection="${escapeHtml(sourceStr)}" data-cms-config="${configJson}">` +
330
336
  `<template data-nested-template>${templateContent}</template>` +
331
337
  `</div>`;
332
338
  }
333
339
 
334
340
  /**
335
- * Process CMSList node - renders children for each CMS item
336
- * Wraps children in a container div with styles and attributes (like ComponentBuilder)
337
- * Supports named contexts via `itemAs` and direct item IDs via `items`
341
+ * Unified List node processor - renders children for each item
342
+ * Supports both source types:
343
+ * - 'prop': Reads from component props or template context
344
+ * - 'collection': Queries from CMS collections
338
345
  */
339
- async function processCMSList(node: CMSListNode, ctx: SSRContext): Promise<string> {
340
- if (!ctx.cmsService) {
341
- console.warn('CMSList requires CMS service');
346
+ async function processList(node: ListNode, ctx: SSRContext): Promise<string> {
347
+ // Determine source type (default to 'prop', but handle legacy 'cms-list' type)
348
+ const nodeType = (node as any).type;
349
+ const isLegacyCMSList = nodeType === 'cms-list';
350
+ const sourceType = isLegacyCMSList ? 'collection' : (node.sourceType || 'prop');
351
+
352
+ // For collection mode, check CMS service availability
353
+ if (sourceType === 'collection' && !ctx.cmsService) {
354
+ console.warn('List with sourceType "collection" requires CMS service');
342
355
  return '';
343
356
  }
344
357
 
345
358
  // When in templateMode with nestedCMSListMode, emit placeholder for client-side hydration
346
- // This allows nested cms-lists inside emitTemplate to work with MenoFilter
347
- if (ctx.templateMode && ctx.nestedCMSListMode) {
348
- return renderNestedCMSListPlaceholder(node, ctx);
359
+ // This allows nested lists inside emitTemplate to work with MenoFilter
360
+ if (sourceType === 'collection' && ctx.templateMode && ctx.nestedCMSListMode) {
361
+ return renderNestedListPlaceholder(node, ctx);
349
362
  }
350
363
 
364
+ // Get source - handle both new 'source' and legacy 'collection' property
365
+ // Source can be a string (prop name) or array (pre-resolved by processStructure)
366
+ const rawSource = node.source || (node as any).collection;
367
+ const source = typeof rawSource === 'string' ? rawSource : '';
368
+ const sourceIsResolved = Array.isArray(rawSource);
369
+
351
370
  // Determine variable name for this list's items
352
- const variableName = node.itemAs || singularize(node.collection);
371
+ let variableName: string;
372
+ if (node.itemAs) {
373
+ variableName = node.itemAs;
374
+ } else if (sourceType === 'collection') {
375
+ variableName = singularize(source);
376
+ } else {
377
+ variableName = 'item';
378
+ }
353
379
 
354
- // Get schema to compute _url for items
355
- const schema = ctx.cmsService.getSchema(node.collection);
380
+ // Get items based on sourceType
381
+ let items: unknown[];
356
382
 
357
- let items: CMSItem[];
358
-
359
- // Check if items are specified directly (for nested reference lists)
360
- if (node.items) {
361
- // If items is a template expression, resolve it from current context
362
- if (typeof node.items === 'string' && node.items.startsWith('{{')) {
363
- let resolvedIds: string | string[] | undefined;
364
-
365
- // Check if this is a CMS template ({{cms.field}}) - resolve from cmsContext
366
- if (node.items.startsWith('{{cms.') && ctx.cmsContext?.cms) {
367
- const fieldPath = node.items.slice(6, -2); // Extract "field" from "{{cms.field}}"
368
- let value: unknown = ctx.cmsContext.cms;
369
- for (const part of fieldPath.split('.')) {
370
- if (value && typeof value === 'object' && part in value) {
371
- value = (value as Record<string, unknown>)[part];
372
- } else {
373
- value = undefined;
374
- break;
375
- }
376
- }
377
- if (value !== null && value !== undefined) {
378
- resolvedIds = Array.isArray(value) ? value.map(v => String(v)) : String(value);
379
- }
380
- } else {
381
- // Otherwise resolve from template context (for nested cms-lists)
382
- const parentContext = ctx.templateContext || { _type: 'template' as const };
383
- resolvedIds = resolveItemsTemplate(node.items, parentContext);
384
- }
385
-
386
- if (!resolvedIds) {
387
- return '';
388
- }
389
- const ids = Array.isArray(resolvedIds) ? resolvedIds : [resolvedIds];
390
- items = await ctx.cmsService.getItemsByIds(node.collection, ids);
383
+ if (sourceType === 'collection') {
384
+ // Collection mode: Query from CMS service
385
+ items = await getCollectionItems(node, source, ctx);
386
+ } else {
387
+ // Prop mode: Read from component props or template context
388
+ // If source was already resolved to an array by processStructure (e.g., from {{features}}),
389
+ // use it directly instead of looking up by prop name
390
+ if (sourceIsResolved) {
391
+ // Source was resolved to array by processStructure
392
+ items = rawSource as unknown[];
393
+ } else if (source) {
394
+ // Source is a prop name or template expression - resolve it
395
+ items = getPropItems(source, ctx);
391
396
  } else {
392
- // Direct IDs (string or array)
393
- const ids = Array.isArray(node.items) ? node.items : [node.items];
394
- items = await ctx.cmsService.getItemsByIds(node.collection, ids);
397
+ items = [];
395
398
  }
396
- } else {
397
- // Build query from node props (resolve filter templates for nested cms-list support)
398
- const query = {
399
- collection: node.collection,
400
- filter: resolveFilterTemplates(node.filter, ctx.templateContext),
401
- sort: node.sort,
402
- limit: node.limit,
403
- offset: node.offset,
404
- };
405
- items = await ctx.cmsService.queryItems(query);
406
399
  }
407
400
 
408
- // Exclude current item if option is set and we have a current CMS context
409
- if (node.excludeCurrentItem && ctx.cmsContext?.cms?._id) {
410
- const currentId = ctx.cmsContext.cms._id as string;
411
- items = items.filter(item => item._id !== currentId);
401
+ // Apply shared options: offset and limit
402
+ if (node.offset && sourceType === 'prop') {
403
+ items = items.slice(node.offset);
404
+ }
405
+ if (node.limit && sourceType === 'prop') {
406
+ items = items.slice(0, node.limit);
412
407
  }
413
408
 
414
409
  if (items.length === 0) {
415
410
  return '';
416
411
  }
417
412
 
413
+ // Get schema for URL computation (collection mode only)
414
+ const schema = sourceType === 'collection' && ctx.cmsService
415
+ ? ctx.cmsService.getSchema(source)
416
+ : undefined;
417
+
418
418
  // Render children for each item
419
419
  const renderedItems: string[] = [];
420
420
 
421
421
  for (let i = 0; i < items.length; i++) {
422
422
  // Add computed _url field if schema is available
423
+ const rawItem = items[i] as Record<string, unknown>;
423
424
  const item = schema
424
- ? addItemUrl(items[i], schema, ctx.locale, ctx.i18nConfig)
425
- : items[i];
425
+ ? addItemUrl(rawItem as CMSItem, schema, ctx.locale, ctx.i18nConfig)
426
+ : rawItem;
426
427
 
427
428
  // Build template context with named variable (preserves parent context for nested lists)
428
429
  const templateContext = buildTemplateContext(
429
430
  variableName,
430
- item,
431
+ item as CMSItem,
431
432
  i,
432
433
  items.length,
433
434
  ctx.templateContext
@@ -446,23 +447,23 @@ async function processCMSList(node: CMSListNode, ctx: SSRContext): Promise<strin
446
447
 
447
448
  const childrenHTML = renderedItems.join('');
448
449
 
449
- // Build wrapper div with styles and attributes (like ComponentBuilder)
450
+ // Build wrapper div with styles and attributes
450
451
  const classes: string[] = [];
451
452
 
452
453
  // Convert node.style to utility classes
453
454
  if (node.style) {
454
455
  const nodeStyle = node.style as ResponsiveStyleObject;
455
- validateStyleCoverage(nodeStyle, 'cms-list');
456
+ validateStyleCoverage(nodeStyle, 'list');
456
457
  classes.push(...responsiveStylesToClasses(nodeStyle));
457
458
  }
458
459
 
459
- // Handle interactive styles for cms-list wrapper (cast to InteractiveStyles since inferred type is slightly different)
460
- const cmsListInteractiveStyles = node.interactiveStyles as InteractiveStyles | undefined;
461
- const cmsListGenerateElementClass = node.generateElementClass;
462
- const cmsListLabel = node.label;
463
- let cmsListCssVariables: Record<string, string> = {};
460
+ // Handle interactive styles for list wrapper
461
+ const listInteractiveStyles = node.interactiveStyles as InteractiveStyles | undefined;
462
+ const listGenerateElementClass = node.generateElementClass;
463
+ const listLabel = node.label;
464
+ let listCssVariables: Record<string, string> = {};
464
465
 
465
- if ((cmsListInteractiveStyles && cmsListInteractiveStyles.length > 0) || cmsListGenerateElementClass) {
466
+ if ((listInteractiveStyles && listInteractiveStyles.length > 0) || listGenerateElementClass) {
466
467
  const useComponentContext = Boolean(ctx.componentContext);
467
468
  const effectiveFileType = useComponentContext ? 'component' : 'page';
468
469
  const effectiveFileName = useComponentContext ? ctx.componentContext : (ctx.pagePath
@@ -472,7 +473,7 @@ async function processCMSList(node: CMSListNode, ctx: SSRContext): Promise<strin
472
473
  const elementClassCtx: ElementClassContext = {
473
474
  fileType: effectiveFileType,
474
475
  fileName: effectiveFileName || 'page',
475
- label: cmsListLabel,
476
+ label: listLabel,
476
477
  path: ctx.elementPath || [],
477
478
  };
478
479
  const elementClass = generateElementClassName(elementClassCtx);
@@ -481,24 +482,24 @@ async function processCMSList(node: CMSListNode, ctx: SSRContext): Promise<strin
481
482
  classes.unshift(elementClass);
482
483
 
483
484
  // Register interactive styles for CSS generation with mapping support
484
- if (cmsListInteractiveStyles && cmsListInteractiveStyles.length > 0 && ctx.interactiveStylesMap) {
485
- if (ctx.componentResolvedProps && hasInteractiveStyleMappings(cmsListInteractiveStyles)) {
486
- const { resolvedStyles, mappings } = extractInteractiveStyleMappings(cmsListInteractiveStyles);
487
- cmsListCssVariables = resolveExtractedMappings(mappings, ctx.componentResolvedProps);
485
+ if (listInteractiveStyles && listInteractiveStyles.length > 0 && ctx.interactiveStylesMap) {
486
+ if (ctx.componentResolvedProps && hasInteractiveStyleMappings(listInteractiveStyles)) {
487
+ const { resolvedStyles, mappings } = extractInteractiveStyleMappings(listInteractiveStyles);
488
+ listCssVariables = resolveExtractedMappings(mappings, ctx.componentResolvedProps);
488
489
  ctx.interactiveStylesMap.set(elementClass, resolvedStyles);
489
490
  } else {
490
- ctx.interactiveStylesMap.set(elementClass, cmsListInteractiveStyles);
491
+ ctx.interactiveStylesMap.set(elementClass, listInteractiveStyles);
491
492
  }
492
493
  }
493
494
  }
494
495
 
495
496
  // Build style attribute with CSS variables if any
496
- let cmsListStyleAttr = '';
497
- if (Object.keys(cmsListCssVariables).length > 0) {
498
- const styleString = Object.entries(cmsListCssVariables)
497
+ let listStyleAttr = '';
498
+ if (Object.keys(listCssVariables).length > 0) {
499
+ const styleString = Object.entries(listCssVariables)
499
500
  .map(([k, v]) => `${k}: ${v}`)
500
501
  .join('; ');
501
- cmsListStyleAttr = ` style="${escapeHtml(styleString)}"`;
502
+ listStyleAttr = ` style="${escapeHtml(styleString)}"`;
502
503
  }
503
504
 
504
505
  // Extract attributes from node
@@ -523,28 +524,139 @@ async function processCMSList(node: CMSListNode, ctx: SSRContext): Promise<strin
523
524
  }
524
525
  }
525
526
 
526
- // Add data attributes for consistency with client
527
- attrsStr += ` data-cms-list="true"`;
528
- attrsStr += ` data-collection="${escapeHtml(node.collection || '')}"`;
527
+ // Add data attributes based on source type
528
+ if (sourceType === 'collection') {
529
+ attrsStr += ` data-cms-list="true"`;
530
+ attrsStr += ` data-collection="${escapeHtml(source || '')}"`;
531
+ } else {
532
+ attrsStr += ` data-list="true"`;
533
+ attrsStr += ` data-source="${escapeHtml(source || (sourceIsResolved ? 'resolved' : ''))}"`;
534
+ }
529
535
 
530
- // Emit item template when emitTemplate is enabled
536
+ // Emit item template when emitTemplate is enabled (collection mode)
531
537
  // This allows MenoFilter to dynamically render items from JSON data
532
538
  let templateHtml = '';
533
- if (node.emitTemplate && node.children && node.children.length > 0) {
539
+ if (sourceType === 'collection' && node.emitTemplate && node.children && node.children.length > 0) {
534
540
  // Render children with templateMode to preserve {{item.field}} placeholders
535
- // Keep templateContext for nested cms-list resolution, set nestedCMSListMode
536
- // so nested cms-lists emit placeholders for client-side hydration
541
+ // Keep templateContext for nested list resolution, set nestedCMSListMode
542
+ // so nested lists emit placeholders for client-side hydration
537
543
  const templateCtx: SSRContext = {
538
544
  ...ctx,
539
545
  templateMode: true,
540
- templateContext: ctx.templateContext, // Keep parent context for nested cms-list resolution
541
- nestedCMSListMode: true, // Nested cms-lists will emit placeholders for client hydration
546
+ templateContext: ctx.templateContext, // Keep parent context for nested list resolution
547
+ nestedCMSListMode: true, // Nested lists will emit placeholders for client hydration
542
548
  };
543
549
  const templateContent = await renderChildrenAsync(node.children, templateCtx);
544
550
  templateHtml = `<template data-meno-item>${templateContent}</template>`;
545
551
  }
546
552
 
547
- return `<div${classAttr}${cmsListStyleAttr}${attrsStr}>${childrenHTML}${templateHtml}</div>`;
553
+ // Use configurable tag (defaults to 'div')
554
+ const tag = node.tag || 'div';
555
+ return `<${tag}${classAttr}${listStyleAttr}${attrsStr}>${childrenHTML}${templateHtml}</${tag}>`;
556
+ }
557
+
558
+ /**
559
+ * Get items from CMS collection (for sourceType: 'collection')
560
+ */
561
+ async function getCollectionItems(node: ListNode, source: string, ctx: SSRContext): Promise<CMSItem[]> {
562
+ if (!ctx.cmsService) return [];
563
+
564
+ let items: CMSItem[];
565
+
566
+ // Check if items are specified directly (for nested reference lists)
567
+ if (node.items) {
568
+ // If items is a template expression, resolve it from current context
569
+ if (typeof node.items === 'string' && node.items.startsWith('{{')) {
570
+ let resolvedIds: string | string[] | undefined;
571
+
572
+ // Check if this is a CMS template ({{cms.field}}) - resolve from cmsContext
573
+ if (node.items.startsWith('{{cms.') && ctx.cmsContext?.cms) {
574
+ const fieldPath = node.items.slice(6, -2); // Extract "field" from "{{cms.field}}"
575
+ let value: unknown = ctx.cmsContext.cms;
576
+ for (const part of fieldPath.split('.')) {
577
+ if (value && typeof value === 'object' && part in value) {
578
+ value = (value as Record<string, unknown>)[part];
579
+ } else {
580
+ value = undefined;
581
+ break;
582
+ }
583
+ }
584
+ if (value !== null && value !== undefined) {
585
+ resolvedIds = Array.isArray(value) ? value.map(v => String(v)) : String(value);
586
+ }
587
+ } else {
588
+ // Otherwise resolve from template context (for nested lists)
589
+ const parentContext = ctx.templateContext || { _type: 'template' as const };
590
+ resolvedIds = resolveItemsTemplate(node.items, parentContext);
591
+ }
592
+
593
+ if (!resolvedIds) {
594
+ return [];
595
+ }
596
+ const ids = Array.isArray(resolvedIds) ? resolvedIds : [resolvedIds];
597
+ items = await ctx.cmsService.getItemsByIds(source, ids);
598
+ } else {
599
+ // Direct IDs (string or array)
600
+ const ids = Array.isArray(node.items) ? node.items : [node.items];
601
+ items = await ctx.cmsService.getItemsByIds(source, ids);
602
+ }
603
+ } else {
604
+ // Build query from node props (resolve filter templates for nested list support)
605
+ const query = {
606
+ collection: source,
607
+ filter: resolveFilterTemplates(node.filter as CMSFilterCondition | CMSFilterCondition[] | Record<string, unknown> | undefined, ctx.templateContext),
608
+ sort: node.sort,
609
+ limit: node.limit,
610
+ offset: node.offset,
611
+ };
612
+ items = await ctx.cmsService.queryItems(query);
613
+ }
614
+
615
+ // Exclude current item if option is set and we have a current CMS context
616
+ if (node.excludeCurrentItem && ctx.cmsContext?.cms?._id) {
617
+ const currentId = ctx.cmsContext.cms._id as string;
618
+ items = items.filter(item => item._id !== currentId);
619
+ }
620
+
621
+ return items;
622
+ }
623
+
624
+ /**
625
+ * Get items from component props or template context (for sourceType: 'prop')
626
+ */
627
+ function getPropItems(source: string, ctx: SSRContext): unknown[] {
628
+ // Resolve the source - can be a direct prop name or a template expression
629
+ if (source.startsWith('{{') && source.endsWith('}}')) {
630
+ // Template expression - resolve from template context (for nested lists)
631
+ // e.g., {{category.items}} where category is from parent list
632
+ const templateCtx = ctx.templateContext;
633
+ if (templateCtx) {
634
+ const path = source.slice(2, -2).trim(); // Extract "category.items"
635
+ const resolved = getNestedValue(templateCtx as Record<string, unknown>, path);
636
+ if (Array.isArray(resolved)) {
637
+ return resolved;
638
+ } else if (resolved !== undefined) {
639
+ // Warn if resolved to non-array value (likely a bug in template)
640
+ console.warn(`List source "${source}" resolved to non-array value (type: ${typeof resolved})`);
641
+ }
642
+ }
643
+ return [];
644
+ } else {
645
+ // Direct prop name - resolve from component props
646
+ const propValue = ctx.componentResolvedProps?.[source];
647
+ if (Array.isArray(propValue)) {
648
+ return propValue;
649
+ }
650
+
651
+ // Also try template context for prop mode (for cms context values)
652
+ if (ctx.cmsContext?.cms) {
653
+ const cmsValue = ctx.cmsContext.cms[source];
654
+ if (Array.isArray(cmsValue)) {
655
+ return cmsValue;
656
+ }
657
+ }
658
+ }
659
+ return [];
548
660
  }
549
661
 
550
662
  /**
@@ -617,9 +729,10 @@ async function renderNode(
617
729
  return '';
618
730
  }
619
731
 
620
- // Handle CMSList nodes (async operation)
621
- if (isCMSListNode(node)) {
622
- return await processCMSList(node, ctx);
732
+ // Handle List nodes (async operation - handles both prop and collection source types)
733
+ // isListNode() also matches legacy 'cms-list' type for migration
734
+ if (isListNode(node)) {
735
+ return await processList(node, ctx);
623
736
  }
624
737
 
625
738
  const nodeType = 'type' in node ? node.type : undefined;
@@ -628,8 +741,29 @@ async function renderNode(
628
741
 
629
742
  // Handle embed nodes - render custom HTML content
630
743
  if (isEmbedNode(node)) {
744
+ // Process templates in html property before sanitization
745
+ let htmlContent = node.html;
746
+
747
+ // In template mode, preserve {{item.field}} placeholders for client-side rendering
748
+ if (ctx.templateMode) {
749
+ // Only process CMS template strings ({{cms.field}}) - preserve item templates
750
+ if (ctx.cmsContext?.cms && htmlContent.includes('{{cms.')) {
751
+ htmlContent = processCMSTemplate(htmlContent, ctx.cmsContext.cms, locale, i18nConfig);
752
+ }
753
+ } else {
754
+ // Normal mode: Process item template strings first (for CMSList context)
755
+ const templateCtx = getTemplateContext(ctx);
756
+ if (templateCtx && hasItemTemplates(htmlContent)) {
757
+ htmlContent = processItemTemplate(htmlContent, templateCtx, getI18nResolver(ctx));
758
+ }
759
+ // Process CMS template strings like {{cms.title}}
760
+ if (ctx.cmsContext?.cms && htmlContent.includes('{{cms.')) {
761
+ htmlContent = processCMSTemplate(htmlContent, ctx.cmsContext.cms, locale, i18nConfig);
762
+ }
763
+ }
764
+
631
765
  // Sanitize HTML with allowlist for SVG, rich-text formatting, and common elements (same as client)
632
- const sanitizedHtml = DOMPurify.sanitize(node.html, {
766
+ const sanitizedHtml = DOMPurify.sanitize(htmlContent, {
633
767
  ALLOWED_TAGS: ['svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'text', 'tspan', 'image', 'defs', 'use', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'mask', 'pattern', 'marker', 'symbol', 'a', 'div', 'span', 'p', 'br', 'button', 'img', 'iframe', 'video', 'audio', 'source', 'canvas', 'b', 'i', 'u', 'strong', 'em', 'sub', 'sup', 'mark', 's', 'small', 'del', 'ins', 'q', 'abbr', 'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
634
768
  ALLOWED_ATTR: ['class', 'id', 'style', 'width', 'height', 'viewBox', 'xmlns', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'd', 'cx', 'cy', 'r', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'points', 'href', 'src', 'alt', 'target', 'rel', 'data-*', 'aria-*', 'transform', 'opacity', 'fill-opacity', 'stroke-opacity', 'font-size', 'font-family', 'text-anchor', 'dominant-baseline', 'offset', 'stop-color', 'stop-opacity'],
635
769
  KEEP_CONTENT: true