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.
@@ -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
- const utilityClasses = this.getCachedStyleClasses(nodeStyle);
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
- htmlContent = processItemTemplate(htmlContent, ctx.templateContext);
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
- const utilityClasses = responsiveStylesToClasses(node.style as StyleObject | ResponsiveStyleObject);
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
- const utilityClasses = responsiveStylesToClasses(node.style as StyleObject | ResponsiveStyleObject);
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') - declared early for all return paths
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 from TemplateContext
319
- // Start with global context, then merge props (props take precedence)
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: Record<string, unknown> = { ...getGlobalTemplateContext(), ...context.props };
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 from props (same as tag processing)
530
- const evalContext: Record<string, unknown> = { ...getGlobalTemplateContext(), ...context.props };
531
- if (context.componentDef && typeof context.componentDef === 'object') {
532
- Object.assign(evalContext, context.componentDef as Record<string, unknown>);
533
- }
534
- // Add parent cms-list item context for nested template resolution
535
- const itemContext = (context as Record<string, unknown>).itemContext as Record<string, unknown> | undefined;
536
- if (itemContext) {
537
- Object.assign(evalContext, itemContext);
538
- }
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
- if (preserveResponsiveStyles) {
587
- // Preserve responsive styles as-is (for SSR)
588
- // Just resolve templates and mappings within each breakpoint
589
- const resolvedResponsive: ResponsiveStyleObject = {};
590
- for (const [bkeyName, bkeyValue] of Object.entries(processedStyle)) {
591
- if (typeof bkeyValue === 'object' && bkeyValue !== null) {
592
- resolvedResponsive[bkeyName] = {};
593
- for (const [styleKey, styleValue] of Object.entries(bkeyValue)) {
594
- const resolved = resolveStyleMapping(styleValue, context.props);
595
- if (resolved !== undefined) {
596
- resolvedResponsive[bkeyName]![styleKey] = resolved;
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
- // First resolve style mappings
643
- const resolved = resolveStyleMapping(styleValue, context.props);
644
- if (resolved !== undefined) {
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: Record<string, unknown> = { ...getGlobalTemplateContext(), ...context.props };
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)
@@ -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
@@ -118,69 +118,147 @@ async function loadFileAsText(filePath: string): Promise<string | null> {
118
118
  }
119
119
 
120
120
  /**
121
- * Load all component definitions from components directory
122
- * Automatically loads corresponding .js files if they exist
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, ComponentDefinition>> {
127
- const components = new Map<string, ComponentDefinition>();
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 files = readdirSync(dirPath);
215
+ const entries = readdirSync(dirPath, { withFileTypes: true });
134
216
 
135
- for (const file of files) {
136
- if (file.endsWith('.json')) {
137
- const componentName = file.replace('.json', '');
138
- const content = await loadJSONFile(`${dirPath}/${file}`);
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
- if (content) {
141
- try {
142
- const parsed = parseJSON<ComponentDefinition>(content);
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 if there's a corresponding .css file
157
- const cssFilePath = `${dirPath}/${componentName}.css`;
158
- const cssContent = await loadFileAsText(cssFilePath);
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
- // If .js file exists, use its content for javascript field
165
- // This takes precedence over any javascript field in the JSON
166
- if (jsContent) {
167
- componentDef.component.javascript = jsContent;
234
+ const componentDef = await loadSingleComponent(
235
+ `${categoryPath}/${file}`,
236
+ componentName,
237
+ category
238
+ );
168
239
 
169
- // Auto-enable defineVars when .js file exists (unless explicitly set to false)
170
- if (componentDef.component.defineVars === undefined) {
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
- // If .css file exists, use its content for css field
176
- // This takes precedence over any css field in the JSON
177
- if (cssContent) {
178
- componentDef.component.css = cssContent;
179
- }
255
+ const componentDef = await loadSingleComponent(
256
+ `${dirPath}/${entry.name}`,
257
+ componentName
258
+ );
180
259
 
181
- components.set(componentName, componentDef);
182
- } catch (error) {
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 components = componentService.getAllComponents();
15
- return jsonResponse(components);
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
  /**