meno-core 1.0.49 → 1.0.50
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/build-astro.ts +6 -2
- package/dist/build-static.js +5 -5
- package/dist/chunks/{chunk-JER5NQVM.js → chunk-56EUSC6D.js} +4 -4
- package/dist/chunks/{chunk-S2CX6HFM.js → chunk-7NIC4I3V.js} +42 -20
- package/dist/chunks/chunk-7NIC4I3V.js.map +7 -0
- package/dist/chunks/{chunk-EQYDSPBB.js → chunk-CVLFID6V.js} +64 -20
- package/dist/chunks/chunk-CVLFID6V.js.map +7 -0
- package/dist/chunks/{chunk-LKAGAQ3M.js → chunk-EDQSMAMP.js} +13 -2
- package/dist/chunks/{chunk-LKAGAQ3M.js.map → chunk-EDQSMAMP.js.map} +2 -2
- package/dist/chunks/{chunk-6IVUG7FY.js → chunk-LPVETICS.js} +19 -2
- package/dist/chunks/{chunk-6IVUG7FY.js.map → chunk-LPVETICS.js.map} +2 -2
- package/dist/chunks/{chunk-KPU2XHOS.js → chunk-PQ2HRXDR.js} +1 -1
- package/dist/chunks/chunk-PQ2HRXDR.js.map +7 -0
- package/dist/chunks/{chunk-CHD5UCFF.js → chunk-YWJJD5D6.js} +116 -32
- package/dist/chunks/chunk-YWJJD5D6.js.map +7 -0
- package/dist/chunks/{configService-CCA6AIDI.js → configService-VOY2MY2K.js} +2 -2
- package/dist/entries/server-router.js +5 -5
- package/dist/lib/client/index.js +41 -15
- package/dist/lib/client/index.js.map +3 -3
- package/dist/lib/server/index.js +11 -9
- package/dist/lib/server/index.js.map +2 -2
- package/dist/lib/shared/index.js +2 -2
- package/lib/client/core/ComponentBuilder.test.ts +34 -0
- package/lib/client/core/ComponentBuilder.ts +25 -3
- package/lib/client/core/builders/embedBuilder.ts +13 -5
- package/lib/client/core/builders/linkNodeBuilder.ts +13 -5
- package/lib/client/core/builders/localeListBuilder.ts +13 -5
- package/lib/client/templateEngine.ts +24 -0
- package/lib/server/fileWatcher.test.ts +134 -0
- package/lib/server/fileWatcher.ts +100 -32
- package/lib/server/jsonLoader.ts +1 -0
- package/lib/server/providers/fileSystemCMSProvider.ts +46 -14
- package/lib/server/services/configService.ts +1 -0
- package/lib/server/services/fileWatcherService.ts +17 -0
- package/lib/server/ssr/htmlGenerator.ts +11 -3
- package/lib/server/ssr/ssrRenderer.test.ts +258 -0
- package/lib/server/ssr/ssrRenderer.ts +46 -5
- package/lib/server/webflow/buildWebflow.ts +1 -1
- package/lib/server/websocketManager.test.ts +61 -6
- package/lib/server/websocketManager.ts +25 -1
- package/lib/shared/cssProperties.test.ts +28 -0
- package/lib/shared/cssProperties.ts +27 -1
- package/lib/shared/types/api.ts +10 -1
- package/lib/shared/types/cms.ts +18 -9
- package/lib/shared/validation/schemas.test.ts +93 -0
- package/lib/shared/validation/schemas.ts +56 -15
- package/package.json +1 -1
- package/dist/chunks/chunk-CHD5UCFF.js.map +0 -7
- package/dist/chunks/chunk-EQYDSPBB.js.map +0 -7
- package/dist/chunks/chunk-KPU2XHOS.js.map +0 -7
- package/dist/chunks/chunk-S2CX6HFM.js.map +0 -7
- /package/dist/chunks/{chunk-JER5NQVM.js.map → chunk-56EUSC6D.js.map} +0 -0
- /package/dist/chunks/{configService-CCA6AIDI.js.map → configService-VOY2MY2K.js.map} +0 -0
|
@@ -30,12 +30,16 @@ async function loadJSONFile(filePath: string): Promise<unknown | null> {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Normalize a raw CMS item by adding _slug and
|
|
33
|
+
* Normalize a raw CMS item by adding _slug, _filename, and _id (backfilled
|
|
34
|
+
* from filename for items written without one — the editor stopped writing
|
|
35
|
+
* a separate random _id once _filename became the canonical identifier).
|
|
34
36
|
* If `isDraft` is true, also sets the transient `_isDraft` flag.
|
|
35
37
|
*/
|
|
36
38
|
function normalizeItem(content: unknown, filename: string, isDraft = false): CMSItem {
|
|
39
|
+
const raw = content as CMSItem;
|
|
37
40
|
const base: CMSItem = {
|
|
38
|
-
...
|
|
41
|
+
...raw,
|
|
42
|
+
_id: raw._id || filename,
|
|
39
43
|
_slug: filename,
|
|
40
44
|
_filename: filename,
|
|
41
45
|
};
|
|
@@ -45,10 +49,18 @@ function normalizeItem(content: unknown, filename: string, isDraft = false): CMS
|
|
|
45
49
|
|
|
46
50
|
/**
|
|
47
51
|
* Strip transient fields that must never be persisted to disk.
|
|
52
|
+
* Also drops `_filename` when it equals `_id` (the canonical case for new
|
|
53
|
+
* items) so the field stops accumulating in storage. Legacy items where
|
|
54
|
+
* `_filename` differs from `_id` keep the field on disk so the on-disk
|
|
55
|
+
* file-routing path can still find them.
|
|
48
56
|
*/
|
|
49
57
|
function stripTransient<T extends Record<string, unknown>>(item: T): Omit<T, '_slug' | '_isDraft' | '_hasDraft' | '_url'> {
|
|
50
|
-
const { _slug, _isDraft, _hasDraft, _url, ...rest } = item;
|
|
51
|
-
|
|
58
|
+
const { _slug, _isDraft, _hasDraft, _url, _filename, ...rest } = item;
|
|
59
|
+
const out = rest as Omit<T, '_slug' | '_isDraft' | '_hasDraft' | '_url'>;
|
|
60
|
+
if (typeof _filename === 'string' && _filename !== (out as Record<string, unknown>)._id) {
|
|
61
|
+
(out as Record<string, unknown>)._filename = _filename;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
52
64
|
}
|
|
53
65
|
|
|
54
66
|
/**
|
|
@@ -217,8 +229,10 @@ export class FileSystemCMSProvider implements CMSProvider {
|
|
|
217
229
|
}
|
|
218
230
|
|
|
219
231
|
/**
|
|
220
|
-
* Save item to file system
|
|
221
|
-
*
|
|
232
|
+
* Save item to file system.
|
|
233
|
+
* The on-disk filename is derived from `_filename` (legacy alias) when set,
|
|
234
|
+
* otherwise from `_id` (canonical identifier; equals the filename for new
|
|
235
|
+
* items). Falls back to the slugField value as a last resort.
|
|
222
236
|
*/
|
|
223
237
|
async saveItem(collection: string, item: CMSItem): Promise<void> {
|
|
224
238
|
this.validateCollection(collection);
|
|
@@ -232,21 +246,31 @@ export class FileSystemCMSProvider implements CMSProvider {
|
|
|
232
246
|
throw new Error(`Unknown collection: ${collection}`);
|
|
233
247
|
}
|
|
234
248
|
|
|
235
|
-
//
|
|
236
|
-
//
|
|
249
|
+
// Filename derivation precedence:
|
|
250
|
+
// 1. `_filename` — explicit on-disk filename. Used by the editor (preserves
|
|
251
|
+
// legacy items where `_filename` differs from `_id`).
|
|
252
|
+
// 2. slug-field value — back-compat for external API callers who build
|
|
253
|
+
// items from raw input with a slug field.
|
|
254
|
+
// 3. `_id` — last resort. For new items written by the editor `_id`
|
|
255
|
+
// equals the filename, so this path also produces the correct name.
|
|
237
256
|
let filename: string;
|
|
238
257
|
|
|
239
258
|
if (item._filename) {
|
|
240
259
|
filename = item._filename;
|
|
241
260
|
} else {
|
|
242
|
-
// Legacy fallback: use slug field value (may break for i18n slugs)
|
|
243
261
|
const slugField = schemaInfo.schema.slugField;
|
|
244
262
|
const slugValue = item[slugField];
|
|
245
|
-
|
|
263
|
+
if (typeof slugValue === 'string' && slugValue) {
|
|
264
|
+
filename = slugValue;
|
|
265
|
+
} else if (typeof item._id === 'string' && item._id) {
|
|
266
|
+
filename = item._id;
|
|
267
|
+
} else {
|
|
268
|
+
filename = String(slugValue);
|
|
269
|
+
}
|
|
246
270
|
}
|
|
247
271
|
|
|
248
272
|
if (!filename || filename === '[object Object]') {
|
|
249
|
-
throw new Error('
|
|
273
|
+
throw new Error('Cannot derive filename: item is missing _id, _filename, and a usable slug-field value.');
|
|
250
274
|
}
|
|
251
275
|
|
|
252
276
|
// Validate filename to prevent path traversal
|
|
@@ -345,7 +369,8 @@ export class FileSystemCMSProvider implements CMSProvider {
|
|
|
345
369
|
/**
|
|
346
370
|
* Save the draft version of an item. Loose validation — drafts may have
|
|
347
371
|
* missing required fields or partial data. Strict validation only runs at
|
|
348
|
-
* publish time. The
|
|
372
|
+
* publish time. The on-disk filename is derived the same way as for
|
|
373
|
+
* `saveItem` (prefer `_filename`, fall back to `_id`).
|
|
349
374
|
*/
|
|
350
375
|
async saveDraft(collection: string, item: CMSItem): Promise<void> {
|
|
351
376
|
this.validateCollection(collection);
|
|
@@ -357,17 +382,24 @@ export class FileSystemCMSProvider implements CMSProvider {
|
|
|
357
382
|
throw new Error(`Unknown collection: ${collection}`);
|
|
358
383
|
}
|
|
359
384
|
|
|
385
|
+
// Filename derivation: same precedence as saveItem.
|
|
360
386
|
let filename: string;
|
|
361
387
|
if (item._filename) {
|
|
362
388
|
filename = item._filename;
|
|
363
389
|
} else {
|
|
364
390
|
const slugField = schemaInfo.schema.slugField;
|
|
365
391
|
const slugValue = item[slugField];
|
|
366
|
-
|
|
392
|
+
if (typeof slugValue === 'string' && slugValue) {
|
|
393
|
+
filename = slugValue;
|
|
394
|
+
} else if (typeof item._id === 'string' && item._id) {
|
|
395
|
+
filename = item._id;
|
|
396
|
+
} else {
|
|
397
|
+
filename = String(slugValue);
|
|
398
|
+
}
|
|
367
399
|
}
|
|
368
400
|
|
|
369
401
|
if (!filename || filename === '[object Object]') {
|
|
370
|
-
throw new Error('
|
|
402
|
+
throw new Error('Cannot derive draft filename: item is missing _id, _filename, and a usable slug-field value.');
|
|
371
403
|
}
|
|
372
404
|
|
|
373
405
|
this.validateFilename(filename);
|
|
@@ -14,6 +14,7 @@ import { PageCache } from '../pageCache';
|
|
|
14
14
|
import { colorService } from './ColorService';
|
|
15
15
|
import { variableService } from './VariableService';
|
|
16
16
|
import { enumService } from './EnumService';
|
|
17
|
+
import { configService } from './configService';
|
|
17
18
|
import { loadJSONFile, mapPageNameToPath } from '../jsonLoader';
|
|
18
19
|
import { buildLineMap } from '../utils/jsonLineMapper';
|
|
19
20
|
import { projectPaths } from '../projectContext';
|
|
@@ -71,9 +72,14 @@ export class FileWatcherService {
|
|
|
71
72
|
clearTimeout(this.refreshSchemasTimer);
|
|
72
73
|
}
|
|
73
74
|
const cmsService = this.cmsService;
|
|
75
|
+
const wsManager = this.wsManager;
|
|
74
76
|
this.refreshSchemasTimer = setTimeout(async () => {
|
|
75
77
|
this.refreshSchemasTimer = null;
|
|
76
78
|
await cmsService.refreshSchemas();
|
|
79
|
+
// Tell connected studio clients to re-fetch the collections list.
|
|
80
|
+
// Without this, the CMS panel only refreshes after a dev-server
|
|
81
|
+
// restart even though the server cache is now correct.
|
|
82
|
+
wsManager.broadcastCollectionsUpdate();
|
|
77
83
|
}, 50);
|
|
78
84
|
}
|
|
79
85
|
},
|
|
@@ -96,6 +102,9 @@ export class FileWatcherService {
|
|
|
96
102
|
this.wsManager.broadcastEnumsUpdate();
|
|
97
103
|
},
|
|
98
104
|
onCMSChange: async (collection: string) => {
|
|
105
|
+
// Drop the items cache for this collection so the next read returns
|
|
106
|
+
// fresh data immediately, not the previous TTL-cached snapshot.
|
|
107
|
+
this.cmsService?.clearItemsCache(collection);
|
|
99
108
|
// Broadcast to all clients to refresh CMS content
|
|
100
109
|
this.wsManager.broadcastCMSUpdate(collection);
|
|
101
110
|
},
|
|
@@ -103,6 +112,14 @@ export class FileWatcherService {
|
|
|
103
112
|
onLibraryChange: async () => {
|
|
104
113
|
this.wsManager.broadcastLibrariesUpdate();
|
|
105
114
|
},
|
|
115
|
+
onProjectConfigChange: async () => {
|
|
116
|
+
// Reset the singleton config cache so the next read pulls the new
|
|
117
|
+
// i18n locales / breakpoints / icons from disk. Without this, the
|
|
118
|
+
// config API would keep serving the stale snapshot.
|
|
119
|
+
configService.reset();
|
|
120
|
+
await configService.load();
|
|
121
|
+
this.wsManager.broadcastConfigUpdate();
|
|
122
|
+
},
|
|
106
123
|
});
|
|
107
124
|
|
|
108
125
|
this.fileWatcher.watchAll();
|
|
@@ -490,14 +490,22 @@ picture {
|
|
|
490
490
|
? generateAllInlineDataScripts(finalClientDataCollections) + '\n '
|
|
491
491
|
: '';
|
|
492
492
|
|
|
493
|
-
// Generate favicon and apple touch icon link tags
|
|
493
|
+
// Generate favicon and apple touch icon link tags.
|
|
494
|
+
// When a dark-mode favicon is configured alongside the default, scope the
|
|
495
|
+
// default to `(prefers-color-scheme: light)` and emit a second tag scoped to
|
|
496
|
+
// `(prefers-color-scheme: dark)` so the browser swaps automatically.
|
|
497
|
+
// Note: apple-touch-icon does not honor prefers-color-scheme on iOS.
|
|
498
|
+
const hasDarkFavicon = !!(iconsConfig.favicon && iconsConfig.faviconDark);
|
|
494
499
|
const faviconTag = iconsConfig.favicon
|
|
495
|
-
? `<link rel="icon" href="${escapeHtml(iconsConfig.favicon)}" />`
|
|
500
|
+
? `<link rel="icon" href="${escapeHtml(iconsConfig.favicon)}"${hasDarkFavicon ? ' media="(prefers-color-scheme: light)"' : ''} />`
|
|
501
|
+
: '';
|
|
502
|
+
const faviconDarkTag = iconsConfig.faviconDark
|
|
503
|
+
? `<link rel="icon" href="${escapeHtml(iconsConfig.faviconDark)}" media="(prefers-color-scheme: dark)" />`
|
|
496
504
|
: '';
|
|
497
505
|
const appleTouchIconTag = iconsConfig.appleTouchIcon
|
|
498
506
|
? `<link rel="apple-touch-icon" href="${escapeHtml(iconsConfig.appleTouchIcon)}" />`
|
|
499
507
|
: '';
|
|
500
|
-
const iconTags = [faviconTag, appleTouchIconTag].filter(Boolean).join('\n ');
|
|
508
|
+
const iconTags = [faviconTag, faviconDarkTag, appleTouchIconTag].filter(Boolean).join('\n ');
|
|
501
509
|
|
|
502
510
|
// Script preload tag - eliminates critical request chain by discovering script early
|
|
503
511
|
const scriptPreloadTag = extScriptPath
|
|
@@ -441,6 +441,82 @@ describe('ssrRenderer', () => {
|
|
|
441
441
|
});
|
|
442
442
|
});
|
|
443
443
|
|
|
444
|
+
// -----------------------------------------------------------------------
|
|
445
|
+
// 6b. _i18n value resolution on node children + attributes
|
|
446
|
+
// -----------------------------------------------------------------------
|
|
447
|
+
describe('buildComponentHTML - _i18n on children and attributes', () => {
|
|
448
|
+
const en = { _i18n: true, en: 'Hello', pl: 'Cześć' };
|
|
449
|
+
const i18nConfig = {
|
|
450
|
+
defaultLocale: 'en',
|
|
451
|
+
locales: [
|
|
452
|
+
{ code: 'en', name: 'EN', nativeName: 'English', langTag: 'en-US' },
|
|
453
|
+
{ code: 'pl', name: 'PL', nativeName: 'Polski', langTag: 'pl-PL' },
|
|
454
|
+
],
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
test('resolves _i18n object as direct children for the default locale', async () => {
|
|
458
|
+
const node = { type: 'node', tag: 'h1', children: en };
|
|
459
|
+
const html = await render(node, { i18nConfig });
|
|
460
|
+
expect(html).toContain('Hello');
|
|
461
|
+
expect(html).not.toContain('Cześć');
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test('resolves _i18n object as direct children for an explicit locale', async () => {
|
|
465
|
+
const node = { type: 'node', tag: 'h1', children: en };
|
|
466
|
+
const html = await render(node, { locale: 'pl', i18nConfig });
|
|
467
|
+
expect(html).toContain('Cześć');
|
|
468
|
+
expect(html).not.toContain('Hello');
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test('resolves _i18n objects inside an array of children, preserving siblings', async () => {
|
|
472
|
+
const node = {
|
|
473
|
+
type: 'node',
|
|
474
|
+
tag: 'p',
|
|
475
|
+
children: [
|
|
476
|
+
en,
|
|
477
|
+
' literal ',
|
|
478
|
+
{ type: 'node', tag: 'span', children: 'static' },
|
|
479
|
+
],
|
|
480
|
+
};
|
|
481
|
+
const html = await render(node, { i18nConfig });
|
|
482
|
+
expect(html).toContain('Hello');
|
|
483
|
+
expect(html).toContain(' literal ');
|
|
484
|
+
expect(html).toContain('<span');
|
|
485
|
+
expect(html).toContain('static');
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('resolves _i18n object on attribute values', async () => {
|
|
489
|
+
const node = {
|
|
490
|
+
type: 'node',
|
|
491
|
+
tag: 'img',
|
|
492
|
+
attributes: {
|
|
493
|
+
src: '/x.png',
|
|
494
|
+
alt: { _i18n: true, en: 'Photo', pl: 'Zdjęcie' },
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
const html = await render(node, { locale: 'pl', i18nConfig });
|
|
498
|
+
expect(html).toContain('alt="Zdjęcie"');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test('falls back to defaultLocale when the active locale key is missing', async () => {
|
|
502
|
+
const valueWithoutDe = { _i18n: true, en: 'Hello', pl: 'Cześć' };
|
|
503
|
+
const node = { type: 'node', tag: 'h1', children: valueWithoutDe };
|
|
504
|
+
const html = await render(node, { locale: 'de', i18nConfig });
|
|
505
|
+
// resolveTranslation falls back to defaultLocale (en)
|
|
506
|
+
expect(html).toContain('Hello');
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test('precedence: _i18n: true wins over a stray type/tag on the same object', async () => {
|
|
510
|
+
// If someone accidentally writes both _i18n and type on the same object,
|
|
511
|
+
// i18n wins — the renderer treats it as a localized string, not a node.
|
|
512
|
+
const ambiguous = { _i18n: true, type: 'node', tag: 'div', en: 'X', pl: 'Y' };
|
|
513
|
+
const node = { type: 'node', tag: 'h1', children: ambiguous };
|
|
514
|
+
const html = await render(node, { locale: 'pl', i18nConfig });
|
|
515
|
+
expect(html).toContain('Y');
|
|
516
|
+
expect(html).not.toContain('<div');
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
444
520
|
// -----------------------------------------------------------------------
|
|
445
521
|
// 7. Link nodes
|
|
446
522
|
// -----------------------------------------------------------------------
|
|
@@ -4060,6 +4136,188 @@ describe('ssrRenderer', () => {
|
|
|
4060
4136
|
});
|
|
4061
4137
|
});
|
|
4062
4138
|
|
|
4139
|
+
// ---------------------------------------------------------------------------
|
|
4140
|
+
// List with sourceType: 'prop' placed inside another component's slot
|
|
4141
|
+
// ---------------------------------------------------------------------------
|
|
4142
|
+
describe('buildComponentHTML - prop-source list inside slotted component', () => {
|
|
4143
|
+
test('list with bare prop-name source resolves against host props when inside a slot', async () => {
|
|
4144
|
+
// Section: a simple slot host (mirrors the user's Section component)
|
|
4145
|
+
const Section: ComponentDefinition = {
|
|
4146
|
+
component: {
|
|
4147
|
+
interface: {
|
|
4148
|
+
theme: { type: 'string', default: 'blue' },
|
|
4149
|
+
},
|
|
4150
|
+
structure: {
|
|
4151
|
+
type: 'node',
|
|
4152
|
+
tag: 'section',
|
|
4153
|
+
children: [
|
|
4154
|
+
{ type: 'slot' },
|
|
4155
|
+
],
|
|
4156
|
+
},
|
|
4157
|
+
},
|
|
4158
|
+
};
|
|
4159
|
+
|
|
4160
|
+
// Host: defines `testimonials`, renders a Section whose slot contains
|
|
4161
|
+
// a list bound to the host's `testimonials` prop with a bare name.
|
|
4162
|
+
const Host: ComponentDefinition = {
|
|
4163
|
+
component: {
|
|
4164
|
+
interface: {
|
|
4165
|
+
testimonials: { type: 'list' as any, default: [] },
|
|
4166
|
+
},
|
|
4167
|
+
structure: {
|
|
4168
|
+
type: 'node',
|
|
4169
|
+
tag: 'div',
|
|
4170
|
+
children: [
|
|
4171
|
+
{
|
|
4172
|
+
type: 'component',
|
|
4173
|
+
component: 'Section',
|
|
4174
|
+
props: { theme: 'blue' },
|
|
4175
|
+
children: [
|
|
4176
|
+
{
|
|
4177
|
+
type: 'list',
|
|
4178
|
+
sourceType: 'prop',
|
|
4179
|
+
source: 'testimonials',
|
|
4180
|
+
children: [
|
|
4181
|
+
{ type: 'node', tag: 'p', children: '{{item.name}}' },
|
|
4182
|
+
],
|
|
4183
|
+
},
|
|
4184
|
+
],
|
|
4185
|
+
},
|
|
4186
|
+
],
|
|
4187
|
+
},
|
|
4188
|
+
},
|
|
4189
|
+
};
|
|
4190
|
+
|
|
4191
|
+
const node = {
|
|
4192
|
+
type: 'component',
|
|
4193
|
+
component: 'Host',
|
|
4194
|
+
props: {
|
|
4195
|
+
testimonials: [
|
|
4196
|
+
{ name: 'Alice' },
|
|
4197
|
+
{ name: 'Bob' },
|
|
4198
|
+
{ name: 'Carol' },
|
|
4199
|
+
],
|
|
4200
|
+
},
|
|
4201
|
+
};
|
|
4202
|
+
|
|
4203
|
+
const html = await render(node, { globalComponents: { Host, Section } });
|
|
4204
|
+
expect(html).toContain('<section');
|
|
4205
|
+
expect(html).toContain('Alice');
|
|
4206
|
+
expect(html).toContain('Bob');
|
|
4207
|
+
expect(html).toContain('Carol');
|
|
4208
|
+
});
|
|
4209
|
+
|
|
4210
|
+
test('list with {{template}} source also resolves inside a slot', async () => {
|
|
4211
|
+
const Section: ComponentDefinition = {
|
|
4212
|
+
component: {
|
|
4213
|
+
interface: {},
|
|
4214
|
+
structure: {
|
|
4215
|
+
type: 'node',
|
|
4216
|
+
tag: 'section',
|
|
4217
|
+
children: [{ type: 'slot' }],
|
|
4218
|
+
},
|
|
4219
|
+
},
|
|
4220
|
+
};
|
|
4221
|
+
|
|
4222
|
+
const Host: ComponentDefinition = {
|
|
4223
|
+
component: {
|
|
4224
|
+
interface: {
|
|
4225
|
+
testimonials: { type: 'list' as any, default: [] },
|
|
4226
|
+
},
|
|
4227
|
+
structure: {
|
|
4228
|
+
type: 'component',
|
|
4229
|
+
component: 'Section',
|
|
4230
|
+
children: [
|
|
4231
|
+
{
|
|
4232
|
+
type: 'list',
|
|
4233
|
+
sourceType: 'prop',
|
|
4234
|
+
source: '{{testimonials}}',
|
|
4235
|
+
children: [
|
|
4236
|
+
{ type: 'node', tag: 'p', children: '{{item.name}}' },
|
|
4237
|
+
],
|
|
4238
|
+
},
|
|
4239
|
+
],
|
|
4240
|
+
},
|
|
4241
|
+
},
|
|
4242
|
+
};
|
|
4243
|
+
|
|
4244
|
+
const node = {
|
|
4245
|
+
type: 'component',
|
|
4246
|
+
component: 'Host',
|
|
4247
|
+
props: { testimonials: [{ name: 'Dana' }] },
|
|
4248
|
+
};
|
|
4249
|
+
|
|
4250
|
+
const html = await render(node, { globalComponents: { Host, Section } });
|
|
4251
|
+
expect(html).toContain('Dana');
|
|
4252
|
+
});
|
|
4253
|
+
|
|
4254
|
+
test('bare prop-name source still works when list is in component own structure (no slot)', async () => {
|
|
4255
|
+
const Direct: ComponentDefinition = {
|
|
4256
|
+
component: {
|
|
4257
|
+
interface: {
|
|
4258
|
+
items: { type: 'list' as any, default: [] },
|
|
4259
|
+
},
|
|
4260
|
+
structure: {
|
|
4261
|
+
type: 'list',
|
|
4262
|
+
sourceType: 'prop',
|
|
4263
|
+
source: 'items',
|
|
4264
|
+
children: [
|
|
4265
|
+
{ type: 'node', tag: 'li', children: '{{item.label}}' },
|
|
4266
|
+
],
|
|
4267
|
+
},
|
|
4268
|
+
},
|
|
4269
|
+
};
|
|
4270
|
+
|
|
4271
|
+
const node = {
|
|
4272
|
+
type: 'component',
|
|
4273
|
+
component: 'Direct',
|
|
4274
|
+
props: { items: [{ label: 'Eve' }, { label: 'Frank' }] },
|
|
4275
|
+
};
|
|
4276
|
+
|
|
4277
|
+
const html = await render(node, { globalComponents: { Direct } });
|
|
4278
|
+
expect(html).toContain('Eve');
|
|
4279
|
+
expect(html).toContain('Frank');
|
|
4280
|
+
});
|
|
4281
|
+
|
|
4282
|
+
test('list source stays a string and renders empty when host has no matching prop', async () => {
|
|
4283
|
+
const Section: ComponentDefinition = {
|
|
4284
|
+
component: {
|
|
4285
|
+
interface: {},
|
|
4286
|
+
structure: {
|
|
4287
|
+
type: 'node',
|
|
4288
|
+
tag: 'section',
|
|
4289
|
+
children: [{ type: 'slot' }],
|
|
4290
|
+
},
|
|
4291
|
+
},
|
|
4292
|
+
};
|
|
4293
|
+
|
|
4294
|
+
const Host: ComponentDefinition = {
|
|
4295
|
+
component: {
|
|
4296
|
+
interface: {},
|
|
4297
|
+
structure: {
|
|
4298
|
+
type: 'component',
|
|
4299
|
+
component: 'Section',
|
|
4300
|
+
children: [
|
|
4301
|
+
{
|
|
4302
|
+
type: 'list',
|
|
4303
|
+
sourceType: 'prop',
|
|
4304
|
+
source: 'missing',
|
|
4305
|
+
children: [
|
|
4306
|
+
{ type: 'node', tag: 'p', children: '{{item}}' },
|
|
4307
|
+
],
|
|
4308
|
+
},
|
|
4309
|
+
],
|
|
4310
|
+
},
|
|
4311
|
+
},
|
|
4312
|
+
};
|
|
4313
|
+
|
|
4314
|
+
const node = { type: 'component', component: 'Host' };
|
|
4315
|
+
const html = await render(node, { globalComponents: { Host, Section } });
|
|
4316
|
+
// No items - list renders empty
|
|
4317
|
+
expect(html).not.toContain('<p');
|
|
4318
|
+
});
|
|
4319
|
+
});
|
|
4320
|
+
|
|
4063
4321
|
describe('CMS link localization', () => {
|
|
4064
4322
|
const i18nConfig = {
|
|
4065
4323
|
defaultLocale: 'en',
|
|
@@ -72,6 +72,30 @@ export interface PreloadImage {
|
|
|
72
72
|
|
|
73
73
|
// Re-export types for external consumers
|
|
74
74
|
export type { CMSContext } from './cmsSSRProcessor';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve any `_i18n` value objects inside an attributes record into the
|
|
78
|
+
* active locale's string. Authors can write `attributes: { alt: { _i18n:
|
|
79
|
+
* true, en: "Photo", pl: "Zdjęcie" } }` and the runtime substitutes a single
|
|
80
|
+
* locale-appropriate string here, before `buildAttributes` (which silently
|
|
81
|
+
* drops object-typed values to avoid `[object Object]` in HTML).
|
|
82
|
+
*/
|
|
83
|
+
function resolveI18nAttrs<T extends Record<string, unknown>>(
|
|
84
|
+
attrs: T,
|
|
85
|
+
locale: string | undefined,
|
|
86
|
+
i18nConfig: I18nConfig | undefined
|
|
87
|
+
): T {
|
|
88
|
+
let mutated: Record<string, unknown> | null = null;
|
|
89
|
+
const config = i18nConfig ?? DEFAULT_I18N_CONFIG;
|
|
90
|
+
const effectiveLocale = locale || config.defaultLocale;
|
|
91
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
92
|
+
if (isI18nValue(value)) {
|
|
93
|
+
mutated = mutated ?? { ...attrs };
|
|
94
|
+
mutated[key] = resolveI18nValue(value, effectiveLocale, config);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return (mutated ?? attrs) as T;
|
|
98
|
+
}
|
|
75
99
|
export type { PageMeta } from './metaTagGenerator';
|
|
76
100
|
export { extractPageMeta, generateMetaTags } from './metaTagGenerator';
|
|
77
101
|
|
|
@@ -968,6 +992,21 @@ async function renderNode(
|
|
|
968
992
|
|
|
969
993
|
if (typeof node !== 'object') return '';
|
|
970
994
|
|
|
995
|
+
// Resolve `_i18n` value objects to a single string before node-shape
|
|
996
|
+
// dispatch. Authors can write a localized string anywhere `children` is
|
|
997
|
+
// accepted — on `type: "node"` elements, list templates, link children, etc.
|
|
998
|
+
// — and the runtime resolves it to the active locale here. Precedence rule:
|
|
999
|
+
// an object with both `_i18n: true` and `type`/`tag` resolves as i18n.
|
|
1000
|
+
if (isI18nValue(node)) {
|
|
1001
|
+
const i18nResolveConfig = i18nConfig ?? DEFAULT_I18N_CONFIG;
|
|
1002
|
+
const i18nEffectiveLocale = locale || i18nResolveConfig.defaultLocale;
|
|
1003
|
+
const resolved = resolveI18nValue(node, i18nEffectiveLocale, i18nResolveConfig);
|
|
1004
|
+
return renderNode(
|
|
1005
|
+
resolved as ComponentNode | string | number | null | undefined,
|
|
1006
|
+
ctx
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
971
1010
|
// Check if condition - skip rendering if false
|
|
972
1011
|
if (!evaluateIfCondition(node as ComponentNode, ctx)) {
|
|
973
1012
|
return '';
|
|
@@ -1027,7 +1066,7 @@ async function renderNode(
|
|
|
1027
1066
|
: sanitizedHtml;
|
|
1028
1067
|
|
|
1029
1068
|
// Extract attributes from node
|
|
1030
|
-
const nodeAttributes = extractAttributesFromNode(node);
|
|
1069
|
+
const nodeAttributes = resolveI18nAttrs(extractAttributesFromNode(node), locale, i18nConfig);
|
|
1031
1070
|
|
|
1032
1071
|
// Build className array
|
|
1033
1072
|
const classNames: string[] = ['oem'];
|
|
@@ -1120,7 +1159,7 @@ async function renderNode(
|
|
|
1120
1159
|
href = localizeHref(href, ctx);
|
|
1121
1160
|
|
|
1122
1161
|
// Extract attributes from node
|
|
1123
|
-
const nodeAttributes = extractAttributesFromNode(node);
|
|
1162
|
+
const nodeAttributes = resolveI18nAttrs(extractAttributesFromNode(node), locale, i18nConfig);
|
|
1124
1163
|
|
|
1125
1164
|
// Build className array - start with olink base class
|
|
1126
1165
|
const classNames: string[] = ['olink'];
|
|
@@ -1201,8 +1240,10 @@ async function renderNode(
|
|
|
1201
1240
|
nodeProps = processItemPropsTemplate(nodeProps, templateCtx, i18nResolver);
|
|
1202
1241
|
}
|
|
1203
1242
|
|
|
1204
|
-
// Extract attributes from node
|
|
1205
|
-
|
|
1243
|
+
// Extract attributes from node. Resolve any `_i18n` value objects on
|
|
1244
|
+
// attribute values to the active locale's string before downstream
|
|
1245
|
+
// processing (templates, CMS substitution, kebab-casing).
|
|
1246
|
+
let nodeAttributes = resolveI18nAttrs(extractAttributesFromNode(node), locale, i18nConfig);
|
|
1206
1247
|
const originalAttributes = { ...nodeAttributes };
|
|
1207
1248
|
|
|
1208
1249
|
// Process CMS templates in attributes (e.g., href="{{cms.link}}")
|
|
@@ -1826,7 +1867,7 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
|
|
|
1826
1867
|
: links.join('');
|
|
1827
1868
|
|
|
1828
1869
|
// Extract attributes from node
|
|
1829
|
-
const nodeAttributes = extractAttributesFromNode(node);
|
|
1870
|
+
const nodeAttributes = resolveI18nAttrs(extractAttributesFromNode(node), locale, i18nConfig);
|
|
1830
1871
|
const attrsStr = buildAttributes(nodeAttributes);
|
|
1831
1872
|
|
|
1832
1873
|
const localeListResult = `<div data-locale-list="true"${containerClassAttr}${localeListStyleAttr}${attrsStr}${editorAttrs(ctx)}>${linksHTML}</div>`;
|
|
@@ -456,7 +456,7 @@ export async function buildWebflowPayload(
|
|
|
456
456
|
// the slug, so a template like `templates/blog.json` lands at
|
|
457
457
|
// `/blog/<first-slug>` and doesn't collide with a sibling listing
|
|
458
458
|
// page like `pages/blog.json`.
|
|
459
|
-
let itemSlug = item[cmsSchema.slugField] ?? item._slug ?? item._id;
|
|
459
|
+
let itemSlug = item[cmsSchema.slugField] ?? item._slug ?? item._filename ?? item._id;
|
|
460
460
|
if (isI18nValue(itemSlug)) {
|
|
461
461
|
itemSlug = resolveI18nValue(itemSlug, locale, i18nConfig) as string;
|
|
462
462
|
}
|
|
@@ -1,9 +1,64 @@
|
|
|
1
|
-
import { describe, test, expect } from 'bun:test';
|
|
1
|
+
import { describe, test, expect, beforeEach } from 'bun:test';
|
|
2
|
+
import { WebSocketManager } from './websocketManager';
|
|
3
|
+
import { WEBSOCKET_STATES } from '../shared/constants';
|
|
4
|
+
import type { RuntimeWSClient } from './runtime';
|
|
2
5
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
function makeMockClient(readyState: number = WEBSOCKET_STATES.OPEN): RuntimeWSClient & { sent: string[] } {
|
|
7
|
+
const sent: string[] = [];
|
|
8
|
+
return {
|
|
9
|
+
readyState,
|
|
10
|
+
send(data) {
|
|
11
|
+
sent.push(typeof data === 'string' ? data : '<binary>');
|
|
12
|
+
},
|
|
13
|
+
close() {},
|
|
14
|
+
sent,
|
|
15
|
+
} as RuntimeWSClient & { sent: string[] };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('WebSocketManager', () => {
|
|
19
|
+
let manager: WebSocketManager;
|
|
20
|
+
let client: ReturnType<typeof makeMockClient>;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
manager = new WebSocketManager();
|
|
24
|
+
client = makeMockClient();
|
|
25
|
+
manager.addClient(client);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('broadcastCollectionsUpdate', () => {
|
|
29
|
+
test('sends hmr:cms-collections-update to OPEN clients', () => {
|
|
30
|
+
manager.broadcastCollectionsUpdate();
|
|
31
|
+
expect(client.sent).toEqual([JSON.stringify({ type: 'hmr:cms-collections-update' })]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('skips clients that are not OPEN', () => {
|
|
35
|
+
const closing = makeMockClient(WEBSOCKET_STATES.CLOSING);
|
|
36
|
+
manager.addClient(closing);
|
|
37
|
+
manager.broadcastCollectionsUpdate();
|
|
38
|
+
expect(client.sent.length).toBe(1);
|
|
39
|
+
expect(closing.sent.length).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('broadcastCMSUpdate', () => {
|
|
44
|
+
test('includes collection in payload', () => {
|
|
45
|
+
manager.broadcastCMSUpdate('blog');
|
|
46
|
+
expect(client.sent).toEqual([JSON.stringify({ type: 'hmr:cms-update', collection: 'blog' })]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('broadcastConfigUpdate', () => {
|
|
51
|
+
test('sends hmr:config-update to OPEN clients', () => {
|
|
52
|
+
manager.broadcastConfigUpdate();
|
|
53
|
+
expect(client.sent).toEqual([JSON.stringify({ type: 'hmr:config-update' })]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('client lifecycle', () => {
|
|
58
|
+
test('removed clients no longer receive broadcasts', () => {
|
|
59
|
+
manager.removeClient(client);
|
|
60
|
+
manager.broadcastCollectionsUpdate();
|
|
61
|
+
expect(client.sent.length).toBe(0);
|
|
62
|
+
});
|
|
8
63
|
});
|
|
9
64
|
});
|