meno-core 1.0.21 → 1.0.22
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/lib/client/core/ComponentBuilder.ts +14 -3
- package/lib/client/core/builders/embedBuilder.ts +25 -3
- package/lib/client/core/builders/linkNodeBuilder.ts +18 -1
- package/lib/client/core/builders/listBuilder.ts +12 -3
- package/lib/client/templateEngine.ts +89 -98
- package/lib/server/index.ts +2 -2
- package/lib/server/jsonLoader.ts +124 -46
- package/lib/server/routes/api/components.ts +13 -2
- package/lib/server/services/componentService.ts +255 -29
- package/lib/server/services/configService.ts +27 -0
- package/lib/server/services/index.ts +1 -1
- package/lib/server/ssr/ssrRenderer.ts +36 -6
- package/lib/shared/constants.ts +3 -0
- package/lib/shared/i18n.test.ts +106 -0
- package/lib/shared/i18n.ts +17 -11
- package/lib/shared/registry/NodeTypeDefinition.ts +2 -2
- package/lib/shared/registry/nodeTypes/ListNodeType.ts +20 -2
- package/lib/shared/types/components.ts +12 -3
- package/lib/shared/types/index.ts +1 -0
- package/lib/shared/validation/propValidator.test.ts +50 -0
- package/lib/shared/validation/propValidator.ts +2 -2
- package/lib/shared/validation/schemas.ts +8 -1
- package/package.json +1 -1
|
@@ -362,7 +362,7 @@ export class ComponentBuilder {
|
|
|
362
362
|
const processedProps = this.processPropsTemplates(props, ctx);
|
|
363
363
|
|
|
364
364
|
// Convert styles to utility classes
|
|
365
|
-
const propsWithClasses = this.applyStyleClasses(processedProps, node);
|
|
365
|
+
const propsWithClasses = this.applyStyleClasses(processedProps, node, ctx);
|
|
366
366
|
|
|
367
367
|
// Apply interactive styles
|
|
368
368
|
const propsWithInteractive = this.applyInteractiveStyles(propsWithClasses, node, ctx);
|
|
@@ -502,11 +502,22 @@ export class ComponentBuilder {
|
|
|
502
502
|
/**
|
|
503
503
|
* Apply style classes to props
|
|
504
504
|
*/
|
|
505
|
-
private applyStyleClasses(props: Record<string, unknown>, node: ComponentNode): Record<string, unknown> {
|
|
505
|
+
private applyStyleClasses(props: Record<string, unknown>, node: ComponentNode, ctx: BuilderContext): Record<string, unknown> {
|
|
506
506
|
const nodeStyle = node.style || (isComponentNode(node) ? node.props?.style : undefined);
|
|
507
507
|
|
|
508
508
|
if (nodeStyle && typeof nodeStyle === 'object') {
|
|
509
|
-
|
|
509
|
+
// Process item templates in style values (for List context)
|
|
510
|
+
let processedStyle = nodeStyle as Record<string, unknown>;
|
|
511
|
+
const templateCtx = ctx.templateContext || ctx.itemContext;
|
|
512
|
+
if (templateCtx) {
|
|
513
|
+
const effectiveLocale = ctx.cmsLocale || ctx.locale;
|
|
514
|
+
const config = ctx.i18nConfig || DEFAULT_I18N_CONFIG;
|
|
515
|
+
const i18nResolver: ValueResolver | undefined = effectiveLocale
|
|
516
|
+
? (value: unknown) => resolveI18nValue(value, effectiveLocale, config)
|
|
517
|
+
: undefined;
|
|
518
|
+
processedStyle = processItemPropsTemplate(processedStyle, templateCtx, i18nResolver);
|
|
519
|
+
}
|
|
520
|
+
const utilityClasses = this.getCachedStyleClasses(processedStyle);
|
|
510
521
|
if (utilityClasses.length > 0) {
|
|
511
522
|
const existingClassName = (props.className || '') as string;
|
|
512
523
|
const classArray = existingClassName ? existingClassName.split(/\s+/) : [];
|
|
@@ -15,7 +15,8 @@ import { hasInteractiveStyleMappings, extractInteractiveStyleMappings, resolveEx
|
|
|
15
15
|
import DOMPurify from "isomorphic-dompurify";
|
|
16
16
|
import type { ElementRegistry } from "../../elementRegistry";
|
|
17
17
|
import type { BuilderContext } from "./types";
|
|
18
|
-
import { hasItemTemplates, processItemTemplate } from "../../../shared/itemTemplateUtils";
|
|
18
|
+
import { hasItemTemplates, processItemTemplate, processItemPropsTemplate, type ValueResolver } from "../../../shared/itemTemplateUtils";
|
|
19
|
+
import { resolveI18nValue, DEFAULT_I18N_CONFIG } from "../../../shared/i18n";
|
|
19
20
|
|
|
20
21
|
export interface EmbedBuilderDeps {
|
|
21
22
|
elementRegistry: ElementRegistry;
|
|
@@ -55,7 +56,13 @@ export function buildEmbed(
|
|
|
55
56
|
|
|
56
57
|
// Process item template strings (for List context) - e.g., {{item.icon}}, {{feature.title}}
|
|
57
58
|
if (ctx.templateContext && hasItemTemplates(htmlContent)) {
|
|
58
|
-
|
|
59
|
+
// Create i18n resolver for template processing (matching SSR behavior)
|
|
60
|
+
const effectiveLocale = ctx.cmsLocale || ctx.locale;
|
|
61
|
+
const i18nConfig = ctx.i18nConfig || DEFAULT_I18N_CONFIG;
|
|
62
|
+
const i18nResolver: ValueResolver | undefined = effectiveLocale
|
|
63
|
+
? (value: unknown) => resolveI18nValue(value, effectiveLocale, i18nConfig)
|
|
64
|
+
: undefined;
|
|
65
|
+
htmlContent = processItemTemplate(htmlContent, ctx.templateContext, i18nResolver);
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
// Sanitize HTML with allowlist
|
|
@@ -95,7 +102,22 @@ export function buildEmbed(
|
|
|
95
102
|
|
|
96
103
|
// Convert embed styles to utility classes
|
|
97
104
|
if (node.style) {
|
|
98
|
-
|
|
105
|
+
// Process item templates in style values (for List context)
|
|
106
|
+
let processedStyle = node.style;
|
|
107
|
+
if (ctx.templateContext) {
|
|
108
|
+
// Create i18n resolver for style template processing (matching SSR behavior)
|
|
109
|
+
const effectiveLocale = ctx.cmsLocale || ctx.locale;
|
|
110
|
+
const i18nConfig = ctx.i18nConfig || DEFAULT_I18N_CONFIG;
|
|
111
|
+
const i18nResolver: ValueResolver | undefined = effectiveLocale
|
|
112
|
+
? (value: unknown) => resolveI18nValue(value, effectiveLocale, i18nConfig)
|
|
113
|
+
: undefined;
|
|
114
|
+
processedStyle = processItemPropsTemplate(
|
|
115
|
+
node.style as Record<string, unknown>,
|
|
116
|
+
ctx.templateContext,
|
|
117
|
+
i18nResolver
|
|
118
|
+
) as StyleObject | ResponsiveStyleObject;
|
|
119
|
+
}
|
|
120
|
+
const utilityClasses = responsiveStylesToClasses(processedStyle as StyleObject | ResponsiveStyleObject);
|
|
99
121
|
classNames.push(...utilityClasses);
|
|
100
122
|
}
|
|
101
123
|
|
|
@@ -12,6 +12,8 @@ import { pathToString } from "../../../shared/pathArrayUtils";
|
|
|
12
12
|
import { generateElementClassName, type ElementClassContext } from "../../../shared/elementClassName";
|
|
13
13
|
import { InteractiveStylesRegistry } from "../../InteractiveStylesRegistry";
|
|
14
14
|
import { hasInteractiveStyleMappings, extractInteractiveStyleMappings, resolveExtractedMappings } from "../../../shared/interactiveStyleMappings";
|
|
15
|
+
import { processItemPropsTemplate, type ValueResolver } from "../../../shared/itemTemplateUtils";
|
|
16
|
+
import { resolveI18nValue, DEFAULT_I18N_CONFIG } from "../../../shared/i18n";
|
|
15
17
|
import type { ElementRegistry } from "../../elementRegistry";
|
|
16
18
|
import type { BuilderContext, BuildResult, BuildChildrenContext } from "./types";
|
|
17
19
|
|
|
@@ -80,7 +82,22 @@ export function buildLinkNode(
|
|
|
80
82
|
|
|
81
83
|
// Convert link node styles to utility classes
|
|
82
84
|
if (node.style) {
|
|
83
|
-
|
|
85
|
+
// Process item templates in style values (for List context)
|
|
86
|
+
let processedStyle = node.style;
|
|
87
|
+
if (ctx.templateContext) {
|
|
88
|
+
// Create i18n resolver for style template processing (matching SSR behavior)
|
|
89
|
+
const effectiveLocale = ctx.cmsLocale || ctx.locale;
|
|
90
|
+
const i18nConfig = ctx.i18nConfig || DEFAULT_I18N_CONFIG;
|
|
91
|
+
const i18nResolver: ValueResolver | undefined = effectiveLocale
|
|
92
|
+
? (value: unknown) => resolveI18nValue(value, effectiveLocale, i18nConfig)
|
|
93
|
+
: undefined;
|
|
94
|
+
processedStyle = processItemPropsTemplate(
|
|
95
|
+
node.style as Record<string, unknown>,
|
|
96
|
+
ctx.templateContext,
|
|
97
|
+
i18nResolver
|
|
98
|
+
) as StyleObject | ResponsiveStyleObject;
|
|
99
|
+
}
|
|
100
|
+
const utilityClasses = responsiveStylesToClasses(processedStyle as StyleObject | ResponsiveStyleObject);
|
|
84
101
|
classNames.push(...utilityClasses);
|
|
85
102
|
}
|
|
86
103
|
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* - CMS collections (sourceType: 'collection')
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { createElement as h } from "react";
|
|
8
|
+
import { createElement as h, Fragment } from "react";
|
|
9
9
|
import type { ReactElement } from "react";
|
|
10
10
|
import type { ListNode } from "../../../shared/registry/nodeTypes/ListNodeType";
|
|
11
11
|
import type { CMSItem, CMSFilterCondition, CMSSortConfig, CMSFilterOperator } from "../../../shared/types/cms";
|
|
@@ -221,8 +221,8 @@ export function buildList(
|
|
|
221
221
|
const textColor = isCollectionMode ? '#8b5cf6' : '#3b82f6';
|
|
222
222
|
const label = isCollectionMode ? 'CMS List' : 'List';
|
|
223
223
|
|
|
224
|
-
// Use configurable tag (defaults to 'div') -
|
|
225
|
-
const tag = node.tag || 'div';
|
|
224
|
+
// Use configurable tag (defaults to 'div') - null means fragment mode (no wrapper)
|
|
225
|
+
const tag = node.tag === false ? null : (node.tag || 'div');
|
|
226
226
|
|
|
227
227
|
if (!source && !sourceIsResolved) {
|
|
228
228
|
// No source - render empty container with placeholder
|
|
@@ -239,6 +239,9 @@ export function buildList(
|
|
|
239
239
|
textAlign: 'center'
|
|
240
240
|
}
|
|
241
241
|
}, `${label}: No source - No items`);
|
|
242
|
+
if (tag === null) {
|
|
243
|
+
return emptyState;
|
|
244
|
+
}
|
|
242
245
|
return h(tag, containerProps, emptyState);
|
|
243
246
|
}
|
|
244
247
|
|
|
@@ -257,6 +260,9 @@ export function buildList(
|
|
|
257
260
|
textAlign: 'center'
|
|
258
261
|
}
|
|
259
262
|
}, `${label}: ${source || 'resolved'} - No items`);
|
|
263
|
+
if (tag === null) {
|
|
264
|
+
return emptyState;
|
|
265
|
+
}
|
|
260
266
|
return h(tag, containerProps, emptyState);
|
|
261
267
|
}
|
|
262
268
|
|
|
@@ -316,6 +322,9 @@ export function buildList(
|
|
|
316
322
|
}
|
|
317
323
|
}
|
|
318
324
|
|
|
325
|
+
if (tag === null) {
|
|
326
|
+
return h(Fragment, null, renderedItems);
|
|
327
|
+
}
|
|
319
328
|
return h(tag, containerProps, renderedItems);
|
|
320
329
|
}
|
|
321
330
|
|
|
@@ -167,6 +167,71 @@ export function resolveStyleMapping(
|
|
|
167
167
|
return mappedValue !== undefined ? mappedValue : undefined;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Process a single style value - resolves mappings and evaluates templates.
|
|
172
|
+
* Skips item templates ({{item.field}}, {{varName.field}}) which are resolved later during rendering.
|
|
173
|
+
*
|
|
174
|
+
* @param styleValue - The style value to process (may be string, number, or mapping object)
|
|
175
|
+
* @param props - Component props for resolving mappings and templates
|
|
176
|
+
* @returns The processed style value, or undefined if the value should be skipped
|
|
177
|
+
*/
|
|
178
|
+
function processStyleValue(
|
|
179
|
+
styleValue: unknown,
|
|
180
|
+
props: Record<string, unknown> | undefined
|
|
181
|
+
): string | number | undefined {
|
|
182
|
+
// First try style mapping resolution
|
|
183
|
+
const resolved = resolveStyleMapping(styleValue, props);
|
|
184
|
+
if (resolved !== undefined) {
|
|
185
|
+
return resolved;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// String values - evaluate templates (but skip item templates)
|
|
189
|
+
if (typeof styleValue === 'string') {
|
|
190
|
+
if (hasTemplates(styleValue) && !hasItemTemplates(styleValue)) {
|
|
191
|
+
return processCodeTemplates(styleValue, props);
|
|
192
|
+
}
|
|
193
|
+
return styleValue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Number values - pass through
|
|
197
|
+
if (typeof styleValue === 'number') {
|
|
198
|
+
return styleValue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Build evaluation context for template processing.
|
|
206
|
+
* Merges global context, props, componentDef, and optionally itemContext.
|
|
207
|
+
*
|
|
208
|
+
* @param context - The TemplateContext containing props and componentDef
|
|
209
|
+
* @param includeItemContext - Whether to include itemContext from the context object
|
|
210
|
+
* @returns A flat object suitable for template evaluation
|
|
211
|
+
*/
|
|
212
|
+
function buildEvalContext(
|
|
213
|
+
context: TemplateContext,
|
|
214
|
+
includeItemContext: boolean = false
|
|
215
|
+
): Record<string, unknown> {
|
|
216
|
+
const evalContext: Record<string, unknown> = {
|
|
217
|
+
...getGlobalTemplateContext(),
|
|
218
|
+
...context.props
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
if (context.componentDef && typeof context.componentDef === 'object') {
|
|
222
|
+
Object.assign(evalContext, context.componentDef as Record<string, unknown>);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (includeItemContext) {
|
|
226
|
+
const itemContext = (context as Record<string, unknown>).itemContext as Record<string, unknown> | undefined;
|
|
227
|
+
if (itemContext) {
|
|
228
|
+
Object.assign(evalContext, itemContext);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return evalContext;
|
|
233
|
+
}
|
|
234
|
+
|
|
170
235
|
/** Maximum allowed expression length to prevent DoS via complex expressions */
|
|
171
236
|
const MAX_EXPRESSION_LENGTH = 500;
|
|
172
237
|
|
|
@@ -315,22 +380,8 @@ export function processStructure(
|
|
|
315
380
|
}
|
|
316
381
|
|
|
317
382
|
if (typeof structure === 'string') {
|
|
318
|
-
// Build evaluation context
|
|
319
|
-
|
|
320
|
-
const evalContext: Record<string, unknown> = { ...getGlobalTemplateContext(), ...context.props };
|
|
321
|
-
|
|
322
|
-
// Add componentDef properties to context (for backward compatibility)
|
|
323
|
-
if (context.componentDef && typeof context.componentDef === 'object') {
|
|
324
|
-
Object.assign(evalContext, context.componentDef as Record<string, unknown>);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Add parent cms-list item context for nested template resolution
|
|
328
|
-
// This enables {{item.field}} in components rendered inside cms-list
|
|
329
|
-
const itemContext = (context as Record<string, unknown>).itemContext as Record<string, unknown> | undefined;
|
|
330
|
-
if (itemContext) {
|
|
331
|
-
Object.assign(evalContext, itemContext);
|
|
332
|
-
}
|
|
333
|
-
|
|
383
|
+
// Build evaluation context with item context for nested template resolution
|
|
384
|
+
const evalContext = buildEvalContext(context, true);
|
|
334
385
|
|
|
335
386
|
// Check if entire string is a complete template {{expr}}
|
|
336
387
|
// Use evaluateTemplate to preserve type (objects, arrays, numbers)
|
|
@@ -504,12 +555,12 @@ export function processStructure(
|
|
|
504
555
|
if (isHtmlNode(processed) || isListNode(processed)) {
|
|
505
556
|
if (typeof value === 'string') {
|
|
506
557
|
// Process template in tag (e.g., "h{{size}}" -> "h1")
|
|
507
|
-
const evalContext
|
|
508
|
-
if (context.componentDef && typeof context.componentDef === 'object') {
|
|
509
|
-
Object.assign(evalContext, context.componentDef as Record<string, unknown>);
|
|
510
|
-
}
|
|
558
|
+
const evalContext = buildEvalContext(context, false);
|
|
511
559
|
// Use processCodeTemplates to handle partial templates like "h{{size}}"
|
|
512
560
|
(processed as any).tag = processCodeTemplates(value, evalContext);
|
|
561
|
+
} else if (value === false) {
|
|
562
|
+
// Preserve boolean false for fragment mode (no container)
|
|
563
|
+
(processed as any).tag = false;
|
|
513
564
|
} else {
|
|
514
565
|
(processed as any).tag = String(value);
|
|
515
566
|
}
|
|
@@ -526,16 +577,8 @@ export function processStructure(
|
|
|
526
577
|
// Handle html property for embed nodes - process templates like {{propName}}
|
|
527
578
|
if (preservedType === NODE_TYPE.EMBED) {
|
|
528
579
|
if (typeof value === 'string') {
|
|
529
|
-
// Build evaluation context
|
|
530
|
-
const evalContext
|
|
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
|
-
}
|
|
580
|
+
// Build evaluation context with item context for nested template resolution
|
|
581
|
+
const evalContext = buildEvalContext(context, true);
|
|
539
582
|
// Check if entire string is a complete template {{expr}}
|
|
540
583
|
if (/^\{\{.+\}\}$/.test(value) && !hasItemTemplates(value)) {
|
|
541
584
|
const result = evaluateTemplate(value, evalContext);
|
|
@@ -583,78 +626,29 @@ export function processStructure(
|
|
|
583
626
|
if (processedStyle && typeof processedStyle === 'object' && !Array.isArray(processedStyle)) {
|
|
584
627
|
// Check if it's a responsive style object
|
|
585
628
|
if (isResponsiveStyle(processedStyle as StyleValue)) {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
} else if (typeof styleValue === 'string') {
|
|
598
|
-
if (hasTemplates(styleValue)) {
|
|
599
|
-
const evaluated = processCodeTemplates(styleValue, context.props);
|
|
600
|
-
resolvedResponsive[bkeyName]![styleKey] = evaluated;
|
|
601
|
-
} else {
|
|
602
|
-
resolvedResponsive[bkeyName]![styleKey] = styleValue;
|
|
603
|
-
}
|
|
604
|
-
} else if (typeof styleValue === 'number') {
|
|
605
|
-
resolvedResponsive[bkeyName]![styleKey] = styleValue;
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
resolvedStyle = resolvedResponsive;
|
|
611
|
-
} else {
|
|
612
|
-
// Preserve responsive styles for editor (same as SSR)
|
|
613
|
-
// responsiveStylesToClasses will generate prefixed classes (t-, mob-)
|
|
614
|
-
// CSS media queries will handle displaying the correct styles based on viewport
|
|
615
|
-
const resolvedResponsive: ResponsiveStyleObject = {};
|
|
616
|
-
for (const [bkeyName, bkeyValue] of Object.entries(processedStyle)) {
|
|
617
|
-
if (typeof bkeyValue === 'object' && bkeyValue !== null) {
|
|
618
|
-
resolvedResponsive[bkeyName] = {};
|
|
619
|
-
for (const [styleKey, styleValue] of Object.entries(bkeyValue)) {
|
|
620
|
-
const resolved = resolveStyleMapping(styleValue, context.props);
|
|
621
|
-
if (resolved !== undefined) {
|
|
622
|
-
resolvedResponsive[bkeyName]![styleKey] = resolved;
|
|
623
|
-
} else if (typeof styleValue === 'string') {
|
|
624
|
-
if (hasTemplates(styleValue)) {
|
|
625
|
-
const evaluated = processCodeTemplates(styleValue, context.props);
|
|
626
|
-
resolvedResponsive[bkeyName]![styleKey] = evaluated;
|
|
627
|
-
} else {
|
|
628
|
-
resolvedResponsive[bkeyName]![styleKey] = styleValue;
|
|
629
|
-
}
|
|
630
|
-
} else if (typeof styleValue === 'number') {
|
|
631
|
-
resolvedResponsive[bkeyName]![styleKey] = styleValue;
|
|
632
|
-
}
|
|
629
|
+
// Preserve responsive styles (for both SSR and editor)
|
|
630
|
+
// responsiveStylesToClasses will generate prefixed classes (t-, mob-)
|
|
631
|
+
// CSS media queries will handle displaying the correct styles based on viewport
|
|
632
|
+
const resolvedResponsive: ResponsiveStyleObject = {};
|
|
633
|
+
for (const [bkeyName, bkeyValue] of Object.entries(processedStyle)) {
|
|
634
|
+
if (typeof bkeyValue === 'object' && bkeyValue !== null) {
|
|
635
|
+
resolvedResponsive[bkeyName] = {};
|
|
636
|
+
for (const [styleKey, styleValue] of Object.entries(bkeyValue)) {
|
|
637
|
+
const processedValue = processStyleValue(styleValue, context.props);
|
|
638
|
+
if (processedValue !== undefined) {
|
|
639
|
+
resolvedResponsive[bkeyName]![styleKey] = processedValue;
|
|
633
640
|
}
|
|
634
641
|
}
|
|
635
642
|
}
|
|
636
|
-
resolvedStyle = resolvedResponsive;
|
|
637
643
|
}
|
|
644
|
+
resolvedStyle = resolvedResponsive;
|
|
638
645
|
} else {
|
|
639
646
|
// Legacy flat style object - resolve mappings and evaluate templates
|
|
640
647
|
resolvedStyle = {};
|
|
641
648
|
for (const [styleKey, styleValue] of Object.entries(processedStyle)) {
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
resolvedStyle[styleKey] = resolved;
|
|
646
|
-
} else if (typeof styleValue === 'string') {
|
|
647
|
-
// Evaluate template strings in style values (supports partial templates like "{{size}}px")
|
|
648
|
-
// Use processCodeTemplates to handle templates with text before/after
|
|
649
|
-
if (hasTemplates(styleValue)) {
|
|
650
|
-
const evaluated = processCodeTemplates(styleValue, context.props);
|
|
651
|
-
resolvedStyle[styleKey] = evaluated;
|
|
652
|
-
} else {
|
|
653
|
-
// No templates, keep original value
|
|
654
|
-
resolvedStyle[styleKey] = styleValue;
|
|
655
|
-
}
|
|
656
|
-
} else if (typeof styleValue === 'number') {
|
|
657
|
-
resolvedStyle[styleKey] = styleValue;
|
|
649
|
+
const processedValue = processStyleValue(styleValue, context.props);
|
|
650
|
+
if (processedValue !== undefined) {
|
|
651
|
+
resolvedStyle[styleKey] = processedValue;
|
|
658
652
|
}
|
|
659
653
|
}
|
|
660
654
|
}
|
|
@@ -712,10 +706,7 @@ export function processStructure(
|
|
|
712
706
|
// Special handling for attributes - process templates but don't treat as node structure
|
|
713
707
|
// This preserves type="checkbox" etc. which would otherwise be caught by node type handling
|
|
714
708
|
const processedAttributes: Record<string, unknown> = {};
|
|
715
|
-
const evalContext
|
|
716
|
-
if (context.componentDef && typeof context.componentDef === 'object') {
|
|
717
|
-
Object.assign(evalContext, context.componentDef as Record<string, unknown>);
|
|
718
|
-
}
|
|
709
|
+
const evalContext = buildEvalContext(context, false);
|
|
719
710
|
for (const [attrKey, attrValue] of Object.entries(value)) {
|
|
720
711
|
if (typeof attrValue === 'string' && hasTemplates(attrValue)) {
|
|
721
712
|
// Check if entire string is a complete template {{expr}} - preserve type (boolean, number)
|
package/lib/server/index.ts
CHANGED
|
@@ -15,9 +15,9 @@ export { generateBuildErrorPage, type BuildError, type BuildErrorsData } from '.
|
|
|
15
15
|
|
|
16
16
|
// Services
|
|
17
17
|
export { PageService } from './services/pageService';
|
|
18
|
-
export { ComponentService } from './services/componentService';
|
|
18
|
+
export { ComponentService, type ComponentInfo } from './services/componentService';
|
|
19
19
|
export { CMSService, type ReferenceLocation } from './services/cmsService';
|
|
20
|
-
export { configService, ConfigService } from './services/configService';
|
|
20
|
+
export { configService, ConfigService, type EnumsConfig } from './services/configService';
|
|
21
21
|
export { ColorService } from './services/ColorService';
|
|
22
22
|
|
|
23
23
|
// Providers
|
package/lib/server/jsonLoader.ts
CHANGED
|
@@ -118,69 +118,147 @@ async function loadFileAsText(filePath: string): Promise<string | null> {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
|
-
*
|
|
122
|
-
|
|
121
|
+
* Component with category metadata from folder structure
|
|
122
|
+
*/
|
|
123
|
+
export interface ComponentWithCategory extends ComponentDefinition {
|
|
124
|
+
_category?: string;
|
|
125
|
+
_relativePath?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Load a single component from a file path
|
|
130
|
+
* @internal
|
|
131
|
+
*/
|
|
132
|
+
async function loadSingleComponent(
|
|
133
|
+
filePath: string,
|
|
134
|
+
componentName: string,
|
|
135
|
+
category?: string
|
|
136
|
+
): Promise<ComponentWithCategory | null> {
|
|
137
|
+
const content = await loadJSONFile(filePath);
|
|
138
|
+
if (!content) return null;
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const parsed = parseJSON<ComponentDefinition>(content);
|
|
142
|
+
|
|
143
|
+
// Validate component definition (logs warnings but doesn't fail - graceful degradation)
|
|
144
|
+
const validationResult = validateComponentDefinition(parsed);
|
|
145
|
+
if (!validationResult.valid) {
|
|
146
|
+
console.warn(`[jsonLoader] Component validation failed for ${componentName}:`,
|
|
147
|
+
validationResult.errors.map(e => `${e.path}: ${e.message}`).join('; '));
|
|
148
|
+
}
|
|
149
|
+
const componentDef: ComponentWithCategory = validationResult.valid ? validationResult.data : parsed;
|
|
150
|
+
|
|
151
|
+
// Determine the directory where the component file is located
|
|
152
|
+
const dirPath = filePath.substring(0, filePath.lastIndexOf('/'));
|
|
153
|
+
|
|
154
|
+
// Check if there's a corresponding .js file
|
|
155
|
+
const jsFilePath = `${dirPath}/${componentName}.js`;
|
|
156
|
+
const jsContent = await loadFileAsText(jsFilePath);
|
|
157
|
+
|
|
158
|
+
// Check if there's a corresponding .css file
|
|
159
|
+
const cssFilePath = `${dirPath}/${componentName}.css`;
|
|
160
|
+
const cssContent = await loadFileAsText(cssFilePath);
|
|
161
|
+
|
|
162
|
+
if (!componentDef.component) {
|
|
163
|
+
componentDef.component = {};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// If .js file exists, use its content for javascript field
|
|
167
|
+
// This takes precedence over any javascript field in the JSON
|
|
168
|
+
if (jsContent) {
|
|
169
|
+
componentDef.component.javascript = jsContent;
|
|
170
|
+
|
|
171
|
+
// Auto-enable defineVars when .js file exists (unless explicitly set to false)
|
|
172
|
+
if (componentDef.component.defineVars === undefined) {
|
|
173
|
+
componentDef.component.defineVars = true;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// If .css file exists, use its content for css field
|
|
178
|
+
// This takes precedence over any css field in the JSON
|
|
179
|
+
if (cssContent) {
|
|
180
|
+
componentDef.component.css = cssContent;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Add category metadata from folder structure
|
|
184
|
+
if (category) {
|
|
185
|
+
componentDef._category = category;
|
|
186
|
+
componentDef._relativePath = `${category}/${componentName}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return componentDef;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Load all component definitions from components directory (recursive)
|
|
197
|
+
* Scans subdirectories as category folders. Component names must be unique across all folders.
|
|
198
|
+
*
|
|
199
|
+
* Folder structure:
|
|
200
|
+
* - /components/Button.json → name: "Button", category: undefined
|
|
201
|
+
* - /components/ui/Card.json → name: "Card", category: "ui"
|
|
202
|
+
*
|
|
203
|
+
* @param dirPath - Root components directory path
|
|
204
|
+
* @returns Map of component names to ComponentDefinition with category metadata
|
|
123
205
|
*/
|
|
124
206
|
export async function loadComponentDirectory(
|
|
125
207
|
dirPath: string = './components'
|
|
126
|
-
): Promise<Map<string,
|
|
127
|
-
const components = new Map<string,
|
|
208
|
+
): Promise<Map<string, ComponentWithCategory>> {
|
|
209
|
+
const components = new Map<string, ComponentWithCategory>();
|
|
128
210
|
|
|
129
211
|
if (!existsSync(dirPath)) {
|
|
130
212
|
return components;
|
|
131
213
|
}
|
|
132
214
|
|
|
133
|
-
const
|
|
215
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
134
216
|
|
|
135
|
-
for (const
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
const
|
|
217
|
+
for (const entry of entries) {
|
|
218
|
+
if (entry.isDirectory()) {
|
|
219
|
+
// This is a category folder - scan it for components
|
|
220
|
+
const category = entry.name;
|
|
221
|
+
const categoryPath = `${dirPath}/${category}`;
|
|
222
|
+
const categoryFiles = readdirSync(categoryPath);
|
|
139
223
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
// Validate component definition (logs warnings but doesn't fail - graceful degradation)
|
|
145
|
-
const validationResult = validateComponentDefinition(parsed);
|
|
146
|
-
if (!validationResult.valid) {
|
|
147
|
-
console.warn(`[jsonLoader] Component validation failed for ${componentName}:`,
|
|
148
|
-
validationResult.errors.map(e => `${e.path}: ${e.message}`).join('; '));
|
|
149
|
-
}
|
|
150
|
-
const componentDef = validationResult.valid ? validationResult.data : parsed;
|
|
151
|
-
|
|
152
|
-
// Check if there's a corresponding .js file
|
|
153
|
-
const jsFilePath = `${dirPath}/${componentName}.js`;
|
|
154
|
-
const jsContent = await loadFileAsText(jsFilePath);
|
|
224
|
+
for (const file of categoryFiles) {
|
|
225
|
+
if (file.endsWith('.json')) {
|
|
226
|
+
const componentName = file.replace('.json', '');
|
|
155
227
|
|
|
156
|
-
// Check
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (!componentDef.component) {
|
|
161
|
-
componentDef.component = {};
|
|
228
|
+
// Check for duplicate component names
|
|
229
|
+
if (components.has(componentName)) {
|
|
230
|
+
console.warn(`[jsonLoader] Duplicate component name "${componentName}" found in category "${category}". Skipping.`);
|
|
231
|
+
continue;
|
|
162
232
|
}
|
|
163
233
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
234
|
+
const componentDef = await loadSingleComponent(
|
|
235
|
+
`${categoryPath}/${file}`,
|
|
236
|
+
componentName,
|
|
237
|
+
category
|
|
238
|
+
);
|
|
168
239
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
componentDef.component.defineVars = true;
|
|
172
|
-
}
|
|
240
|
+
if (componentDef) {
|
|
241
|
+
components.set(componentName, componentDef);
|
|
173
242
|
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} else if (entry.name.endsWith('.json')) {
|
|
246
|
+
// Root-level component (uncategorized)
|
|
247
|
+
const componentName = entry.name.replace('.json', '');
|
|
248
|
+
|
|
249
|
+
// Check for duplicate component names
|
|
250
|
+
if (components.has(componentName)) {
|
|
251
|
+
console.warn(`[jsonLoader] Duplicate component name "${componentName}" at root level. Skipping.`);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
174
254
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
255
|
+
const componentDef = await loadSingleComponent(
|
|
256
|
+
`${dirPath}/${entry.name}`,
|
|
257
|
+
componentName
|
|
258
|
+
);
|
|
180
259
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
260
|
+
if (componentDef) {
|
|
261
|
+
components.set(componentName, componentDef);
|
|
184
262
|
}
|
|
185
263
|
}
|
|
186
264
|
}
|
|
@@ -9,10 +9,21 @@ import { jsonResponse } from './shared';
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Handle components API endpoint - GET /api/components
|
|
12
|
+
* Returns components with category metadata derived from folder structure
|
|
12
13
|
*/
|
|
13
14
|
export function handleComponentsRoute(componentService: ComponentService): Response {
|
|
14
|
-
const
|
|
15
|
-
|
|
15
|
+
const componentsWithCategories = componentService.getAllComponentsWithCategories();
|
|
16
|
+
|
|
17
|
+
// Transform to API format: { name: { ...definition, _category } }
|
|
18
|
+
const result: Record<string, any> = {};
|
|
19
|
+
for (const [name, info] of Object.entries(componentsWithCategories)) {
|
|
20
|
+
result[name] = {
|
|
21
|
+
...info.definition,
|
|
22
|
+
_category: info.category
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return jsonResponse(result);
|
|
16
27
|
}
|
|
17
28
|
|
|
18
29
|
/**
|