meno-core 1.0.48 → 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 (89) hide show
  1. package/build-astro.ts +6 -2
  2. package/dist/build-static.js +7 -7
  3. package/dist/chunks/{chunk-D5E3OKSL.js → chunk-56EUSC6D.js} +5 -5
  4. package/dist/chunks/{chunk-3FHJUHAS.js → chunk-7NIC4I3V.js} +300 -43
  5. package/dist/chunks/chunk-7NIC4I3V.js.map +7 -0
  6. package/dist/chunks/{chunk-B2RTLDXY.js → chunk-AZQYF6KE.js} +132 -1
  7. package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
  8. package/dist/chunks/{chunk-TPQ7APVQ.js → chunk-CVLFID6V.js} +473 -73
  9. package/dist/chunks/chunk-CVLFID6V.js.map +7 -0
  10. package/dist/chunks/{chunk-NP76N4HQ.js → chunk-EDQSMAMP.js} +13 -2
  11. package/dist/chunks/{chunk-NP76N4HQ.js.map → chunk-EDQSMAMP.js.map} +2 -2
  12. package/dist/chunks/{chunk-RQSTH2BS.js → chunk-H4JSCDNW.js} +2 -2
  13. package/dist/chunks/{chunk-EK4KESLU.js → chunk-J23ZX5AP.js} +8 -2
  14. package/dist/chunks/{chunk-EK4KESLU.js.map → chunk-J23ZX5AP.js.map} +2 -2
  15. package/dist/chunks/{chunk-UUA5LEWF.js → chunk-LPVETICS.js} +156 -8
  16. package/dist/chunks/chunk-LPVETICS.js.map +7 -0
  17. package/dist/chunks/{chunk-BJRKEPMP.js → chunk-PQ2HRXDR.js} +5 -2
  18. package/dist/chunks/chunk-PQ2HRXDR.js.map +7 -0
  19. package/dist/chunks/{chunk-NKUV77SR.js → chunk-YWJJD5D6.js} +133 -37
  20. package/dist/chunks/chunk-YWJJD5D6.js.map +7 -0
  21. package/dist/chunks/{configService-IGJEC3MC.js → configService-VOY2MY2K.js} +3 -3
  22. package/dist/entries/server-router.js +9 -9
  23. package/dist/entries/server-router.js.map +2 -2
  24. package/dist/lib/client/index.js +92 -32
  25. package/dist/lib/client/index.js.map +3 -3
  26. package/dist/lib/server/index.js +14 -12
  27. package/dist/lib/server/index.js.map +2 -2
  28. package/dist/lib/shared/index.js +46 -10
  29. package/dist/lib/shared/index.js.map +3 -3
  30. package/entries/server-router.tsx +6 -2
  31. package/lib/client/core/ComponentBuilder.test.ts +34 -0
  32. package/lib/client/core/ComponentBuilder.ts +33 -4
  33. package/lib/client/core/builders/embedBuilder.ts +28 -7
  34. package/lib/client/core/builders/linkNodeBuilder.ts +28 -7
  35. package/lib/client/core/builders/localeListBuilder.ts +30 -11
  36. package/lib/client/styles/StyleInjector.ts +3 -2
  37. package/lib/client/templateEngine.ts +24 -0
  38. package/lib/client/theme.ts +4 -4
  39. package/lib/server/cssGenerator.test.ts +64 -1
  40. package/lib/server/cssGenerator.ts +48 -9
  41. package/lib/server/fileWatcher.test.ts +134 -0
  42. package/lib/server/fileWatcher.ts +100 -32
  43. package/lib/server/jsonLoader.ts +1 -0
  44. package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
  45. package/lib/server/providers/fileSystemCMSProvider.ts +240 -19
  46. package/lib/server/routes/index.ts +1 -1
  47. package/lib/server/routes/pages.ts +23 -1
  48. package/lib/server/services/cmsService.test.ts +246 -0
  49. package/lib/server/services/cmsService.ts +122 -5
  50. package/lib/server/services/configService.ts +6 -0
  51. package/lib/server/services/fileWatcherService.ts +17 -0
  52. package/lib/server/ssr/attributeBuilder.ts +41 -0
  53. package/lib/server/ssr/htmlGenerator.test.ts +113 -0
  54. package/lib/server/ssr/htmlGenerator.ts +62 -7
  55. package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
  56. package/lib/server/ssr/ssrRenderer.test.ts +564 -0
  57. package/lib/server/ssr/ssrRenderer.ts +228 -49
  58. package/lib/server/webflow/buildWebflow.ts +1 -1
  59. package/lib/server/websocketManager.test.ts +61 -6
  60. package/lib/server/websocketManager.ts +25 -1
  61. package/lib/shared/cssGeneration.test.ts +267 -1
  62. package/lib/shared/cssGeneration.ts +240 -18
  63. package/lib/shared/cssProperties.test.ts +275 -1
  64. package/lib/shared/cssProperties.ts +223 -7
  65. package/lib/shared/interfaces/contentProvider.ts +39 -6
  66. package/lib/shared/pathSecurity.ts +16 -0
  67. package/lib/shared/responsiveScaling.test.ts +143 -0
  68. package/lib/shared/responsiveScaling.ts +253 -2
  69. package/lib/shared/themeDefaults.test.ts +3 -3
  70. package/lib/shared/themeDefaults.ts +3 -3
  71. package/lib/shared/types/api.ts +10 -1
  72. package/lib/shared/types/cms.ts +46 -12
  73. package/lib/shared/types/index.ts +1 -0
  74. package/lib/shared/utilityClassConfig.ts +3 -0
  75. package/lib/shared/utilityClassMapper.test.ts +123 -0
  76. package/lib/shared/utilityClassMapper.ts +179 -8
  77. package/lib/shared/validation/schemas.test.ts +93 -0
  78. package/lib/shared/validation/schemas.ts +71 -16
  79. package/lib/shared/validation/validators.ts +26 -1
  80. package/package.json +1 -1
  81. package/dist/chunks/chunk-3FHJUHAS.js.map +0 -7
  82. package/dist/chunks/chunk-B2RTLDXY.js.map +0 -7
  83. package/dist/chunks/chunk-BJRKEPMP.js.map +0 -7
  84. package/dist/chunks/chunk-NKUV77SR.js.map +0 -7
  85. package/dist/chunks/chunk-TPQ7APVQ.js.map +0 -7
  86. package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
  87. /package/dist/chunks/{chunk-D5E3OKSL.js.map → chunk-56EUSC6D.js.map} +0 -0
  88. /package/dist/chunks/{chunk-RQSTH2BS.js.map → chunk-H4JSCDNW.js.map} +0 -0
  89. /package/dist/chunks/{configService-IGJEC3MC.js.map → configService-VOY2MY2K.js.map} +0 -0
@@ -43,6 +43,7 @@ mock.module('./imageMetadata', () => ({
43
43
  mock.module('../services/configService', () => ({
44
44
  configService: {
45
45
  getImageFormat: () => mockImageFormat,
46
+ getResponsiveScales: () => ({ enabled: false, baseReference: 16 }),
46
47
  load: async () => {},
47
48
  isLoaded: () => true,
48
49
  reset: () => {},
@@ -440,6 +441,82 @@ describe('ssrRenderer', () => {
440
441
  });
441
442
  });
442
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
+
443
520
  // -----------------------------------------------------------------------
444
521
  // 7. Link nodes
445
522
  // -----------------------------------------------------------------------
@@ -3109,6 +3186,203 @@ describe('ssrRenderer', () => {
3109
3186
  expect(html).toContain('Author a1');
3110
3187
  expect(html).toContain('Author a2');
3111
3188
  });
3189
+
3190
+ test('inner list items dotted-ref resolves against host component prop', async () => {
3191
+ // {{config.0.tagIds}} is a dotted template — processStructure leaves it literal
3192
+ // (hasItemTemplates returns true for `\w+\.\w+`). The SSR items resolver must
3193
+ // see the host component's `config` prop in its scope.
3194
+ const calledWith: string[][] = [];
3195
+ const mockCmsService = {
3196
+ getItemsByIds: async (_collection: string, ids: string[]) => {
3197
+ calledWith.push(ids);
3198
+ return ids.map(id => ({ _id: id, name: `Tag ${id}` }));
3199
+ },
3200
+ getSchema: () => undefined,
3201
+ };
3202
+
3203
+ const Card: ComponentDefinition = {
3204
+ component: {
3205
+ interface: {
3206
+ config: { type: 'list' as any, default: [] },
3207
+ },
3208
+ structure: {
3209
+ type: 'list',
3210
+ sourceType: 'collection',
3211
+ source: 'tags',
3212
+ itemAs: 'tag',
3213
+ items: '{{config.0.tagIds}}',
3214
+ children: [
3215
+ { type: 'node', tag: 'span', children: '{{tag.name}}' },
3216
+ ],
3217
+ },
3218
+ },
3219
+ };
3220
+
3221
+ const node = {
3222
+ type: 'component',
3223
+ component: 'Card',
3224
+ props: {
3225
+ config: [{ tagIds: ['t1', 't2'] }],
3226
+ },
3227
+ };
3228
+
3229
+ const html = await render(node, {
3230
+ globalComponents: { Card },
3231
+ cmsService: mockCmsService,
3232
+ });
3233
+
3234
+ expect(calledWith).toEqual([['t1', 't2']]);
3235
+ expect(html).toContain('Tag t1');
3236
+ expect(html).toContain('Tag t2');
3237
+ });
3238
+
3239
+ test('inner list filter dotted-ref resolves against host component prop', async () => {
3240
+ // {{config.0.collection}} is a dotted template — processStructure leaves it literal.
3241
+ // SSR's resolveFilterTemplates must see the host component's `config` prop.
3242
+ const queries: any[] = [];
3243
+ const mockCmsService = {
3244
+ queryItems: async (q: any) => {
3245
+ queries.push(q);
3246
+ return [{ _id: 'p1', title: 'Hello' }];
3247
+ },
3248
+ getSchema: () => undefined,
3249
+ };
3250
+
3251
+ const Card: ComponentDefinition = {
3252
+ component: {
3253
+ interface: {
3254
+ config: { type: 'list' as any, default: [] },
3255
+ },
3256
+ structure: {
3257
+ type: 'list',
3258
+ sourceType: 'collection',
3259
+ source: 'posts',
3260
+ itemAs: 'post',
3261
+ filter: { category: '{{config.0.collection}}' },
3262
+ children: [
3263
+ { type: 'node', tag: 'div', children: '{{post.title}}' },
3264
+ ],
3265
+ },
3266
+ },
3267
+ };
3268
+
3269
+ const node = {
3270
+ type: 'component',
3271
+ component: 'Card',
3272
+ props: {
3273
+ config: [{ collection: 'news' }],
3274
+ },
3275
+ };
3276
+
3277
+ const html = await render(node, {
3278
+ globalComponents: { Card },
3279
+ cmsService: mockCmsService,
3280
+ });
3281
+
3282
+ expect(queries.length).toBe(1);
3283
+ expect(queries[0].filter).toEqual({ category: 'news' });
3284
+ expect(html).toContain('Hello');
3285
+ });
3286
+
3287
+ test('parent list loop variable wins over a colliding host component prop', async () => {
3288
+ // Outer CMS list iterates with itemAs: 'tag', producing a templateContext entry
3289
+ // named `tag` set to the current item. The host Card also receives a prop named
3290
+ // `tag` set to a fake value. The inner list's items: {{tag.id}} must resolve to
3291
+ // the parent loop variable's id (precedence: parent loop variable > component props).
3292
+ const calledWith: string[][] = [];
3293
+ const mockCmsService = {
3294
+ // Outer iteration: itemAs: 'tag' makes templateContext.tag = item, so the
3295
+ // inner template `{{tag.id}}` looks up item.id directly.
3296
+ queryItems: async () => [
3297
+ { _id: 'p1', id: 'real-1' },
3298
+ { _id: 'p2', id: 'real-2' },
3299
+ ],
3300
+ getItemsByIds: async (_collection: string, ids: string[]) => {
3301
+ calledWith.push(ids);
3302
+ return ids.map(id => ({ _id: id, name: `Item ${id}` }));
3303
+ },
3304
+ getSchema: () => undefined,
3305
+ };
3306
+
3307
+ const Card: ComponentDefinition = {
3308
+ component: {
3309
+ interface: {
3310
+ // type: 'list' so the array prop value passes validation. The prop
3311
+ // `tag` will land in componentResolvedProps; the parent loop var
3312
+ // `tag` (set to the current outer item) will land in templateContext.
3313
+ tag: { type: 'list' as any, default: [] },
3314
+ },
3315
+ structure: {
3316
+ type: 'list',
3317
+ sourceType: 'collection',
3318
+ source: 'tags',
3319
+ itemAs: 'inner',
3320
+ items: '{{tag.id}}',
3321
+ children: [
3322
+ { type: 'node', tag: 'span', children: '{{inner.name}}' },
3323
+ ],
3324
+ },
3325
+ },
3326
+ };
3327
+
3328
+ const node = {
3329
+ type: 'list',
3330
+ sourceType: 'collection',
3331
+ source: 'posts',
3332
+ itemAs: 'tag',
3333
+ children: [
3334
+ {
3335
+ type: 'component',
3336
+ component: 'Card',
3337
+ props: {
3338
+ // Static fake value for the colliding prop; the inner template
3339
+ // must resolve to the parent loop var, not this.
3340
+ tag: [{ id: 'fake-prop-id' }],
3341
+ },
3342
+ },
3343
+ ],
3344
+ };
3345
+
3346
+ await render(node, {
3347
+ globalComponents: { Card },
3348
+ cmsService: mockCmsService,
3349
+ });
3350
+
3351
+ expect(calledWith).toEqual([['real-1'], ['real-2']]);
3352
+ });
3353
+
3354
+ test('page-root filter does not silently resolve {{cms.X}} (regression)', async () => {
3355
+ // Without a host component, ctx.componentResolvedProps is undefined and
3356
+ // ctx.templateContext is undefined → buildListResolutionScope returns
3357
+ // undefined → resolveFilterTemplates leaves filter values literal.
3358
+ // This pins down the documented behavior: the cmsContext namespace does
3359
+ // NOT silently merge into filter scope.
3360
+ const queries: any[] = [];
3361
+ const mockCmsService = {
3362
+ queryItems: async (q: any) => {
3363
+ queries.push(q);
3364
+ return [];
3365
+ },
3366
+ getSchema: () => undefined,
3367
+ };
3368
+
3369
+ const node = {
3370
+ type: 'list',
3371
+ sourceType: 'collection',
3372
+ source: 'posts',
3373
+ filter: { category: '{{cms.something}}' },
3374
+ children: [{ type: 'node', tag: 'div', children: '{{post.title}}' }],
3375
+ };
3376
+
3377
+ // cmsContext is provided, but page-root filter must not pull from it.
3378
+ await render(node, {
3379
+ cmsService: mockCmsService,
3380
+ cmsContext: { cms: { something: 'shouldNotBeUsed' } },
3381
+ });
3382
+
3383
+ expect(queries.length).toBe(1);
3384
+ expect(queries[0].filter).toEqual({ category: '{{cms.something}}' });
3385
+ });
3112
3386
  });
3113
3387
 
3114
3388
  // -----------------------------------------------------------------------
@@ -3862,6 +4136,188 @@ describe('ssrRenderer', () => {
3862
4136
  });
3863
4137
  });
3864
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
+
3865
4321
  describe('CMS link localization', () => {
3866
4322
  const i18nConfig = {
3867
4323
  defaultLocale: 'en',
@@ -4019,4 +4475,112 @@ describe('ssrRenderer', () => {
4019
4475
  expect(html).toContain('href="/fr/a-propos"');
4020
4476
  });
4021
4477
  });
4478
+
4479
+ // Editor attrs are emitted only when buildComponentHTML's injectEditorAttrs flag is on.
4480
+ // These attributes (data-element-path, data-cms-item-index, data-cms-context,
4481
+ // data-component-root, data-parent-component, data-component-context) drive
4482
+ // XRayOverlay and the click-to-select handler in static (SSR) preview mode.
4483
+ describe('buildComponentHTML - editor attrs (preview-only)', () => {
4484
+ test('omits data-element-path by default', async () => {
4485
+ const node = { type: 'node', tag: 'div', children: ['hello'] };
4486
+ const result = await buildComponentHTML(node as any);
4487
+ expect(result.html).not.toContain('data-element-path');
4488
+ });
4489
+
4490
+ test('emits data-element-path on every element when injectEditorAttrs is true', async () => {
4491
+ const node = {
4492
+ type: 'node',
4493
+ tag: 'div',
4494
+ children: [
4495
+ { type: 'node', tag: 'span', children: ['hi'] },
4496
+ { type: 'node', tag: 'a', attributes: { href: '/x' }, children: ['link'] },
4497
+ ],
4498
+ };
4499
+ const result = await buildComponentHTML(
4500
+ node as any,
4501
+ {}, {}, undefined, undefined, undefined, undefined, undefined, undefined, undefined, true,
4502
+ );
4503
+ expect(result.html).toContain('data-element-path="0"');
4504
+ expect(result.html).toContain('data-element-path="0,0"');
4505
+ expect(result.html).toContain('data-element-path="0,1"');
4506
+ });
4507
+
4508
+ test('emits data-cms-item-index and data-cms-context for elements inside a list', async () => {
4509
+ // List in prop mode under a component resolves items from componentResolvedProps.
4510
+ const Card: ComponentDefinition = {
4511
+ component: {
4512
+ interface: {
4513
+ items: { type: 'list', default: [{ label: 'A' }, { label: 'B' }] } as any,
4514
+ },
4515
+ structure: {
4516
+ type: 'node',
4517
+ tag: 'div',
4518
+ children: [{
4519
+ type: 'list',
4520
+ sourceType: 'prop',
4521
+ source: 'items',
4522
+ children: [{ type: 'node', tag: 'p', children: '{{item.label}}' }],
4523
+ }],
4524
+ },
4525
+ },
4526
+ };
4527
+ const result = await buildComponentHTML(
4528
+ { type: 'component', component: 'Card' } as any,
4529
+ { Card },
4530
+ {}, undefined, undefined, undefined, undefined, undefined, undefined, undefined, true,
4531
+ );
4532
+ expect(result.html).toContain('data-cms-item-index="0"');
4533
+ expect(result.html).toContain('data-cms-item-index="1"');
4534
+ expect(result.html).toContain('data-cms-context=');
4535
+ });
4536
+
4537
+ test('marks the component instance root with data-component-root', async () => {
4538
+ const Card: ComponentDefinition = {
4539
+ component: {
4540
+ interface: {},
4541
+ structure: { type: 'node', tag: 'div', children: ['inner'] },
4542
+ },
4543
+ };
4544
+ const result = await buildComponentHTML(
4545
+ { type: 'component', component: 'Card' } as any,
4546
+ { Card },
4547
+ {}, undefined, undefined, undefined, undefined, undefined, undefined, undefined, true,
4548
+ );
4549
+ expect(result.html).toContain('data-component-root="true"');
4550
+ expect(result.html).toContain('data-component-context="Card"');
4551
+ });
4552
+
4553
+ test('emits page-absolute data-element-path for elements inside a component', async () => {
4554
+ // Component has a single wrapper <div> with a <span> child. The page hosts
4555
+ // the component as the only child of its root <div>, so:
4556
+ // page root <div> → [0]
4557
+ // Card root <div> → [0,0]
4558
+ // inner <span> → [0,0,0]
4559
+ // Before the fix SSR reset elementPath inside components, so the inner
4560
+ // span landed at [0] (component-relative) and XRay couldn't find it.
4561
+ const Card: ComponentDefinition = {
4562
+ component: {
4563
+ interface: {},
4564
+ structure: {
4565
+ type: 'node',
4566
+ tag: 'div',
4567
+ children: [{ type: 'node', tag: 'span', children: 'inner' }],
4568
+ },
4569
+ },
4570
+ };
4571
+ const page = {
4572
+ type: 'node',
4573
+ tag: 'div',
4574
+ children: [{ type: 'component', component: 'Card' }],
4575
+ };
4576
+ const result = await buildComponentHTML(
4577
+ page as any,
4578
+ { Card },
4579
+ {}, undefined, undefined, undefined, undefined, undefined, undefined, undefined, true,
4580
+ );
4581
+ expect(result.html).toContain('data-element-path="0"');
4582
+ expect(result.html).toContain('data-element-path="0,0"');
4583
+ expect(result.html).toContain('data-element-path="0,0,0"');
4584
+ });
4585
+ });
4022
4586
  });