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
|
@@ -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,
|
|
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,
|
|
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
|
|
279
|
-
* Used when parent
|
|
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
|
|
283
|
-
node:
|
|
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(
|
|
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:
|
|
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-
|
|
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(
|
|
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
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
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
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
347
|
-
if (ctx.templateMode && ctx.nestedCMSListMode) {
|
|
348
|
-
return
|
|
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
|
-
|
|
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
|
|
355
|
-
|
|
380
|
+
// Get items based on sourceType
|
|
381
|
+
let items: unknown[];
|
|
356
382
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
409
|
-
if (node.
|
|
410
|
-
|
|
411
|
-
|
|
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(
|
|
425
|
-
:
|
|
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
|
|
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, '
|
|
456
|
+
validateStyleCoverage(nodeStyle, 'list');
|
|
456
457
|
classes.push(...responsiveStylesToClasses(nodeStyle));
|
|
457
458
|
}
|
|
458
459
|
|
|
459
|
-
// Handle interactive styles for
|
|
460
|
-
const
|
|
461
|
-
const
|
|
462
|
-
const
|
|
463
|
-
let
|
|
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 ((
|
|
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:
|
|
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 (
|
|
485
|
-
if (ctx.componentResolvedProps && hasInteractiveStyleMappings(
|
|
486
|
-
const { resolvedStyles, mappings } = extractInteractiveStyleMappings(
|
|
487
|
-
|
|
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,
|
|
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
|
|
497
|
-
if (Object.keys(
|
|
498
|
-
const styleString = Object.entries(
|
|
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
|
-
|
|
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
|
|
527
|
-
|
|
528
|
-
|
|
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
|
|
536
|
-
// so nested
|
|
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
|
|
541
|
-
nestedCMSListMode: true, // Nested
|
|
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
|
-
|
|
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
|
|
621
|
-
|
|
622
|
-
|
|
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(
|
|
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
|