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
|
@@ -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
|
-
*
|
|
54
|
-
* and associated .js and .css files are
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
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
|
-
|
|
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
|
|
276
|
-
const jsFilePath = join(
|
|
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(
|
|
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
|
|
322
|
-
const cssFilePath = join(
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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) {
|
package/lib/shared/constants.ts
CHANGED
|
@@ -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
|
package/lib/shared/i18n.test.ts
CHANGED
|
@@ -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', () => {
|