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
@@ -9,6 +9,7 @@ import { processItemTemplate, processItemPropsTemplate, hasItemTemplates, buildT
9
9
  import { singularize, isItemDraftForLocale } from '../../shared/types/cms';
10
10
  import type { ResponsiveStyleObject, StyleObject } from '../../shared/types';
11
11
  import type { BreakpointConfig } from '../../shared/breakpoints';
12
+ import type { ResponsiveScales } from '../../shared/responsiveScaling';
12
13
  import { evaluateTemplate, processStructure, isResponsiveStyle, isHtmlMapping, resolveHtmlMapping } from '../../client/templateEngine';
13
14
  import { resolvePropsFromDefinition, isRichTextMarker, richTextMarkerToHtml } from '../../shared/propResolver';
14
15
  import { loadBreakpointConfig, loadI18nConfig } from '../jsonLoader';
@@ -50,7 +51,7 @@ import type { SlugMap } from '../../shared/slugTranslator';
50
51
  import { buildSlugIndex, getLocaleLinks, translatePath } from '../../shared/slugTranslator';
51
52
 
52
53
  // Import from modularized files
53
- import { escapeHtml, buildAttributes, styleToString } from './attributeBuilder';
54
+ import { escapeHtml, buildAttributes, buildEditorAttrs, styleToString } from './attributeBuilder';
54
55
  import { extractPageMeta, generateMetaTags } from './metaTagGenerator';
55
56
  import { collectComponentCSS } from './cssCollector';
56
57
  import { collectComponentJavaScript } from './jsCollector';
@@ -71,6 +72,30 @@ export interface PreloadImage {
71
72
 
72
73
  // Re-export types for external consumers
73
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
+ }
74
99
  export type { PageMeta } from './metaTagGenerator';
75
100
  export { extractPageMeta, generateMetaTags } from './metaTagGenerator';
76
101
 
@@ -128,6 +153,30 @@ interface SSRContext {
128
153
  processedRawHtmlCollector?: Map<string, string>;
129
154
  /** Image format: 'webp' uses plain <img>, 'avif' uses <picture> with AVIF+WebP sources */
130
155
  imageFormat?: 'webp' | 'avif';
156
+ /**
157
+ * When true, emit editor-only attributes (data-element-path, data-cms-item-index,
158
+ * data-cms-context, data-component-root, data-parent-component, data-component-context)
159
+ * so XRayOverlay and click-to-select can hook into SSR-rendered DOM.
160
+ * Set only in preview/dev mode — never in production builds.
161
+ */
162
+ injectEditorAttrs?: boolean;
163
+ /** CMS list item index path (parallel to cmsListPaths) for nested list selection support */
164
+ cmsItemIndexPath?: number[];
165
+ /** CMS list element paths (parallel to cmsItemIndexPath) — outer lists first */
166
+ cmsListPaths?: number[][];
167
+ /** Name of the enclosing component instance (for data-parent-component on descendants) */
168
+ parentComponentName?: string;
169
+ /**
170
+ * Element path of the current component instance's root, in the component's local
171
+ * (reset) coordinate space. When ctx.elementPath deeply equals this, the element
172
+ * being rendered is the component root and gets data-component-root="true".
173
+ */
174
+ componentRootPath?: number[];
175
+ /**
176
+ * Responsive scales config — needed for fluid-mode style transformations
177
+ * (e.g. `applyContainerPattern` when width === maxWidth).
178
+ */
179
+ responsiveScales?: ResponsiveScales;
131
180
  }
132
181
 
133
182
  /**
@@ -138,6 +187,21 @@ function getTemplateContext(ctx: SSRContext): TemplateContext | null {
138
187
  return ctx.templateContext || null;
139
188
  }
140
189
 
190
+ /**
191
+ * Build the resolution scope for list `items` and `filter` value templates inside a
192
+ * component structure. Merge order (lowest → highest precedence):
193
+ * componentResolvedProps → templateContext (parent list loop variables)
194
+ * The cmsContext stays out of this scope — it's namespaced via {{cms.X}} and resolved
195
+ * on its own branch in getCollectionItems. Returns undefined when no scope is available
196
+ * (e.g. page root with no host component and no parent list).
197
+ */
198
+ function buildListResolutionScope(ctx: SSRContext): Record<string, unknown> | undefined {
199
+ const tplCtx = ctx.templateContext as Record<string, unknown> | undefined;
200
+ const props = ctx.componentResolvedProps;
201
+ if (!tplCtx && !props) return undefined;
202
+ return { ...(props ?? {}), ...(tplCtx ?? {}) };
203
+ }
204
+
141
205
  /**
142
206
  * Create a value resolver for i18n resolution (for use with item templates)
143
207
  */
@@ -198,7 +262,18 @@ function processStyleToClasses(
198
262
  getI18nResolver(ctx)
199
263
  ) as StyleObject | ResponsiveStyleObject;
200
264
  }
201
- return responsiveStylesToClasses(processedStyle as ResponsiveStyleObject);
265
+
266
+ // Fluid container pattern: in fluid mode `responsiveStylesToClasses` rewrites
267
+ // any tier whose `width === maxWidth` to use `calc(100% - var(--site-margin)*2)`
268
+ // with auto margins.
269
+ const fluidActive =
270
+ ctx.responsiveScales?.enabled === true &&
271
+ ctx.responsiveScales?.mode === 'fluid';
272
+
273
+ return responsiveStylesToClasses(
274
+ processedStyle as ResponsiveStyleObject,
275
+ { fluidActive, responsiveScales: ctx.responsiveScales }
276
+ );
202
277
  }
203
278
 
204
279
  /**
@@ -260,33 +335,34 @@ function evaluateIfCondition(node: ComponentNode, ctx: SSRContext): boolean {
260
335
  }
261
336
 
262
337
  /**
263
- * Resolve a template value like "{{category.slug}}" from template context.
338
+ * Resolve a template value like "{{category.slug}}" from a flat scope.
264
339
  * Returns the original value if not a template or can't be resolved.
265
340
  */
266
- function resolveFilterValue(value: unknown, templateContext: TemplateContext | undefined): unknown {
267
- if (!templateContext || typeof value !== 'string' || !value.startsWith('{{') || !value.endsWith('}}')) {
341
+ function resolveFilterValue(value: unknown, scope: Record<string, unknown> | undefined): unknown {
342
+ if (!scope || typeof value !== 'string' || !value.startsWith('{{') || !value.endsWith('}}')) {
268
343
  return value;
269
344
  }
270
345
  const path = value.slice(2, -2).trim();
271
- const resolved = getNestedValue(templateContext as Record<string, unknown>, path);
346
+ const resolved = getNestedValue(scope, path);
272
347
  return resolved !== undefined ? resolved : value;
273
348
  }
274
349
 
275
350
  /**
276
351
  * Resolve template values in CMS filter for nested cms-list support.
277
- * Handles filter values like "{{category.slug}}" from parent template context.
352
+ * Handles filter values like "{{category.slug}}" against a flat scope built from
353
+ * parent template context overlaid on host-component resolved props.
278
354
  */
279
355
  function resolveFilterTemplates(
280
356
  filter: CMSFilterCondition | CMSFilterCondition[] | Record<string, unknown> | undefined,
281
- templateContext: TemplateContext | undefined
357
+ scope: Record<string, unknown> | undefined
282
358
  ): CMSFilterCondition | CMSFilterCondition[] | Record<string, unknown> | undefined {
283
- if (!filter || !templateContext) return filter;
359
+ if (!filter || !scope) return filter;
284
360
 
285
361
  // Handle array of conditions
286
362
  if (Array.isArray(filter)) {
287
363
  return filter.map(cond => ({
288
364
  ...cond,
289
- value: resolveFilterValue(cond.value, templateContext)
365
+ value: resolveFilterValue(cond.value, scope)
290
366
  }));
291
367
  }
292
368
 
@@ -294,14 +370,14 @@ function resolveFilterTemplates(
294
370
  if ('field' in filter && 'value' in filter) {
295
371
  return {
296
372
  ...(filter as CMSFilterCondition),
297
- value: resolveFilterValue((filter as CMSFilterCondition).value, templateContext)
373
+ value: resolveFilterValue((filter as CMSFilterCondition).value, scope)
298
374
  };
299
375
  }
300
376
 
301
377
  // Handle simple object filter: { category: "{{category.slug}}" }
302
378
  const resolved: Record<string, unknown> = {};
303
379
  for (const [key, value] of Object.entries(filter)) {
304
- resolved[key] = resolveFilterValue(value, templateContext);
380
+ resolved[key] = resolveFilterValue(value, scope);
305
381
  }
306
382
  return resolved;
307
383
  }
@@ -383,7 +459,8 @@ export async function buildComponentHTML(
383
459
  pagePath?: string,
384
460
  cmsContext?: CMSContext,
385
461
  cmsService?: CMSService,
386
- isProductionBuild?: boolean
462
+ isProductionBuild?: boolean,
463
+ injectEditorAttrs?: boolean
387
464
  ): Promise<{ html: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string>; ssrFallbackCollector: Map<string, string>; processedRawHtmlCollector: Map<string, string> }> {
388
465
  // Create map to collect interactive styles during render
389
466
  const interactiveStylesMap = new Map<string, InteractiveStyles>();
@@ -430,6 +507,8 @@ export async function buildComponentHTML(
430
507
  ssrFallbackCollector, // Collect SSR fallback HTML for complex nodes
431
508
  processedRawHtmlCollector, // Collect raw→processed HTML for Astro exporter
432
509
  imageFormat: configService.getImageFormat(),
510
+ injectEditorAttrs,
511
+ responsiveScales: configService.getResponsiveScales(),
433
512
  };
434
513
 
435
514
  const html = await renderNode(node, ctx);
@@ -482,7 +561,7 @@ async function renderNestedListPlaceholder(
482
561
  const configJson = escapeHtml(JSON.stringify(config));
483
562
 
484
563
  // Emit placeholder with embedded config and template
485
- return `<div data-cms-list-nested="true" data-collection="${escapeHtml(sourceStr)}" data-cms-config="${configJson}">` +
564
+ return `<div data-cms-list-nested="true" data-collection="${escapeHtml(sourceStr)}" data-cms-config="${configJson}"${editorAttrs(ctx, { isCMSListContainer: true })}>` +
486
565
  `<template data-nested-template>${templateContent}</template>` +
487
566
  `</div>`;
488
567
  }
@@ -585,6 +664,8 @@ async function processList(node: ListNode, ctx: SSRContext): Promise<string> {
585
664
  const itemCtx: SSRContext = {
586
665
  ...ctx,
587
666
  templateContext,
667
+ cmsItemIndexPath: [...(ctx.cmsItemIndexPath ?? []), i],
668
+ cmsListPaths: [...(ctx.cmsListPaths ?? []), ctx.elementPath ?? []],
588
669
  };
589
670
 
590
671
  const childrenHtml = await renderChildrenAsync(node.children || [], itemCtx);
@@ -648,8 +729,12 @@ async function getCollectionItems(node: ListNode, source: string, ctx: SSRContex
648
729
  resolvedIds = Array.isArray(value) ? value.map(v => String(v)) : String(value);
649
730
  }
650
731
  } else {
651
- // Otherwise resolve from template context (for nested lists)
652
- const parentContext = ctx.templateContext || { _type: 'template' as const };
732
+ // Otherwise resolve from a merged scope: parent list loop variables (highest precedence)
733
+ // overlaid on the host component's resolved props (lowest precedence). This lets
734
+ // {{config.tagIds}} resolve against a component prop named `config` while {{post.X}}
735
+ // from a parent list still wins on key collisions.
736
+ const mergedScope = buildListResolutionScope(ctx) ?? {};
737
+ const parentContext = { _type: 'template' as const, ...mergedScope } as TemplateContext;
653
738
  resolvedIds = resolveItemsTemplate(node.items, parentContext);
654
739
  }
655
740
 
@@ -668,10 +753,11 @@ async function getCollectionItems(node: ListNode, source: string, ctx: SSRContex
668
753
  items = items.filter(item => !isItemDraftForLocale(item, ctx.locale!));
669
754
  }
670
755
  } else {
671
- // Build query from node props (resolve filter templates for nested list support)
756
+ // Build query from node props (resolve filter templates against parent loop variables
757
+ // overlaid on host-component resolved props, with parent loop variables winning).
672
758
  const query = {
673
759
  collection: source,
674
- filter: resolveFilterTemplates(node.filter as CMSFilterCondition | CMSFilterCondition[] | Record<string, unknown> | undefined, ctx.templateContext),
760
+ filter: resolveFilterTemplates(node.filter as CMSFilterCondition | CMSFilterCondition[] | Record<string, unknown> | undefined, buildListResolutionScope(ctx)),
675
761
  sort: node.sort,
676
762
  limit: node.limit,
677
763
  offset: node.offset,
@@ -757,11 +843,18 @@ function buildNodeElementClass(
757
843
  ? pagePath.replace(/^\//, '').replace(/\//g, '_') || 'index'
758
844
  : 'page');
759
845
 
846
+ // Slice off the component-instance prefix so class hashes stay stable across
847
+ // instances of the same component (mirrors ComponentBuilder.ts:678-680).
848
+ const rawPath = ctx.elementPath || [];
849
+ const path = useComponentContext && ctx.componentRootPath
850
+ ? rawPath.slice(ctx.componentRootPath.length)
851
+ : rawPath;
852
+
760
853
  const elementClassCtx: ElementClassContext = {
761
854
  fileType: effectiveFileType,
762
855
  fileName: effectiveFileName || 'page',
763
856
  label,
764
- path: ctx.elementPath || [],
857
+ path,
765
858
  };
766
859
  return generateElementClassName(elementClassCtx);
767
860
  }
@@ -799,6 +892,42 @@ function buildCssVariableStyleAttr(cssVariables: Record<string, string>): string
799
892
  return ` style="${escapeHtml(styleString)}"`;
800
893
  }
801
894
 
895
+ function arraysEqual(a: number[] | undefined, b: number[] | undefined): boolean {
896
+ if (!a || !b || a.length !== b.length) return false;
897
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
898
+ return true;
899
+ }
900
+
901
+ /**
902
+ * Compute editor-only attributes for the current context.
903
+ * Returns '' when injectEditorAttrs is off — preview-only feature.
904
+ */
905
+ function editorAttrs(
906
+ ctx: SSRContext,
907
+ opts: { isSlotContent?: boolean; isCMSListContainer?: boolean } = {}
908
+ ): string {
909
+ if (!ctx.injectEditorAttrs) return '';
910
+ // Component root: inside a component instance and at the path the instance was reset to.
911
+ const isComponentRoot = !!ctx.componentContext
912
+ && !opts.isSlotContent
913
+ && arraysEqual(ctx.elementPath, ctx.componentRootPath);
914
+ // Mirror client semantics: at component root, parent is the outer component;
915
+ // for descendants of a component, parent is the current component itself.
916
+ const effectiveParent = opts.isSlotContent
917
+ ? ctx.parentComponentName
918
+ : (isComponentRoot ? ctx.parentComponentName : (ctx.componentContext ?? ctx.parentComponentName));
919
+ return buildEditorAttrs({
920
+ elementPath: ctx.elementPath,
921
+ cmsItemIndexPath: ctx.cmsItemIndexPath,
922
+ cmsListPaths: ctx.cmsListPaths,
923
+ componentContext: ctx.componentContext,
924
+ parentComponentName: effectiveParent,
925
+ isComponentRoot,
926
+ isSlotContent: opts.isSlotContent,
927
+ isCMSListContainer: opts.isCMSListContainer,
928
+ });
929
+ }
930
+
802
931
  /**
803
932
  * Render a node (or array of nodes) to HTML string
804
933
  */
@@ -863,6 +992,21 @@ async function renderNode(
863
992
 
864
993
  if (typeof node !== 'object') return '';
865
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
+
866
1010
  // Check if condition - skip rendering if false
867
1011
  if (!evaluateIfCondition(node as ComponentNode, ctx)) {
868
1012
  return '';
@@ -922,7 +1066,7 @@ async function renderNode(
922
1066
  : sanitizedHtml;
923
1067
 
924
1068
  // Extract attributes from node
925
- const nodeAttributes = extractAttributesFromNode(node);
1069
+ const nodeAttributes = resolveI18nAttrs(extractAttributesFromNode(node), locale, i18nConfig);
926
1070
 
927
1071
  // Build className array
928
1072
  const classNames: string[] = ['oem'];
@@ -960,7 +1104,7 @@ async function renderNode(
960
1104
  const classAttr = classNames.length > 0 ? ` class="${escapeHtml(classNames.filter(Boolean).join(' '))}"` : '';
961
1105
 
962
1106
  // Always use span for embeds - valid inside <p> and other phrasing content
963
- return `<span${classAttr}${embedStyleAttr}${attrs}>${optimizedHtml}</span>`;
1107
+ return `<span${classAttr}${embedStyleAttr}${attrs}${editorAttrs(ctx, { isSlotContent: isSlotContent(node) })}>${optimizedHtml}</span>`;
964
1108
  }
965
1109
 
966
1110
  // Add attribute className if present (fallback when no interactive styles)
@@ -975,7 +1119,7 @@ async function renderNode(
975
1119
  const classAttr = classNames.length > 0 ? ` class="${escapeHtml(classNames.filter(Boolean).join(' '))}"` : '';
976
1120
 
977
1121
  // Always use span for embeds - valid inside <p> and other phrasing content
978
- return `<span${classAttr}${attrs}>${optimizedHtml}</span>`;
1122
+ return `<span${classAttr}${attrs}${editorAttrs(ctx, { isSlotContent: isSlotContent(node) })}>${optimizedHtml}</span>`;
979
1123
  }
980
1124
 
981
1125
  // Handle link nodes (render as <a> tag in SSR)
@@ -1015,7 +1159,7 @@ async function renderNode(
1015
1159
  href = localizeHref(href, ctx);
1016
1160
 
1017
1161
  // Extract attributes from node
1018
- const nodeAttributes = extractAttributesFromNode(node);
1162
+ const nodeAttributes = resolveI18nAttrs(extractAttributesFromNode(node), locale, i18nConfig);
1019
1163
 
1020
1164
  // Build className array - start with olink base class
1021
1165
  const classNames: string[] = ['olink'];
@@ -1070,7 +1214,7 @@ async function renderNode(
1070
1214
  }))).join('')
1071
1215
  : await renderNode(children as ComponentNode | string | number | null | undefined, { ...ctx, elementPath: ctx.elementPath ? [...ctx.elementPath, 0] : [0] });
1072
1216
 
1073
- return `<a href="${escapeHtml(String(href))}"${classAttr}${olinkStyleAttr}${attrs}>${childrenHTML}</a>`;
1217
+ return `<a href="${escapeHtml(String(href))}"${classAttr}${olinkStyleAttr}${attrs}${editorAttrs(ctx, { isSlotContent: isSlotContent(node) })}>${childrenHTML}</a>`;
1074
1218
  }
1075
1219
 
1076
1220
  // Handle locale-list node type (render locale switcher links)
@@ -1096,8 +1240,10 @@ async function renderNode(
1096
1240
  nodeProps = processItemPropsTemplate(nodeProps, templateCtx, i18nResolver);
1097
1241
  }
1098
1242
 
1099
- // Extract attributes from node
1100
- 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);
1101
1247
  const originalAttributes = { ...nodeAttributes };
1102
1248
 
1103
1249
  // Process CMS templates in attributes (e.g., href="{{cms.link}}")
@@ -1351,13 +1497,17 @@ async function renderComponent(
1351
1497
  }
1352
1498
  }
1353
1499
 
1354
- // Render the processed component structure with component context
1355
- // Reset element path to component-relative for stable class names
1356
- // Pass resolved props for interactive style mapping resolution
1500
+ // Render the processed component structure with component context.
1501
+ // elementPath stays page-absolute (matching client ComponentBuilder.ts:874) so
1502
+ // data-element-path values match XRay's tree paths. componentRootPath captures
1503
+ // the page-level path of this instance so buildNodeElementClass can slice it
1504
+ // off to keep class hashes component-relative across instances.
1357
1505
  return await renderNode(processedStructure, {
1358
1506
  ...ctx,
1507
+ // The previously-active component (if any) becomes the parent for editor attrs
1508
+ parentComponentName: ctx.componentContext,
1359
1509
  componentContext: componentName,
1360
- elementPath: [0],
1510
+ componentRootPath: ctx.elementPath,
1361
1511
  componentResolvedProps: resolvedProps,
1362
1512
  });
1363
1513
 
@@ -1394,7 +1544,7 @@ async function renderLinkNode(
1394
1544
  }))).join('')
1395
1545
  : await renderNode(children as ComponentNode, { ...ctx, elementPath: ctx.elementPath ? [...ctx.elementPath, 0] : [0] });
1396
1546
 
1397
- return `<a href="${escapeHtml(String(href))}"${linkClassAttr}${attrs}>${childrenHTML}</a>`;
1547
+ return `<a href="${escapeHtml(String(href))}"${linkClassAttr}${attrs}${editorAttrs(ctx)}>${childrenHTML}</a>`;
1398
1548
  }
1399
1549
 
1400
1550
  /**
@@ -1435,25 +1585,47 @@ async function renderHtmlElement(
1435
1585
  ? ['style', 'className', ...imageProps]
1436
1586
  : ['style', 'className'];
1437
1587
  const attrs = buildAttributes(propsWithStyleAndAttrs, excludeProps);
1438
- const childrenHTML = Array.isArray(children)
1439
- ? (await Promise.all((children as (ComponentNode | string)[]).map((child, index) => {
1440
- const childPath = ctx.elementPath ? [...ctx.elementPath, index] : [index];
1441
- return renderNode(child, { ...ctx, elementPath: childPath });
1442
- }))).join('')
1443
- : await renderNode(children as ComponentNode, { ...ctx, elementPath: ctx.elementPath ? [...ctx.elementPath, 0] : [0] });
1588
+ // `<style>` and `<script>` are HTML "raw text" elements: their content is
1589
+ // not parsed for entities or child elements. Running text children through
1590
+ // `renderNode` (which calls `escapeHtml`) would convert `"`, `<`, `>` to
1591
+ // entities breaking CSS selectors / @font-face URLs and JS code. Emit the
1592
+ // string children verbatim instead. This path is used by the chrome-
1593
+ // extension import flow to inject `@font-face` rules captured from the
1594
+ // source page (`HtmlToMenoConverter.convertHtmlStringToMeno`).
1595
+ const isRawTextElement = tag.toLowerCase() === 'style' || tag.toLowerCase() === 'script';
1596
+ let childrenHTML: string;
1597
+ if (isRawTextElement) {
1598
+ const flatten = (node: ComponentNode | ComponentNode[] | string | number | null | undefined): string => {
1599
+ if (node == null) return '';
1600
+ if (typeof node === 'string') return node;
1601
+ if (typeof node === 'number') return String(node);
1602
+ if (Array.isArray(node)) return node.map(flatten).join('');
1603
+ return '';
1604
+ };
1605
+ childrenHTML = flatten(children as any);
1606
+ } else {
1607
+ childrenHTML = Array.isArray(children)
1608
+ ? (await Promise.all((children as (ComponentNode | string)[]).map((child, index) => {
1609
+ const childPath = ctx.elementPath ? [...ctx.elementPath, index] : [index];
1610
+ return renderNode(child, { ...ctx, elementPath: childPath });
1611
+ }))).join('')
1612
+ : await renderNode(children as ComponentNode, { ...ctx, elementPath: ctx.elementPath ? [...ctx.elementPath, 0] : [0] });
1613
+ }
1614
+
1615
+ const ea = editorAttrs(ctx);
1444
1616
 
1445
1617
  // Self-closing tags
1446
1618
  const voidElements = ['img', 'input', 'br', 'hr', 'meta', 'link', 'area', 'base', 'col', 'embed', 'source', 'track', 'wbr'];
1447
1619
  if (voidElements.includes(tag.toLowerCase())) {
1448
1620
  // Special handling for img tags - inject srcset and render as <picture> when AVIF available
1449
1621
  if (tag.toLowerCase() === 'img') {
1450
- return renderImageElement(propsWithStyleAndAttrs, classAttr, styleAttr, attrs, ctx);
1622
+ return renderImageElement(propsWithStyleAndAttrs, classAttr, styleAttr, attrs + ea, ctx);
1451
1623
  }
1452
1624
 
1453
- return `<${tag}${classAttr}${styleAttr}${attrs} />`;
1625
+ return `<${tag}${classAttr}${styleAttr}${attrs}${ea} />`;
1454
1626
  }
1455
1627
 
1456
- return `<${tag}${classAttr}${styleAttr}${attrs}>${childrenHTML}</${tag}>`;
1628
+ return `<${tag}${classAttr}${styleAttr}${attrs}${ea}>${childrenHTML}</${tag}>`;
1457
1629
  }
1458
1630
 
1459
1631
  /**
@@ -1584,10 +1756,16 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
1584
1756
  }
1585
1757
  }
1586
1758
 
1759
+ const localeStyleOpts = {
1760
+ fluidActive:
1761
+ ctx.responsiveScales?.enabled === true && ctx.responsiveScales?.mode === 'fluid',
1762
+ responsiveScales: ctx.responsiveScales,
1763
+ };
1764
+
1587
1765
  // Convert container styles to utility classes
1588
1766
  let containerClasses: string[] = [];
1589
1767
  if (nodeStyle) {
1590
- containerClasses = responsiveStylesToClasses(nodeStyle as ResponsiveStyleObject);
1768
+ containerClasses = responsiveStylesToClasses(nodeStyle as ResponsiveStyleObject, localeStyleOpts);
1591
1769
  }
1592
1770
 
1593
1771
  // Handle interactive styles for locale-list wrapper
@@ -1613,7 +1791,7 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
1613
1791
  // Convert item styles to utility classes
1614
1792
  let itemClasses: string[] = [];
1615
1793
  if (node.itemStyle) {
1616
- itemClasses = responsiveStylesToClasses(node.itemStyle as ResponsiveStyleObject);
1794
+ itemClasses = responsiveStylesToClasses(node.itemStyle as ResponsiveStyleObject, localeStyleOpts);
1617
1795
  }
1618
1796
  const itemClassAttr = itemClasses.length > 0
1619
1797
  ? ` class="${escapeHtml(itemClasses.join(' '))}"`
@@ -1622,13 +1800,13 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
1622
1800
  // Convert active item styles to utility classes
1623
1801
  let activeItemClasses: string[] = [];
1624
1802
  if (node.activeItemStyle) {
1625
- activeItemClasses = responsiveStylesToClasses(node.activeItemStyle as ResponsiveStyleObject);
1803
+ activeItemClasses = responsiveStylesToClasses(node.activeItemStyle as ResponsiveStyleObject, localeStyleOpts);
1626
1804
  }
1627
1805
 
1628
1806
  // Convert separator styles to utility classes
1629
1807
  let separatorClasses: string[] = [];
1630
1808
  if (node.separatorStyle) {
1631
- separatorClasses = responsiveStylesToClasses(node.separatorStyle as ResponsiveStyleObject);
1809
+ separatorClasses = responsiveStylesToClasses(node.separatorStyle as ResponsiveStyleObject, localeStyleOpts);
1632
1810
  }
1633
1811
  const separatorClassAttr = separatorClasses.length > 0
1634
1812
  ? ` class="${escapeHtml(separatorClasses.join(' '))}"`
@@ -1637,7 +1815,7 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
1637
1815
  // Convert flag styles to utility classes
1638
1816
  let flagClasses: string[] = [];
1639
1817
  if (node.flagStyle) {
1640
- flagClasses = responsiveStylesToClasses(node.flagStyle as ResponsiveStyleObject);
1818
+ flagClasses = responsiveStylesToClasses(node.flagStyle as ResponsiveStyleObject, localeStyleOpts);
1641
1819
  }
1642
1820
  const flagClassAttr = flagClasses.length > 0
1643
1821
  ? ` class="${escapeHtml(flagClasses.join(' '))}"`
@@ -1689,10 +1867,10 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
1689
1867
  : links.join('');
1690
1868
 
1691
1869
  // Extract attributes from node
1692
- const nodeAttributes = extractAttributesFromNode(node);
1870
+ const nodeAttributes = resolveI18nAttrs(extractAttributesFromNode(node), locale, i18nConfig);
1693
1871
  const attrsStr = buildAttributes(nodeAttributes);
1694
1872
 
1695
- const localeListResult = `<div data-locale-list="true"${containerClassAttr}${localeListStyleAttr}${attrsStr}>${linksHTML}</div>`;
1873
+ const localeListResult = `<div data-locale-list="true"${containerClassAttr}${localeListStyleAttr}${attrsStr}${editorAttrs(ctx)}>${linksHTML}</div>`;
1696
1874
 
1697
1875
  // Store SSR fallback for Astro export (locale-list nodes can't be expressed as static Astro components)
1698
1876
  if (ctx.ssrFallbackCollector && ctx.elementPath) {
@@ -1718,7 +1896,8 @@ export async function renderPageSSR(
1718
1896
  slugMappings?: SlugMap[],
1719
1897
  cmsContext?: CMSContext,
1720
1898
  cmsService?: CMSService,
1721
- isProductionBuild?: boolean
1899
+ isProductionBuild?: boolean,
1900
+ injectEditorAttrs?: boolean
1722
1901
  ): Promise<{ html: string; meta: string; title: string; javascript: string; componentCSS?: string; locale: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string>; ssrFallbackCollector: Map<string, string>; processedRawHtmlCollector: Map<string, string> }> {
1723
1902
  // Extract page content
1724
1903
  const rootNode = pageData?.root || undefined;
@@ -1756,7 +1935,7 @@ export async function renderPageSSR(
1756
1935
  // Render the component tree to HTML with i18n and CMS support
1757
1936
  // Also collect interactive styles, preload images, and needed collections during render
1758
1937
  const { html: contentHTML, interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector, processedRawHtmlCollector } = rootNode
1759
- ? await buildComponentHTML(rootNode, globalComponents, pageComponents, effectiveLocale, config, slugMappings, pagePath, cmsContext, cmsService, isProductionBuild)
1938
+ ? await buildComponentHTML(rootNode, globalComponents, pageComponents, effectiveLocale, config, slugMappings, pagePath, cmsContext, cmsService, isProductionBuild, injectEditorAttrs)
1760
1939
  : { html: '', interactiveStylesMap: new Map<string, InteractiveStyles>(), preloadImages: [], neededCollections: new Set<string>(), ssrFallbackCollector: new Map<string, string>(), processedRawHtmlCollector: new Map<string, string>() };
1761
1940
 
1762
1941
  // Collect JavaScript and CSS from all components
@@ -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
  });
@@ -103,7 +103,31 @@ export class WebSocketManager {
103
103
  collection
104
104
  });
105
105
  }
106
-
106
+
107
+ /**
108
+ * Broadcast CMS collections-list update notification.
109
+ * Emitted when a template file is added, removed, or its schema changes —
110
+ * tells connected clients to re-fetch the collections list.
111
+ */
112
+ broadcastCollectionsUpdate(): void {
113
+ this.broadcast({
114
+ type: 'hmr:cms-collections-update'
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Broadcast project.config.json update notification.
120
+ * Emitted when project.config.json changes (e.g. an AI tool adds a new
121
+ * locale) — tells connected clients to re-fetch config-derived state
122
+ * such as the i18n locale list.
123
+ */
124
+ broadcastConfigUpdate(): void {
125
+ this.broadcast({
126
+ type: 'hmr:config-update'
127
+ });
128
+ }
129
+
130
+
107
131
  /**
108
132
  * Get number of connected clients
109
133
  */