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.
Files changed (53) hide show
  1. package/build-astro.ts +6 -2
  2. package/dist/build-static.js +5 -5
  3. package/dist/chunks/{chunk-JER5NQVM.js → chunk-56EUSC6D.js} +4 -4
  4. package/dist/chunks/{chunk-S2CX6HFM.js → chunk-7NIC4I3V.js} +42 -20
  5. package/dist/chunks/chunk-7NIC4I3V.js.map +7 -0
  6. package/dist/chunks/{chunk-EQYDSPBB.js → chunk-CVLFID6V.js} +64 -20
  7. package/dist/chunks/chunk-CVLFID6V.js.map +7 -0
  8. package/dist/chunks/{chunk-LKAGAQ3M.js → chunk-EDQSMAMP.js} +13 -2
  9. package/dist/chunks/{chunk-LKAGAQ3M.js.map → chunk-EDQSMAMP.js.map} +2 -2
  10. package/dist/chunks/{chunk-6IVUG7FY.js → chunk-LPVETICS.js} +19 -2
  11. package/dist/chunks/{chunk-6IVUG7FY.js.map → chunk-LPVETICS.js.map} +2 -2
  12. package/dist/chunks/{chunk-KPU2XHOS.js → chunk-PQ2HRXDR.js} +1 -1
  13. package/dist/chunks/chunk-PQ2HRXDR.js.map +7 -0
  14. package/dist/chunks/{chunk-CHD5UCFF.js → chunk-YWJJD5D6.js} +116 -32
  15. package/dist/chunks/chunk-YWJJD5D6.js.map +7 -0
  16. package/dist/chunks/{configService-CCA6AIDI.js → configService-VOY2MY2K.js} +2 -2
  17. package/dist/entries/server-router.js +5 -5
  18. package/dist/lib/client/index.js +41 -15
  19. package/dist/lib/client/index.js.map +3 -3
  20. package/dist/lib/server/index.js +11 -9
  21. package/dist/lib/server/index.js.map +2 -2
  22. package/dist/lib/shared/index.js +2 -2
  23. package/lib/client/core/ComponentBuilder.test.ts +34 -0
  24. package/lib/client/core/ComponentBuilder.ts +25 -3
  25. package/lib/client/core/builders/embedBuilder.ts +13 -5
  26. package/lib/client/core/builders/linkNodeBuilder.ts +13 -5
  27. package/lib/client/core/builders/localeListBuilder.ts +13 -5
  28. package/lib/client/templateEngine.ts +24 -0
  29. package/lib/server/fileWatcher.test.ts +134 -0
  30. package/lib/server/fileWatcher.ts +100 -32
  31. package/lib/server/jsonLoader.ts +1 -0
  32. package/lib/server/providers/fileSystemCMSProvider.ts +46 -14
  33. package/lib/server/services/configService.ts +1 -0
  34. package/lib/server/services/fileWatcherService.ts +17 -0
  35. package/lib/server/ssr/htmlGenerator.ts +11 -3
  36. package/lib/server/ssr/ssrRenderer.test.ts +258 -0
  37. package/lib/server/ssr/ssrRenderer.ts +46 -5
  38. package/lib/server/webflow/buildWebflow.ts +1 -1
  39. package/lib/server/websocketManager.test.ts +61 -6
  40. package/lib/server/websocketManager.ts +25 -1
  41. package/lib/shared/cssProperties.test.ts +28 -0
  42. package/lib/shared/cssProperties.ts +27 -1
  43. package/lib/shared/types/api.ts +10 -1
  44. package/lib/shared/types/cms.ts +18 -9
  45. package/lib/shared/validation/schemas.test.ts +93 -0
  46. package/lib/shared/validation/schemas.ts +56 -15
  47. package/package.json +1 -1
  48. package/dist/chunks/chunk-CHD5UCFF.js.map +0 -7
  49. package/dist/chunks/chunk-EQYDSPBB.js.map +0 -7
  50. package/dist/chunks/chunk-KPU2XHOS.js.map +0 -7
  51. package/dist/chunks/chunk-S2CX6HFM.js.map +0 -7
  52. /package/dist/chunks/{chunk-JER5NQVM.js.map → chunk-56EUSC6D.js.map} +0 -0
  53. /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 _filename fields.
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
- ...content as CMSItem,
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
- return rest;
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
- * Uses _filename for file path (stable, doesn't change when slug changes)
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
- // Use _filename for file path (new behavior)
236
- // Falls back to slugField for backward compatibility with items without _filename
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
- filename = typeof slugValue === 'string' ? slugValue : String(slugValue);
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('Missing _filename field. Items must have _filename set on creation.');
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 item's `_filename` determines the target file.
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
- filename = typeof slugValue === 'string' ? slugValue : String(slugValue);
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('Missing _filename field. Drafts must have _filename set on creation.');
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);
@@ -25,6 +25,7 @@ import { readTextFile, fileExists } from '../runtime';
25
25
  */
26
26
  export interface IconsConfig {
27
27
  favicon?: string;
28
+ faviconDark?: string;
28
29
  appleTouchIcon?: string;
29
30
  }
30
31
 
@@ -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
- let nodeAttributes = extractAttributesFromNode(node);
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
- describe('websocketManager', () => {
4
- test('placeholder test for coverage', () => {
5
- // WebSocket tests are complex and require server setup
6
- // This placeholder ensures the file appears in coverage reports
7
- expect(true).toBe(true);
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
  });