meno-core 1.0.20 → 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 (34) hide show
  1. package/lib/client/core/ComponentBuilder.test.ts +68 -56
  2. package/lib/client/core/ComponentBuilder.ts +6 -4
  3. package/lib/client/core/builders/embedBuilder.ts +10 -1
  4. package/lib/client/core/builders/index.ts +6 -2
  5. package/lib/client/core/builders/{cmsListBuilder.ts → listBuilder.ts} +227 -95
  6. package/lib/client/routing/Router.tsx +35 -7
  7. package/lib/client/templateEngine.test.ts +126 -0
  8. package/lib/client/templateEngine.ts +32 -11
  9. package/lib/server/ssr/attributeBuilder.ts +8 -0
  10. package/lib/server/ssr/index.ts +1 -1
  11. package/lib/server/ssr/ssrRenderer.ts +223 -110
  12. package/lib/server/ssrRenderer.test.ts +197 -3
  13. package/lib/shared/constants.test.ts +1 -1
  14. package/lib/shared/constants.ts +5 -1
  15. package/lib/shared/cssGeneration.test.ts +17 -0
  16. package/lib/shared/cssGeneration.ts +3 -2
  17. package/lib/shared/index.ts +3 -0
  18. package/lib/shared/itemTemplateUtils.test.ts +44 -2
  19. package/lib/shared/itemTemplateUtils.ts +15 -2
  20. package/lib/shared/nodeUtils.ts +23 -4
  21. package/lib/shared/registry/BaseNodeTypeRegistry.test.ts +2 -2
  22. package/lib/shared/registry/nodeTypes/ListNodeType.ts +186 -0
  23. package/lib/shared/registry/nodeTypes/SlotMarkerType.ts +6 -0
  24. package/lib/shared/registry/nodeTypes/index.ts +6 -5
  25. package/lib/shared/styleNodeUtils.ts +5 -5
  26. package/lib/shared/tree/PathBuilder.ts +3 -3
  27. package/lib/shared/treePathUtils.ts +7 -5
  28. package/lib/shared/types/cms.ts +4 -57
  29. package/lib/shared/types/components.ts +45 -4
  30. package/lib/shared/types/index.ts +13 -0
  31. package/lib/shared/validation/propValidator.ts +9 -1
  32. package/lib/shared/validation/schemas.ts +60 -14
  33. package/package.json +1 -1
  34. package/lib/shared/registry/nodeTypes/CMSListNodeType.ts +0 -109
@@ -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') {
@@ -737,7 +758,7 @@ export function processStructure(
737
758
  if (isComponentNode(processed)) {
738
759
  processed.props = processed.props || {};
739
760
  processed.props.style = resolvedStyle as ResponsiveStyleObject;
740
- } 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)) {
741
762
  processed.style = resolvedStyle as ResponsiveStyleObject;
742
763
  }
743
764
  } else {
@@ -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';
@@ -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
 
351
- // Determine variable name for this list's items
352
- const variableName = node.itemAs || singularize(node.collection);
353
-
354
- // Get schema to compute _url for items
355
- const schema = ctx.cmsService.getSchema(node.collection);
356
-
357
- let items: CMSItem[];
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);
358
369
 
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;
370
+ // Determine variable name for this list's items
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
+ }
364
379
 
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
- }
380
+ // Get items based on sourceType
381
+ let items: unknown[];
385
382
 
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;