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.
@@ -7,7 +7,9 @@
7
7
  */
8
8
 
9
9
  import { join } from 'path';
10
+ import { existsSync, mkdirSync, unlinkSync, readdirSync, rmdirSync } from 'fs';
10
11
  import { loadComponentDirectory, loadJSONFile, parseJSON } from '../jsonLoader';
12
+ import type { ComponentWithCategory } from '../jsonLoader';
11
13
  import { projectPaths } from '../projectContext';
12
14
  import type { ComponentDefinition } from '../../shared/types';
13
15
 
@@ -29,8 +31,17 @@ export interface ComponentLoader {
29
31
  loadFile(path: string): Promise<string | null>;
30
32
  }
31
33
 
34
+ /**
35
+ * Component info with category metadata
36
+ */
37
+ export interface ComponentInfo {
38
+ definition: ComponentDefinition;
39
+ category?: string;
40
+ }
41
+
32
42
  export class ComponentService {
33
43
  private components = new Map<string, ComponentDefinition>();
44
+ private componentCategories = new Map<string, string | undefined>();
34
45
  private fs?: ComponentServiceFs;
35
46
  private loader?: ComponentLoader;
36
47
 
@@ -48,13 +59,14 @@ export class ComponentService {
48
59
 
49
60
  /**
50
61
  * Load all components from the components directory
51
- *
52
- * Scans the ./components directory and loads all component definitions.
53
- * Clears existing components before loading. Components are loaded from .json files,
54
- * and associated .js and .css files are automatically loaded if they exist.
55
- *
62
+ *
63
+ * Scans the ./components directory recursively and loads all component definitions.
64
+ * Subdirectories are treated as category folders. Clears existing components before loading.
65
+ * Components are loaded from .json files, and associated .js and .css files are
66
+ * automatically loaded if they exist.
67
+ *
56
68
  * @returns Promise that resolves when all components are loaded
57
- *
69
+ *
58
70
  * @example
59
71
  * ```typescript
60
72
  * await componentService.loadAllComponents();
@@ -63,11 +75,23 @@ export class ComponentService {
63
75
  */
64
76
  async loadAllComponents(): Promise<void> {
65
77
  this.components.clear();
78
+ this.componentCategories.clear();
79
+
66
80
  const loadedComponents = this.loader
67
81
  ? await this.loader.loadDirectory(projectPaths.components())
68
82
  : await loadComponentDirectory(projectPaths.components());
83
+
69
84
  loadedComponents.forEach((value, key) => {
70
- this.components.set(key, value);
85
+ // Extract category from the loaded component (set by recursive loader)
86
+ const componentWithCategory = value as ComponentWithCategory;
87
+ const category = componentWithCategory._category;
88
+
89
+ // Store category mapping
90
+ this.componentCategories.set(key, category);
91
+
92
+ // Remove internal metadata before storing
93
+ const { _category, _relativePath, ...cleanDef } = componentWithCategory;
94
+ this.components.set(key, cleanDef as ComponentDefinition);
71
95
  });
72
96
  }
73
97
 
@@ -108,12 +132,12 @@ export class ComponentService {
108
132
 
109
133
  /**
110
134
  * Get all components as a Record
111
- *
135
+ *
112
136
  * Returns all loaded components as a plain object for easy iteration
113
137
  * or serialization.
114
- *
138
+ *
115
139
  * @returns Record mapping component names to ComponentDefinition objects
116
- *
140
+ *
117
141
  * @example
118
142
  * ```typescript
119
143
  * const allComponents = componentService.getAllComponents();
@@ -130,6 +154,42 @@ export class ComponentService {
130
154
  return record;
131
155
  }
132
156
 
157
+ /**
158
+ * Get all components with their category information
159
+ *
160
+ * Returns all loaded components with category metadata derived from folder structure.
161
+ *
162
+ * @returns Record mapping component names to ComponentInfo objects (definition + category)
163
+ *
164
+ * @example
165
+ * ```typescript
166
+ * const components = componentService.getAllComponentsWithCategories();
167
+ * Object.entries(components).forEach(([name, info]) => {
168
+ * console.log(`${name}: category=${info.category || 'uncategorized'}`);
169
+ * });
170
+ * ```
171
+ */
172
+ getAllComponentsWithCategories(): Record<string, ComponentInfo> {
173
+ const record: Record<string, ComponentInfo> = {};
174
+ this.components.forEach((value, key) => {
175
+ record[key] = {
176
+ definition: value,
177
+ category: this.componentCategories.get(key)
178
+ };
179
+ });
180
+ return record;
181
+ }
182
+
183
+ /**
184
+ * Get category for a component
185
+ *
186
+ * @param name - Component name
187
+ * @returns Category string if component is in a category folder, undefined otherwise
188
+ */
189
+ getComponentCategory(name: string): string | undefined {
190
+ return this.componentCategories.get(name);
191
+ }
192
+
133
193
  /**
134
194
  * Validate that a component has a valid structure
135
195
  *
@@ -180,6 +240,16 @@ export class ComponentService {
180
240
  return true;
181
241
  }
182
242
 
243
+ /**
244
+ * Get the directory path for a component based on its category
245
+ * @internal
246
+ */
247
+ private getComponentDir(name: string, category?: string): string {
248
+ const componentsDir = projectPaths.components();
249
+ const cat = category ?? this.componentCategories.get(name);
250
+ return cat ? join(componentsDir, cat) : componentsDir;
251
+ }
252
+
183
253
  /**
184
254
  * Get component JavaScript from .js file
185
255
  *
@@ -198,7 +268,8 @@ export class ComponentService {
198
268
  * ```
199
269
  */
200
270
  async getComponentJavaScript(name: string): Promise<string | null> {
201
- const jsFilePath = join(projectPaths.components(), `${name}.js`);
271
+ const componentDir = this.getComponentDir(name);
272
+ const jsFilePath = join(componentDir, `${name}.js`);
202
273
  try {
203
274
  const file = Bun.file(jsFilePath);
204
275
  if (await file.exists()) {
@@ -212,28 +283,32 @@ export class ComponentService {
212
283
 
213
284
  /**
214
285
  * Save component definition
215
- *
286
+ *
216
287
  * Saves component definition to a .json file and updates the cache.
217
288
  * The javascript field is automatically removed from the saved data
218
289
  * (JavaScript should only be in .js files).
219
- *
290
+ *
291
+ * If category is provided, the component is saved to the category subfolder.
292
+ * If category is undefined and component exists, it stays in its current location.
293
+ * If category is empty string '', component is saved to root (uncategorized).
294
+ *
220
295
  * @param name - Component name (without .json extension)
221
296
  * @param data - ComponentDefinition object to save
297
+ * @param category - Optional category folder name (empty string for root/uncategorized)
222
298
  * @returns Promise that resolves when the component is saved
223
- *
299
+ *
224
300
  * @throws {Error} If file write fails
225
- *
301
+ *
226
302
  * @example
227
303
  * ```typescript
228
- * await componentService.saveComponent('Button', {
229
- * component: {
230
- * interface: { label: { type: 'string', default: 'Click me' } },
231
- * structure: { type: 'node', tag: 'button', children: [] }
232
- * }
233
- * });
304
+ * // Save to root (uncategorized)
305
+ * await componentService.saveComponent('Button', buttonDef);
306
+ *
307
+ * // Save to 'ui' category folder
308
+ * await componentService.saveComponent('Card', cardDef, 'ui');
234
309
  * ```
235
310
  */
236
- async saveComponent(name: string, data: ComponentDefinition): Promise<void> {
311
+ async saveComponent(name: string, data: ComponentDefinition, category?: string): Promise<void> {
237
312
  const writeFile = this.fs
238
313
  ? this.fs.writeFile.bind(this.fs)
239
314
  : (await import('fs/promises')).writeFile;
@@ -244,11 +319,26 @@ export class ComponentService {
244
319
  delete dataWithoutJS.component.javascript;
245
320
  }
246
321
 
247
- const filePath = join(projectPaths.components(), `${name}.json`);
322
+ // Determine target directory
323
+ // If category is explicitly provided (including ''), use it
324
+ // Otherwise, use existing category or root
325
+ const targetCategory = category !== undefined
326
+ ? (category || undefined) // Convert '' to undefined for root
327
+ : this.componentCategories.get(name);
328
+
329
+ const componentDir = this.getComponentDir(name, targetCategory);
330
+
331
+ // Create category directory if needed
332
+ if (targetCategory && !existsSync(componentDir)) {
333
+ mkdirSync(componentDir, { recursive: true });
334
+ }
335
+
336
+ const filePath = join(componentDir, `${name}.json`);
248
337
  await writeFile(filePath, JSON.stringify(dataWithoutJS, null, 2), 'utf-8');
249
338
 
250
339
  // Update in-memory cache
251
340
  this.components.set(name, dataWithoutJS);
341
+ this.componentCategories.set(name, targetCategory);
252
342
  }
253
343
 
254
344
  /**
@@ -272,12 +362,12 @@ export class ComponentService {
272
362
  const writeFile = this.fs
273
363
  ? this.fs.writeFile.bind(this.fs)
274
364
  : (await import('fs/promises')).writeFile;
275
- const componentsDir = projectPaths.components();
276
- const jsFilePath = join(componentsDir, `${name}.js`);
365
+ const componentDir = this.getComponentDir(name);
366
+ const jsFilePath = join(componentDir, `${name}.js`);
277
367
  await writeFile(jsFilePath, javascript || '', 'utf-8');
278
368
 
279
369
  // Reload the component to update the registry with the new JS
280
- const componentPath = join(componentsDir, `${name}.json`);
370
+ const componentPath = join(componentDir, `${name}.json`);
281
371
  const componentData = await loadJSONFile(componentPath);
282
372
  if (componentData) {
283
373
  const parsed = parseJSON<ComponentDefinition>(componentData);
@@ -318,12 +408,12 @@ export class ComponentService {
318
408
  const writeFile = this.fs
319
409
  ? this.fs.writeFile.bind(this.fs)
320
410
  : (await import('fs/promises')).writeFile;
321
- const componentsDir = projectPaths.components();
322
- const cssFilePath = join(componentsDir, `${name}.css`);
411
+ const componentDir = this.getComponentDir(name);
412
+ const cssFilePath = join(componentDir, `${name}.css`);
323
413
  await writeFile(cssFilePath, css || '', 'utf-8');
324
414
 
325
415
  // Reload the component to update the registry with the new CSS
326
- const componentPath = join(componentsDir, `${name}.json`);
416
+ const componentPath = join(componentDir, `${name}.json`);
327
417
  const componentData = await loadJSONFile(componentPath);
328
418
  if (componentData) {
329
419
  const parsed = parseJSON<ComponentDefinition>(componentData);
@@ -342,5 +432,141 @@ export class ComponentService {
342
432
  this.components.set(name, parsed);
343
433
  }
344
434
  }
435
+
436
+ /**
437
+ * Get all component folders (including empty ones)
438
+ *
439
+ * Scans the components directory for subdirectories.
440
+ * Returns all folder names, whether they contain components or not.
441
+ *
442
+ * @returns Array of folder names
443
+ *
444
+ * @example
445
+ * ```typescript
446
+ * const folders = componentService.getAllFolders();
447
+ * // ['ui', 'layout', 'content']
448
+ * ```
449
+ */
450
+ getAllFolders(): string[] {
451
+ const componentsDir = projectPaths.components();
452
+ if (!existsSync(componentsDir)) {
453
+ return [];
454
+ }
455
+
456
+ try {
457
+ const entries = readdirSync(componentsDir, { withFileTypes: true });
458
+ return entries
459
+ .filter(entry => entry.isDirectory())
460
+ .map(entry => entry.name)
461
+ .sort();
462
+ } catch {
463
+ return [];
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Create a component folder
469
+ *
470
+ * Creates an empty folder in the components directory.
471
+ * Useful for pre-creating category folders before moving components.
472
+ *
473
+ * @param folderName - Name of the folder to create
474
+ * @returns Promise that resolves when folder is created
475
+ *
476
+ * @throws {Error} If folder creation fails
477
+ *
478
+ * @example
479
+ * ```typescript
480
+ * await componentService.createFolder('ui');
481
+ * ```
482
+ */
483
+ async createFolder(folderName: string): Promise<void> {
484
+ if (!folderName || folderName.trim() === '') {
485
+ throw new Error('Folder name cannot be empty');
486
+ }
487
+
488
+ // Validate folder name (alphanumeric, dashes, underscores only)
489
+ const sanitized = folderName.trim().toLowerCase().replace(/[^a-z0-9-_]/g, '-');
490
+ if (sanitized !== folderName.trim()) {
491
+ throw new Error('Folder name can only contain lowercase letters, numbers, dashes, and underscores');
492
+ }
493
+
494
+ const componentsDir = projectPaths.components();
495
+ const folderPath = join(componentsDir, folderName);
496
+
497
+ if (existsSync(folderPath)) {
498
+ throw new Error('Folder already exists');
499
+ }
500
+
501
+ mkdirSync(folderPath, { recursive: true });
502
+ }
503
+
504
+ /**
505
+ * Move component to a different category folder
506
+ *
507
+ * Moves all component files (.json, .js, .css) from current location to new category folder.
508
+ * If newCategory is empty string or undefined, moves to root (uncategorized).
509
+ *
510
+ * @param name - Component name
511
+ * @param newCategory - Target category folder (empty string or undefined for root)
512
+ * @returns Promise that resolves when move is complete
513
+ *
514
+ * @throws {Error} If component doesn't exist or move fails
515
+ *
516
+ * @example
517
+ * ```typescript
518
+ * // Move Button from root to 'ui' category
519
+ * await componentService.moveComponent('Button', 'ui');
520
+ *
521
+ * // Move Card from 'ui' to root (uncategorized)
522
+ * await componentService.moveComponent('Card', '');
523
+ * ```
524
+ */
525
+ async moveComponent(name: string, newCategory: string | undefined): Promise<void> {
526
+ const currentCategory = this.componentCategories.get(name);
527
+ const targetCategory = newCategory || undefined;
528
+
529
+ // No-op if category unchanged
530
+ if (currentCategory === targetCategory) {
531
+ return;
532
+ }
533
+
534
+ const componentsDir = projectPaths.components();
535
+ const sourceDir = currentCategory ? join(componentsDir, currentCategory) : componentsDir;
536
+ const targetDir = targetCategory ? join(componentsDir, targetCategory) : componentsDir;
537
+
538
+ // Create target directory if needed
539
+ if (targetCategory && !existsSync(targetDir)) {
540
+ mkdirSync(targetDir, { recursive: true });
541
+ }
542
+
543
+ // Move all component files (.json, .js, .css)
544
+ const extensions = ['.json', '.js', '.css'];
545
+ const { rename } = await import('fs/promises');
546
+
547
+ for (const ext of extensions) {
548
+ const sourcePath = join(sourceDir, `${name}${ext}`);
549
+ const targetPath = join(targetDir, `${name}${ext}`);
550
+
551
+ if (existsSync(sourcePath)) {
552
+ await rename(sourcePath, targetPath);
553
+ }
554
+ }
555
+
556
+ // Update category mapping
557
+ this.componentCategories.set(name, targetCategory);
558
+
559
+ // Clean up empty source directory (only if it's a category folder, not root)
560
+ if (currentCategory) {
561
+ try {
562
+ const remaining = readdirSync(sourceDir);
563
+ if (remaining.length === 0) {
564
+ rmdirSync(sourceDir);
565
+ }
566
+ } catch {
567
+ // Ignore cleanup errors
568
+ }
569
+ }
570
+ }
345
571
  }
346
572
 
@@ -24,6 +24,12 @@ export interface IconsConfig {
24
24
  appleTouchIcon?: string;
25
25
  }
26
26
 
27
+ /**
28
+ * Project-level enum definitions
29
+ * Keys are enum names, values are arrays of options
30
+ */
31
+ export type EnumsConfig = Record<string, string[]>;
32
+
27
33
  /**
28
34
  * Raw project config structure from project.config.json
29
35
  */
@@ -34,6 +40,7 @@ interface RawProjectConfig {
34
40
  icons?: IconsConfig;
35
41
  libraries?: LibrariesConfig;
36
42
  csp?: CSPConfig;
43
+ enums?: EnumsConfig;
37
44
  }
38
45
 
39
46
  /**
@@ -241,6 +248,26 @@ export class ConfigService {
241
248
  return this.config.csp;
242
249
  }
243
250
 
251
+ /**
252
+ * Get project-level enums configuration
253
+ * Returns empty object if not configured
254
+ */
255
+ getEnums(): EnumsConfig {
256
+ if (!this.config?.enums || typeof this.config.enums !== 'object') {
257
+ return {};
258
+ }
259
+
260
+ // Validate that all values are string arrays
261
+ const result: EnumsConfig = {};
262
+ for (const [key, value] of Object.entries(this.config.enums)) {
263
+ if (Array.isArray(value) && value.every(item => typeof item === 'string')) {
264
+ result[key] = value;
265
+ }
266
+ }
267
+
268
+ return result;
269
+ }
270
+
244
271
  /**
245
272
  * Get raw config value by key (for extension)
246
273
  */
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  export { PageService } from './pageService';
7
- export { ComponentService } from './componentService';
7
+ export { ComponentService, type ComponentInfo } from './componentService';
8
8
  export { FileWatcherService } from './fileWatcherService';
9
9
  export { CMSService, type ReferenceLocation } from './cmsService';
10
10
 
@@ -114,6 +114,28 @@ function getI18nResolver(ctx: SSRContext): ValueResolver | undefined {
114
114
  return createI18nResolver(ctx.locale, ctx.i18nConfig);
115
115
  }
116
116
 
117
+ /**
118
+ * Process style templates and convert to utility classes.
119
+ * Handles {{item.field}} patterns within list contexts.
120
+ */
121
+ function processStyleToClasses(
122
+ style: StyleObject | ResponsiveStyleObject | undefined,
123
+ ctx: SSRContext
124
+ ): string[] {
125
+ if (!style) return [];
126
+
127
+ let processedStyle = style;
128
+ const templateCtx = getTemplateContext(ctx);
129
+ if (templateCtx && !ctx.templateMode) {
130
+ processedStyle = processItemPropsTemplate(
131
+ style as Record<string, unknown>,
132
+ templateCtx,
133
+ getI18nResolver(ctx)
134
+ ) as StyleObject | ResponsiveStyleObject;
135
+ }
136
+ return responsiveStylesToClasses(processedStyle as ResponsiveStyleObject);
137
+ }
138
+
117
139
  /**
118
140
  * Evaluate the if condition on a node with full SSR context.
119
141
  * Handles boolean, mapping, and string template values.
@@ -551,6 +573,14 @@ async function processList(node: ListNode, ctx: SSRContext): Promise<string> {
551
573
  }
552
574
 
553
575
  // Use configurable tag (defaults to 'div')
576
+ // If tag is explicitly false (or string "false" for backwards compatibility),
577
+ // render children without a container (fragment mode)
578
+ if (node.tag === false || node.tag === 'false') {
579
+ // Fragment mode - no container element, just children
580
+ // Note: emitTemplate requires a container, so templateHtml is not included
581
+ return childrenHTML;
582
+ }
583
+
554
584
  const tag = node.tag || 'div';
555
585
  return `<${tag}${classAttr}${listStyleAttr}${attrsStr}>${childrenHTML}${templateHtml}</${tag}>`;
556
586
  }
@@ -775,9 +805,9 @@ async function renderNode(
775
805
  // Build className array
776
806
  const classNames: string[] = ['oem'];
777
807
 
778
- // Convert styles to utility classes
808
+ // Convert styles to utility classes (process templates in style values)
779
809
  if (nodeStyle) {
780
- const utilityClasses = responsiveStylesToClasses(nodeStyle as ResponsiveStyleObject);
810
+ const utilityClasses = processStyleToClasses(nodeStyle, ctx);
781
811
  classNames.push(...utilityClasses);
782
812
  }
783
813
 
@@ -894,9 +924,9 @@ async function renderNode(
894
924
  // Build className array - start with olink base class
895
925
  const classNames: string[] = ['olink'];
896
926
 
897
- // Convert styles to utility classes
927
+ // Convert styles to utility classes (process templates in style values)
898
928
  if (nodeStyle) {
899
- const utilityClasses = responsiveStylesToClasses(nodeStyle as ResponsiveStyleObject);
929
+ const utilityClasses = processStyleToClasses(nodeStyle, ctx);
900
930
  classNames.push(...utilityClasses);
901
931
  }
902
932
 
@@ -1013,8 +1043,8 @@ async function renderNode(
1013
1043
  // Validate that all styles can generate utility classes (build-time warnings)
1014
1044
  validateStyleCoverage(nodeStyle, `Node: ${nodeType || 'unknown'}`);
1015
1045
 
1016
- // Convert style object to utility class names
1017
- utilityClasses = responsiveStylesToClasses(nodeStyle as ResponsiveStyleObject);
1046
+ // Convert style object to utility class names (process templates in style values)
1047
+ utilityClasses = processStyleToClasses(nodeStyle, ctx);
1018
1048
  } else if (nodeProps.style) {
1019
1049
  // If no node.style but props have style, keep it for backward compatibility
1020
1050
  if (isResponsiveStyle(nodeProps.style) && breakpoints && viewportWidth) {
@@ -21,6 +21,9 @@ export const API_ROUTES = {
21
21
  SAVE_COMPONENT: '/api/save-component',
22
22
  SAVE_COMPONENT_JS: '/api/save-component-js', // Save JavaScript to .js file
23
23
  SAVE_COMPONENT_CSS: '/api/save-component-css', // Save CSS to .css file
24
+ COMPONENT_CATEGORY: '/api/component-category', // Move component to category folder
25
+ COMPONENT_FOLDER: '/api/component-folder', // Create component folder
26
+ COMPONENT_FOLDERS: '/api/component-folders', // List all component folders
24
27
  COMPONENT_JS: '/api/component-js', // Get JavaScript from .js file
25
28
  CONFIG: '/api/config', // Get project config
26
29
  SAVE_CONFIG: '/api/save-config', // Save project config
@@ -162,6 +162,79 @@ describe('i18n', () => {
162
162
  const value: I18nValue = { _i18n: true };
163
163
  expect(resolveTranslation(value, 'en', testConfig)).toBe('');
164
164
  });
165
+
166
+ // List props i18n tests
167
+ test('should resolve i18n list props (arrays)', () => {
168
+ const listValue = {
169
+ _i18n: true,
170
+ en: [{ title: 'English Item' }],
171
+ pl: [{ title: 'Polish Item' }],
172
+ } as I18nValue;
173
+
174
+ expect(resolveTranslation(listValue, 'en', testConfig)).toEqual([{ title: 'English Item' }]);
175
+ expect(resolveTranslation(listValue, 'pl', testConfig)).toEqual([{ title: 'Polish Item' }]);
176
+ });
177
+
178
+ test('should fallback to default locale for i18n list props', () => {
179
+ const listValue = {
180
+ _i18n: true,
181
+ en: [{ title: 'English Item' }],
182
+ pl: [{ title: 'Polish Item' }],
183
+ } as I18nValue;
184
+
185
+ // 'de' doesn't exist, should fallback to default 'en'
186
+ expect(resolveTranslation(listValue, 'de', testConfig)).toEqual([{ title: 'English Item' }]);
187
+ });
188
+
189
+ test('should fallback to first available for i18n list props when no default', () => {
190
+ const listValue = {
191
+ _i18n: true,
192
+ pl: [{ title: 'Polish Item' }],
193
+ } as I18nValue;
194
+
195
+ // 'fr' doesn't exist and 'en' (default) doesn't exist, should fallback to first available
196
+ expect(resolveTranslation(listValue, 'fr', testConfig)).toEqual([{ title: 'Polish Item' }]);
197
+ });
198
+
199
+ test('should return empty array if no translation available for list i18n', () => {
200
+ const listValue = {
201
+ _i18n: true,
202
+ en: [], // Add empty array to make it a list i18n
203
+ } as I18nValue;
204
+ // Remove the empty array to test empty fallback detection
205
+ delete (listValue as any).en;
206
+
207
+ // For pure list i18n with no values, we need to detect array type from other values
208
+ // Since there are no values, it defaults to empty string
209
+ // Let's test with a value that was removed
210
+ const listValueWithRemoved = {
211
+ _i18n: true,
212
+ } as I18nValue;
213
+ expect(resolveTranslation(listValueWithRemoved, 'en', testConfig)).toBe('');
214
+ });
215
+
216
+ test('should handle multi-item list props per locale', () => {
217
+ const listValue = {
218
+ _i18n: true,
219
+ en: [
220
+ { title: 'First EN', desc: 'Desc 1' },
221
+ { title: 'Second EN', desc: 'Desc 2' },
222
+ ],
223
+ pl: [
224
+ { title: 'First PL', desc: 'Opis 1' },
225
+ { title: 'Second PL', desc: 'Opis 2' },
226
+ { title: 'Third PL', desc: 'Opis 3' },
227
+ ],
228
+ } as I18nValue;
229
+
230
+ const enResult = resolveTranslation(listValue, 'en', testConfig);
231
+ expect(Array.isArray(enResult)).toBe(true);
232
+ expect((enResult as any[]).length).toBe(2);
233
+
234
+ const plResult = resolveTranslation(listValue, 'pl', testConfig);
235
+ expect(Array.isArray(plResult)).toBe(true);
236
+ expect((plResult as any[]).length).toBe(3);
237
+ });
165
238
  });
166
239
 
167
240
  describe('resolveI18nValue', () => {
@@ -179,6 +252,16 @@ describe('i18n', () => {
179
252
  expect(resolveI18nValue(123, 'pl', testConfig)).toBe(123);
180
253
  expect(resolveI18nValue(null, 'pl', testConfig)).toBeNull();
181
254
  });
255
+
256
+ test('should resolve i18n list props (arrays)', () => {
257
+ const listValue = {
258
+ _i18n: true,
259
+ en: [{ title: 'English' }],
260
+ pl: [{ title: 'Polish' }],
261
+ };
262
+ expect(resolveI18nValue(listValue, 'pl', testConfig)).toEqual([{ title: 'Polish' }]);
263
+ expect(resolveI18nValue(listValue, 'en', testConfig)).toEqual([{ title: 'English' }]);
264
+ });
182
265
  });
183
266
 
184
267
  describe('resolveI18nInProps', () => {
@@ -232,6 +315,29 @@ describe('i18n', () => {
232
315
  expect(resolved.enabled).toBe(true);
233
316
  expect((resolved.data as any).value).toBe(100);
234
317
  });
318
+
319
+ test('should resolve i18n list props correctly', () => {
320
+ const props = {
321
+ items: {
322
+ _i18n: true,
323
+ en: [{ title: 'EN Item 1' }, { title: 'EN Item 2' }],
324
+ pl: [{ title: 'PL Item 1' }, { title: 'PL Item 2' }, { title: 'PL Item 3' }],
325
+ },
326
+ title: { _i18n: true, en: 'Hello', pl: 'Cześć' },
327
+ };
328
+
329
+ const resolvedPL = resolveI18nInProps(props, 'pl', testConfig);
330
+ expect(resolvedPL.title).toBe('Cześć');
331
+ expect(Array.isArray(resolvedPL.items)).toBe(true);
332
+ expect((resolvedPL.items as any[]).length).toBe(3);
333
+ expect((resolvedPL.items as any[])[0].title).toBe('PL Item 1');
334
+
335
+ const resolvedEN = resolveI18nInProps(props, 'en', testConfig);
336
+ expect(resolvedEN.title).toBe('Hello');
337
+ expect(Array.isArray(resolvedEN.items)).toBe(true);
338
+ expect((resolvedEN.items as any[]).length).toBe(2);
339
+ expect((resolvedEN.items as any[])[0].title).toBe('EN Item 1');
340
+ });
235
341
  });
236
342
 
237
343
  describe('extractLocaleFromPath', () => {