meno-core 1.0.47 → 1.0.49

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 (97) hide show
  1. package/build-astro.ts +2 -2
  2. package/dist/build-static.js +7 -7
  3. package/dist/chunks/{chunk-UUA5LEWF.js → chunk-6IVUG7FY.js} +138 -7
  4. package/dist/chunks/chunk-6IVUG7FY.js.map +7 -0
  5. package/dist/chunks/{chunk-XSWR3QLI.js → chunk-AZQYF6KE.js} +261 -130
  6. package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
  7. package/dist/chunks/{chunk-47UNLQUU.js → chunk-CHD5UCFF.js} +57 -12
  8. package/dist/chunks/chunk-CHD5UCFF.js.map +7 -0
  9. package/dist/chunks/{chunk-FGUZOYJX.js → chunk-EQYDSPBB.js} +435 -131
  10. package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
  11. package/dist/chunks/{chunk-IF3RATBY.js → chunk-H4JSCDNW.js} +2 -2
  12. package/dist/chunks/{chunk-KITQJYZV.js → chunk-J23ZX5AP.js} +40 -4
  13. package/dist/chunks/chunk-J23ZX5AP.js.map +7 -0
  14. package/dist/chunks/{chunk-LJFB5EBT.js → chunk-JER5NQVM.js} +5 -5
  15. package/dist/chunks/{chunk-ZTKHJQ2Z.js → chunk-KPU2XHOS.js} +5 -2
  16. package/dist/chunks/{chunk-ZTKHJQ2Z.js.map → chunk-KPU2XHOS.js.map} +2 -2
  17. package/dist/chunks/{chunk-BCLGRZ3U.js → chunk-LKAGAQ3M.js} +2 -2
  18. package/dist/chunks/{chunk-FED5MME6.js → chunk-S2CX6HFM.js} +262 -26
  19. package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
  20. package/dist/chunks/{configService-DYCUEURL.js → configService-CCA6AIDI.js} +3 -3
  21. package/dist/entries/server-router.js +9 -9
  22. package/dist/entries/server-router.js.map +2 -2
  23. package/dist/lib/client/index.js +64 -20
  24. package/dist/lib/client/index.js.map +3 -3
  25. package/dist/lib/server/index.js +1737 -296
  26. package/dist/lib/server/index.js.map +4 -4
  27. package/dist/lib/shared/index.js +50 -10
  28. package/dist/lib/shared/index.js.map +3 -3
  29. package/entries/server-router.tsx +6 -2
  30. package/lib/client/core/ComponentBuilder.test.ts +17 -0
  31. package/lib/client/core/ComponentBuilder.ts +25 -1
  32. package/lib/client/core/builders/embedBuilder.ts +15 -2
  33. package/lib/client/core/builders/linkNodeBuilder.ts +15 -2
  34. package/lib/client/core/builders/localeListBuilder.ts +17 -6
  35. package/lib/client/styles/StyleInjector.ts +3 -2
  36. package/lib/client/theme.ts +4 -4
  37. package/lib/server/cssGenerator.test.ts +64 -1
  38. package/lib/server/cssGenerator.ts +48 -9
  39. package/lib/server/index.ts +1 -1
  40. package/lib/server/jsonLoader.test.ts +0 -17
  41. package/lib/server/jsonLoader.ts +0 -81
  42. package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
  43. package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
  44. package/lib/server/routes/api/variables.ts +4 -2
  45. package/lib/server/routes/index.ts +1 -1
  46. package/lib/server/routes/pages.ts +23 -1
  47. package/lib/server/services/cmsService.test.ts +246 -0
  48. package/lib/server/services/cmsService.ts +122 -5
  49. package/lib/server/services/configService.ts +5 -0
  50. package/lib/server/ssr/attributeBuilder.ts +41 -0
  51. package/lib/server/ssr/htmlGenerator.test.ts +114 -2
  52. package/lib/server/ssr/htmlGenerator.ts +53 -6
  53. package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
  54. package/lib/server/ssr/ssrRenderer.test.ts +362 -1
  55. package/lib/server/ssr/ssrRenderer.ts +216 -72
  56. package/lib/server/utils/jsonLineMapper.test.ts +53 -1
  57. package/lib/server/utils/jsonLineMapper.ts +43 -3
  58. package/lib/server/webflow/buildWebflow.ts +343 -123
  59. package/lib/server/webflow/index.ts +1 -0
  60. package/lib/server/webflow/nodeToWebflow.test.ts +3170 -0
  61. package/lib/server/webflow/nodeToWebflow.ts +2141 -129
  62. package/lib/server/webflow/styleMapper.test.ts +389 -0
  63. package/lib/server/webflow/styleMapper.ts +517 -63
  64. package/lib/server/webflow/templateWrapper.ts +49 -0
  65. package/lib/server/webflow/types.ts +218 -18
  66. package/lib/shared/cssGeneration.test.ts +267 -1
  67. package/lib/shared/cssGeneration.ts +240 -18
  68. package/lib/shared/cssProperties.test.ts +247 -1
  69. package/lib/shared/cssProperties.ts +196 -6
  70. package/lib/shared/elementClassName.test.ts +15 -0
  71. package/lib/shared/elementClassName.ts +7 -3
  72. package/lib/shared/interfaces/contentProvider.ts +39 -6
  73. package/lib/shared/pathSecurity.ts +16 -0
  74. package/lib/shared/registry/nodeTypes/ListNodeType.ts +1 -1
  75. package/lib/shared/responsiveScaling.test.ts +143 -0
  76. package/lib/shared/responsiveScaling.ts +253 -2
  77. package/lib/shared/themeDefaults.test.ts +3 -3
  78. package/lib/shared/themeDefaults.ts +3 -3
  79. package/lib/shared/types/cms.ts +28 -3
  80. package/lib/shared/types/index.ts +2 -0
  81. package/lib/shared/types/variables.ts +37 -0
  82. package/lib/shared/utilityClassConfig.ts +3 -0
  83. package/lib/shared/utilityClassMapper.test.ts +123 -0
  84. package/lib/shared/utilityClassMapper.ts +179 -8
  85. package/lib/shared/validation/schemas.ts +15 -1
  86. package/lib/shared/validation/validators.ts +26 -1
  87. package/package.json +1 -1
  88. package/dist/chunks/chunk-47UNLQUU.js.map +0 -7
  89. package/dist/chunks/chunk-FED5MME6.js.map +0 -7
  90. package/dist/chunks/chunk-FGUZOYJX.js.map +0 -7
  91. package/dist/chunks/chunk-KITQJYZV.js.map +0 -7
  92. package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
  93. package/dist/chunks/chunk-XSWR3QLI.js.map +0 -7
  94. /package/dist/chunks/{chunk-IF3RATBY.js.map → chunk-H4JSCDNW.js.map} +0 -0
  95. /package/dist/chunks/{chunk-LJFB5EBT.js.map → chunk-JER5NQVM.js.map} +0 -0
  96. /package/dist/chunks/{chunk-BCLGRZ3U.js.map → chunk-LKAGAQ3M.js.map} +0 -0
  97. /package/dist/chunks/{configService-DYCUEURL.js.map → configService-CCA6AIDI.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';
@@ -20,6 +21,7 @@ import { isComponentNode, isHtmlNode, isLinkNode, isEmbedNode, isLocaleListNode,
20
21
  import type { ListNode } from '../../shared/registry/nodeTypes/ListNodeType';
21
22
  import type { CMSService } from '../services/cmsService';
22
23
  import { extractAttributesFromNode, skipEmptyTemplateAttributes } from '../../shared/attributeNodeUtils';
24
+ import { mergeNodeStyles } from '../../shared/styleNodeUtils';
23
25
  import { SSRRegistry } from '../../shared/registry/SSRRegistry';
24
26
  import { mergeResponsiveStyles } from '../../shared/responsiveStyleUtils';
25
27
  // Lazy-loaded: jsdom (used by isomorphic-dompurify on the server) can't be bundled by esbuild
@@ -49,7 +51,7 @@ import type { SlugMap } from '../../shared/slugTranslator';
49
51
  import { buildSlugIndex, getLocaleLinks, translatePath } from '../../shared/slugTranslator';
50
52
 
51
53
  // Import from modularized files
52
- import { escapeHtml, buildAttributes, styleToString } from './attributeBuilder';
54
+ import { escapeHtml, buildAttributes, buildEditorAttrs, styleToString } from './attributeBuilder';
53
55
  import { extractPageMeta, generateMetaTags } from './metaTagGenerator';
54
56
  import { collectComponentCSS } from './cssCollector';
55
57
  import { collectComponentJavaScript } from './jsCollector';
@@ -127,6 +129,30 @@ interface SSRContext {
127
129
  processedRawHtmlCollector?: Map<string, string>;
128
130
  /** Image format: 'webp' uses plain <img>, 'avif' uses <picture> with AVIF+WebP sources */
129
131
  imageFormat?: 'webp' | 'avif';
132
+ /**
133
+ * When true, emit editor-only attributes (data-element-path, data-cms-item-index,
134
+ * data-cms-context, data-component-root, data-parent-component, data-component-context)
135
+ * so XRayOverlay and click-to-select can hook into SSR-rendered DOM.
136
+ * Set only in preview/dev mode — never in production builds.
137
+ */
138
+ injectEditorAttrs?: boolean;
139
+ /** CMS list item index path (parallel to cmsListPaths) for nested list selection support */
140
+ cmsItemIndexPath?: number[];
141
+ /** CMS list element paths (parallel to cmsItemIndexPath) — outer lists first */
142
+ cmsListPaths?: number[][];
143
+ /** Name of the enclosing component instance (for data-parent-component on descendants) */
144
+ parentComponentName?: string;
145
+ /**
146
+ * Element path of the current component instance's root, in the component's local
147
+ * (reset) coordinate space. When ctx.elementPath deeply equals this, the element
148
+ * being rendered is the component root and gets data-component-root="true".
149
+ */
150
+ componentRootPath?: number[];
151
+ /**
152
+ * Responsive scales config — needed for fluid-mode style transformations
153
+ * (e.g. `applyContainerPattern` when width === maxWidth).
154
+ */
155
+ responsiveScales?: ResponsiveScales;
130
156
  }
131
157
 
132
158
  /**
@@ -137,6 +163,21 @@ function getTemplateContext(ctx: SSRContext): TemplateContext | null {
137
163
  return ctx.templateContext || null;
138
164
  }
139
165
 
166
+ /**
167
+ * Build the resolution scope for list `items` and `filter` value templates inside a
168
+ * component structure. Merge order (lowest → highest precedence):
169
+ * componentResolvedProps → templateContext (parent list loop variables)
170
+ * The cmsContext stays out of this scope — it's namespaced via {{cms.X}} and resolved
171
+ * on its own branch in getCollectionItems. Returns undefined when no scope is available
172
+ * (e.g. page root with no host component and no parent list).
173
+ */
174
+ function buildListResolutionScope(ctx: SSRContext): Record<string, unknown> | undefined {
175
+ const tplCtx = ctx.templateContext as Record<string, unknown> | undefined;
176
+ const props = ctx.componentResolvedProps;
177
+ if (!tplCtx && !props) return undefined;
178
+ return { ...(props ?? {}), ...(tplCtx ?? {}) };
179
+ }
180
+
140
181
  /**
141
182
  * Create a value resolver for i18n resolution (for use with item templates)
142
183
  */
@@ -197,7 +238,18 @@ function processStyleToClasses(
197
238
  getI18nResolver(ctx)
198
239
  ) as StyleObject | ResponsiveStyleObject;
199
240
  }
200
- return responsiveStylesToClasses(processedStyle as ResponsiveStyleObject);
241
+
242
+ // Fluid container pattern: in fluid mode `responsiveStylesToClasses` rewrites
243
+ // any tier whose `width === maxWidth` to use `calc(100% - var(--site-margin)*2)`
244
+ // with auto margins.
245
+ const fluidActive =
246
+ ctx.responsiveScales?.enabled === true &&
247
+ ctx.responsiveScales?.mode === 'fluid';
248
+
249
+ return responsiveStylesToClasses(
250
+ processedStyle as ResponsiveStyleObject,
251
+ { fluidActive, responsiveScales: ctx.responsiveScales }
252
+ );
201
253
  }
202
254
 
203
255
  /**
@@ -242,6 +294,14 @@ function evaluateIfCondition(node: ComponentNode, ctx: SSRContext): boolean {
242
294
  resolved = processCMSTemplate(resolved, ctx.cmsContext.cms, ctx.locale, ctx.i18nConfig);
243
295
  }
244
296
 
297
+ // Unresolved template (e.g. {{prop}} where the prop is undefined and
298
+ // processStructure preserved the original template per evaluateTemplate's
299
+ // backward-compat fallback) → treat as falsy so missing context means
300
+ // "don't render", matching `if: false` and unset BooleanMapping values.
301
+ if (resolved.includes('{{') && resolved.includes('}}')) {
302
+ return false;
303
+ }
304
+
245
305
  // Evaluate truthiness - false, 0, empty string are falsy
246
306
  return Boolean(resolved) && resolved !== 'false' && resolved !== '0' && resolved !== '';
247
307
  }
@@ -251,33 +311,34 @@ function evaluateIfCondition(node: ComponentNode, ctx: SSRContext): boolean {
251
311
  }
252
312
 
253
313
  /**
254
- * Resolve a template value like "{{category.slug}}" from template context.
314
+ * Resolve a template value like "{{category.slug}}" from a flat scope.
255
315
  * Returns the original value if not a template or can't be resolved.
256
316
  */
257
- function resolveFilterValue(value: unknown, templateContext: TemplateContext | undefined): unknown {
258
- if (!templateContext || typeof value !== 'string' || !value.startsWith('{{') || !value.endsWith('}}')) {
317
+ function resolveFilterValue(value: unknown, scope: Record<string, unknown> | undefined): unknown {
318
+ if (!scope || typeof value !== 'string' || !value.startsWith('{{') || !value.endsWith('}}')) {
259
319
  return value;
260
320
  }
261
321
  const path = value.slice(2, -2).trim();
262
- const resolved = getNestedValue(templateContext as Record<string, unknown>, path);
322
+ const resolved = getNestedValue(scope, path);
263
323
  return resolved !== undefined ? resolved : value;
264
324
  }
265
325
 
266
326
  /**
267
327
  * Resolve template values in CMS filter for nested cms-list support.
268
- * Handles filter values like "{{category.slug}}" from parent template context.
328
+ * Handles filter values like "{{category.slug}}" against a flat scope built from
329
+ * parent template context overlaid on host-component resolved props.
269
330
  */
270
331
  function resolveFilterTemplates(
271
332
  filter: CMSFilterCondition | CMSFilterCondition[] | Record<string, unknown> | undefined,
272
- templateContext: TemplateContext | undefined
333
+ scope: Record<string, unknown> | undefined
273
334
  ): CMSFilterCondition | CMSFilterCondition[] | Record<string, unknown> | undefined {
274
- if (!filter || !templateContext) return filter;
335
+ if (!filter || !scope) return filter;
275
336
 
276
337
  // Handle array of conditions
277
338
  if (Array.isArray(filter)) {
278
339
  return filter.map(cond => ({
279
340
  ...cond,
280
- value: resolveFilterValue(cond.value, templateContext)
341
+ value: resolveFilterValue(cond.value, scope)
281
342
  }));
282
343
  }
283
344
 
@@ -285,14 +346,14 @@ function resolveFilterTemplates(
285
346
  if ('field' in filter && 'value' in filter) {
286
347
  return {
287
348
  ...(filter as CMSFilterCondition),
288
- value: resolveFilterValue((filter as CMSFilterCondition).value, templateContext)
349
+ value: resolveFilterValue((filter as CMSFilterCondition).value, scope)
289
350
  };
290
351
  }
291
352
 
292
353
  // Handle simple object filter: { category: "{{category.slug}}" }
293
354
  const resolved: Record<string, unknown> = {};
294
355
  for (const [key, value] of Object.entries(filter)) {
295
- resolved[key] = resolveFilterValue(value, templateContext);
356
+ resolved[key] = resolveFilterValue(value, scope);
296
357
  }
297
358
  return resolved;
298
359
  }
@@ -374,7 +435,8 @@ export async function buildComponentHTML(
374
435
  pagePath?: string,
375
436
  cmsContext?: CMSContext,
376
437
  cmsService?: CMSService,
377
- isProductionBuild?: boolean
438
+ isProductionBuild?: boolean,
439
+ injectEditorAttrs?: boolean
378
440
  ): Promise<{ html: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string>; ssrFallbackCollector: Map<string, string>; processedRawHtmlCollector: Map<string, string> }> {
379
441
  // Create map to collect interactive styles during render
380
442
  const interactiveStylesMap = new Map<string, InteractiveStyles>();
@@ -421,6 +483,8 @@ export async function buildComponentHTML(
421
483
  ssrFallbackCollector, // Collect SSR fallback HTML for complex nodes
422
484
  processedRawHtmlCollector, // Collect raw→processed HTML for Astro exporter
423
485
  imageFormat: configService.getImageFormat(),
486
+ injectEditorAttrs,
487
+ responsiveScales: configService.getResponsiveScales(),
424
488
  };
425
489
 
426
490
  const html = await renderNode(node, ctx);
@@ -473,7 +537,7 @@ async function renderNestedListPlaceholder(
473
537
  const configJson = escapeHtml(JSON.stringify(config));
474
538
 
475
539
  // Emit placeholder with embedded config and template
476
- return `<div data-cms-list-nested="true" data-collection="${escapeHtml(sourceStr)}" data-cms-config="${configJson}">` +
540
+ return `<div data-cms-list-nested="true" data-collection="${escapeHtml(sourceStr)}" data-cms-config="${configJson}"${editorAttrs(ctx, { isCMSListContainer: true })}>` +
477
541
  `<template data-nested-template>${templateContent}</template>` +
478
542
  `</div>`;
479
543
  }
@@ -576,6 +640,8 @@ async function processList(node: ListNode, ctx: SSRContext): Promise<string> {
576
640
  const itemCtx: SSRContext = {
577
641
  ...ctx,
578
642
  templateContext,
643
+ cmsItemIndexPath: [...(ctx.cmsItemIndexPath ?? []), i],
644
+ cmsListPaths: [...(ctx.cmsListPaths ?? []), ctx.elementPath ?? []],
579
645
  };
580
646
 
581
647
  const childrenHtml = await renderChildrenAsync(node.children || [], itemCtx);
@@ -639,8 +705,12 @@ async function getCollectionItems(node: ListNode, source: string, ctx: SSRContex
639
705
  resolvedIds = Array.isArray(value) ? value.map(v => String(v)) : String(value);
640
706
  }
641
707
  } else {
642
- // Otherwise resolve from template context (for nested lists)
643
- const parentContext = ctx.templateContext || { _type: 'template' as const };
708
+ // Otherwise resolve from a merged scope: parent list loop variables (highest precedence)
709
+ // overlaid on the host component's resolved props (lowest precedence). This lets
710
+ // {{config.tagIds}} resolve against a component prop named `config` while {{post.X}}
711
+ // from a parent list still wins on key collisions.
712
+ const mergedScope = buildListResolutionScope(ctx) ?? {};
713
+ const parentContext = { _type: 'template' as const, ...mergedScope } as TemplateContext;
644
714
  resolvedIds = resolveItemsTemplate(node.items, parentContext);
645
715
  }
646
716
 
@@ -659,10 +729,11 @@ async function getCollectionItems(node: ListNode, source: string, ctx: SSRContex
659
729
  items = items.filter(item => !isItemDraftForLocale(item, ctx.locale!));
660
730
  }
661
731
  } else {
662
- // Build query from node props (resolve filter templates for nested list support)
732
+ // Build query from node props (resolve filter templates against parent loop variables
733
+ // overlaid on host-component resolved props, with parent loop variables winning).
663
734
  const query = {
664
735
  collection: source,
665
- filter: resolveFilterTemplates(node.filter as CMSFilterCondition | CMSFilterCondition[] | Record<string, unknown> | undefined, ctx.templateContext),
736
+ filter: resolveFilterTemplates(node.filter as CMSFilterCondition | CMSFilterCondition[] | Record<string, unknown> | undefined, buildListResolutionScope(ctx)),
666
737
  sort: node.sort,
667
738
  limit: node.limit,
668
739
  offset: node.offset,
@@ -748,11 +819,18 @@ function buildNodeElementClass(
748
819
  ? pagePath.replace(/^\//, '').replace(/\//g, '_') || 'index'
749
820
  : 'page');
750
821
 
822
+ // Slice off the component-instance prefix so class hashes stay stable across
823
+ // instances of the same component (mirrors ComponentBuilder.ts:678-680).
824
+ const rawPath = ctx.elementPath || [];
825
+ const path = useComponentContext && ctx.componentRootPath
826
+ ? rawPath.slice(ctx.componentRootPath.length)
827
+ : rawPath;
828
+
751
829
  const elementClassCtx: ElementClassContext = {
752
830
  fileType: effectiveFileType,
753
831
  fileName: effectiveFileName || 'page',
754
832
  label,
755
- path: ctx.elementPath || [],
833
+ path,
756
834
  };
757
835
  return generateElementClassName(elementClassCtx);
758
836
  }
@@ -790,6 +868,42 @@ function buildCssVariableStyleAttr(cssVariables: Record<string, string>): string
790
868
  return ` style="${escapeHtml(styleString)}"`;
791
869
  }
792
870
 
871
+ function arraysEqual(a: number[] | undefined, b: number[] | undefined): boolean {
872
+ if (!a || !b || a.length !== b.length) return false;
873
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
874
+ return true;
875
+ }
876
+
877
+ /**
878
+ * Compute editor-only attributes for the current context.
879
+ * Returns '' when injectEditorAttrs is off — preview-only feature.
880
+ */
881
+ function editorAttrs(
882
+ ctx: SSRContext,
883
+ opts: { isSlotContent?: boolean; isCMSListContainer?: boolean } = {}
884
+ ): string {
885
+ if (!ctx.injectEditorAttrs) return '';
886
+ // Component root: inside a component instance and at the path the instance was reset to.
887
+ const isComponentRoot = !!ctx.componentContext
888
+ && !opts.isSlotContent
889
+ && arraysEqual(ctx.elementPath, ctx.componentRootPath);
890
+ // Mirror client semantics: at component root, parent is the outer component;
891
+ // for descendants of a component, parent is the current component itself.
892
+ const effectiveParent = opts.isSlotContent
893
+ ? ctx.parentComponentName
894
+ : (isComponentRoot ? ctx.parentComponentName : (ctx.componentContext ?? ctx.parentComponentName));
895
+ return buildEditorAttrs({
896
+ elementPath: ctx.elementPath,
897
+ cmsItemIndexPath: ctx.cmsItemIndexPath,
898
+ cmsListPaths: ctx.cmsListPaths,
899
+ componentContext: ctx.componentContext,
900
+ parentComponentName: effectiveParent,
901
+ isComponentRoot,
902
+ isSlotContent: opts.isSlotContent,
903
+ isCMSListContainer: opts.isCMSListContainer,
904
+ });
905
+ }
906
+
793
907
  /**
794
908
  * Render a node (or array of nodes) to HTML string
795
909
  */
@@ -951,7 +1065,7 @@ async function renderNode(
951
1065
  const classAttr = classNames.length > 0 ? ` class="${escapeHtml(classNames.filter(Boolean).join(' '))}"` : '';
952
1066
 
953
1067
  // Always use span for embeds - valid inside <p> and other phrasing content
954
- return `<span${classAttr}${embedStyleAttr}${attrs}>${optimizedHtml}</span>`;
1068
+ return `<span${classAttr}${embedStyleAttr}${attrs}${editorAttrs(ctx, { isSlotContent: isSlotContent(node) })}>${optimizedHtml}</span>`;
955
1069
  }
956
1070
 
957
1071
  // Add attribute className if present (fallback when no interactive styles)
@@ -966,7 +1080,7 @@ async function renderNode(
966
1080
  const classAttr = classNames.length > 0 ? ` class="${escapeHtml(classNames.filter(Boolean).join(' '))}"` : '';
967
1081
 
968
1082
  // Always use span for embeds - valid inside <p> and other phrasing content
969
- return `<span${classAttr}${attrs}>${optimizedHtml}</span>`;
1083
+ return `<span${classAttr}${attrs}${editorAttrs(ctx, { isSlotContent: isSlotContent(node) })}>${optimizedHtml}</span>`;
970
1084
  }
971
1085
 
972
1086
  // Handle link nodes (render as <a> tag in SSR)
@@ -1061,7 +1175,7 @@ async function renderNode(
1061
1175
  }))).join('')
1062
1176
  : await renderNode(children as ComponentNode | string | number | null | undefined, { ...ctx, elementPath: ctx.elementPath ? [...ctx.elementPath, 0] : [0] });
1063
1177
 
1064
- return `<a href="${escapeHtml(String(href))}"${classAttr}${olinkStyleAttr}${attrs}>${childrenHTML}</a>`;
1178
+ return `<a href="${escapeHtml(String(href))}"${classAttr}${olinkStyleAttr}${attrs}${editorAttrs(ctx, { isSlotContent: isSlotContent(node) })}>${childrenHTML}</a>`;
1065
1179
  }
1066
1180
 
1067
1181
  // Handle locale-list node type (render locale switcher links)
@@ -1112,13 +1226,25 @@ async function renderNode(
1112
1226
  // Convert styles to utility classes instead of inline styles
1113
1227
  let utilityClasses: string[] = [];
1114
1228
  let resolvedStyle: StyleObject = {};
1229
+ // For custom component instances, defer style→class conversion: forward the
1230
+ // style object so renderComponent can deep-merge it into the structure root,
1231
+ // then let the recursive renderNode call generate classes from the merged
1232
+ // result. This matches the client (ComponentBuilder.mergeNodeStyles) so that
1233
+ // an instance override of e.g. marginBottom actually replaces the structure's
1234
+ // marginBottom instead of fighting it as a same-specificity utility class.
1235
+ const isCustomComponentNode = nodeType === NODE_TYPE.COMPONENT && isComponentNode(node) && ssrComponentRegistry.has(node.component);
1236
+ let deferredComponentStyle: StyleObject | ResponsiveStyleObject | undefined;
1115
1237
 
1116
1238
  if (nodeStyle) {
1117
- // Validate that all styles can generate utility classes (build-time warnings)
1118
- validateStyleCoverage(nodeStyle, `Node: ${nodeType || 'unknown'}`);
1239
+ if (isCustomComponentNode) {
1240
+ deferredComponentStyle = nodeStyle;
1241
+ } else {
1242
+ // Validate that all styles can generate utility classes (build-time warnings)
1243
+ validateStyleCoverage(nodeStyle, `Node: ${nodeType || 'unknown'}`);
1119
1244
 
1120
- // Convert style object to utility class names (process templates in style values)
1121
- utilityClasses = processStyleToClasses(nodeStyle, ctx);
1245
+ // Convert style object to utility class names (process templates in style values)
1246
+ utilityClasses = processStyleToClasses(nodeStyle, ctx);
1247
+ }
1122
1248
  } else if (nodeProps.style) {
1123
1249
  // If no node.style but props have style, keep it for backward compatibility
1124
1250
  if (isResponsiveStyle(nodeProps.style) && breakpoints && viewportWidth) {
@@ -1164,7 +1290,9 @@ async function renderNode(
1164
1290
  ...nodeProps,
1165
1291
  ...nodeAttributesWithoutClass,
1166
1292
  ...(mergedClassName ? { className: mergedClassName } : {}),
1167
- ...(Object.keys(resolvedStyle).length > 0 ? { style: resolvedStyle } : {})
1293
+ ...(deferredComponentStyle
1294
+ ? { style: deferredComponentStyle }
1295
+ : Object.keys(resolvedStyle).length > 0 ? { style: resolvedStyle } : {})
1168
1296
  };
1169
1297
 
1170
1298
  // Check if this is a custom component
@@ -1250,29 +1378,12 @@ async function renderComponent(
1250
1378
  rootNode.props = {};
1251
1379
  }
1252
1380
 
1253
- // For HTML and Link root nodes, merge styles into node.style (where the renderer reads them)
1254
- if (isHtmlNode(rootNode) || isLinkNode(rootNode)) {
1255
- if (propsWithStyleAndAttrs.style) {
1256
- const existingStyle = rootNode.style;
1257
- if (existingStyle && typeof existingStyle === 'object') {
1258
- rootNode.style = {
1259
- ...(existingStyle as Record<string, any>),
1260
- ...(propsWithStyleAndAttrs.style as Record<string, any>)
1261
- };
1262
- } else {
1263
- rootNode.style = propsWithStyleAndAttrs.style as StyleObject | ResponsiveStyleObject;
1264
- }
1265
- }
1266
- } else {
1267
- // For component root nodes, merge into props.style
1268
- if (rootNode.props.style && typeof rootNode.props.style === 'object') {
1269
- rootNode.props.style = {
1270
- ...(rootNode.props.style as Record<string, unknown>),
1271
- ...(propsWithStyleAndAttrs.style as Record<string, unknown> || {})
1272
- };
1273
- } else if (propsWithStyleAndAttrs.style) {
1274
- rootNode.props.style = propsWithStyleAndAttrs.style;
1275
- }
1381
+ // Deep-merge instance styles into the structure root (per-breakpoint for
1382
+ // responsive styles), so an instance override of a property actually
1383
+ // replaces the structure's value for that property instead of producing a
1384
+ // competing utility class on the same element.
1385
+ if (propsWithStyleAndAttrs.style) {
1386
+ mergeNodeStyles(rootNode, propsWithStyleAndAttrs.style as StyleObject | ResponsiveStyleObject);
1276
1387
  }
1277
1388
 
1278
1389
  // Merge className from component instance (includes responsive utility classes)
@@ -1345,13 +1456,17 @@ async function renderComponent(
1345
1456
  }
1346
1457
  }
1347
1458
 
1348
- // Render the processed component structure with component context
1349
- // Reset element path to component-relative for stable class names
1350
- // Pass resolved props for interactive style mapping resolution
1459
+ // Render the processed component structure with component context.
1460
+ // elementPath stays page-absolute (matching client ComponentBuilder.ts:874) so
1461
+ // data-element-path values match XRay's tree paths. componentRootPath captures
1462
+ // the page-level path of this instance so buildNodeElementClass can slice it
1463
+ // off to keep class hashes component-relative across instances.
1351
1464
  return await renderNode(processedStructure, {
1352
1465
  ...ctx,
1466
+ // The previously-active component (if any) becomes the parent for editor attrs
1467
+ parentComponentName: ctx.componentContext,
1353
1468
  componentContext: componentName,
1354
- elementPath: [0],
1469
+ componentRootPath: ctx.elementPath,
1355
1470
  componentResolvedProps: resolvedProps,
1356
1471
  });
1357
1472
 
@@ -1388,7 +1503,7 @@ async function renderLinkNode(
1388
1503
  }))).join('')
1389
1504
  : await renderNode(children as ComponentNode, { ...ctx, elementPath: ctx.elementPath ? [...ctx.elementPath, 0] : [0] });
1390
1505
 
1391
- return `<a href="${escapeHtml(String(href))}"${linkClassAttr}${attrs}>${childrenHTML}</a>`;
1506
+ return `<a href="${escapeHtml(String(href))}"${linkClassAttr}${attrs}${editorAttrs(ctx)}>${childrenHTML}</a>`;
1392
1507
  }
1393
1508
 
1394
1509
  /**
@@ -1429,25 +1544,47 @@ async function renderHtmlElement(
1429
1544
  ? ['style', 'className', ...imageProps]
1430
1545
  : ['style', 'className'];
1431
1546
  const attrs = buildAttributes(propsWithStyleAndAttrs, excludeProps);
1432
- const childrenHTML = Array.isArray(children)
1433
- ? (await Promise.all((children as (ComponentNode | string)[]).map((child, index) => {
1434
- const childPath = ctx.elementPath ? [...ctx.elementPath, index] : [index];
1435
- return renderNode(child, { ...ctx, elementPath: childPath });
1436
- }))).join('')
1437
- : await renderNode(children as ComponentNode, { ...ctx, elementPath: ctx.elementPath ? [...ctx.elementPath, 0] : [0] });
1547
+ // `<style>` and `<script>` are HTML "raw text" elements: their content is
1548
+ // not parsed for entities or child elements. Running text children through
1549
+ // `renderNode` (which calls `escapeHtml`) would convert `"`, `<`, `>` to
1550
+ // entities breaking CSS selectors / @font-face URLs and JS code. Emit the
1551
+ // string children verbatim instead. This path is used by the chrome-
1552
+ // extension import flow to inject `@font-face` rules captured from the
1553
+ // source page (`HtmlToMenoConverter.convertHtmlStringToMeno`).
1554
+ const isRawTextElement = tag.toLowerCase() === 'style' || tag.toLowerCase() === 'script';
1555
+ let childrenHTML: string;
1556
+ if (isRawTextElement) {
1557
+ const flatten = (node: ComponentNode | ComponentNode[] | string | number | null | undefined): string => {
1558
+ if (node == null) return '';
1559
+ if (typeof node === 'string') return node;
1560
+ if (typeof node === 'number') return String(node);
1561
+ if (Array.isArray(node)) return node.map(flatten).join('');
1562
+ return '';
1563
+ };
1564
+ childrenHTML = flatten(children as any);
1565
+ } else {
1566
+ childrenHTML = Array.isArray(children)
1567
+ ? (await Promise.all((children as (ComponentNode | string)[]).map((child, index) => {
1568
+ const childPath = ctx.elementPath ? [...ctx.elementPath, index] : [index];
1569
+ return renderNode(child, { ...ctx, elementPath: childPath });
1570
+ }))).join('')
1571
+ : await renderNode(children as ComponentNode, { ...ctx, elementPath: ctx.elementPath ? [...ctx.elementPath, 0] : [0] });
1572
+ }
1573
+
1574
+ const ea = editorAttrs(ctx);
1438
1575
 
1439
1576
  // Self-closing tags
1440
1577
  const voidElements = ['img', 'input', 'br', 'hr', 'meta', 'link', 'area', 'base', 'col', 'embed', 'source', 'track', 'wbr'];
1441
1578
  if (voidElements.includes(tag.toLowerCase())) {
1442
1579
  // Special handling for img tags - inject srcset and render as <picture> when AVIF available
1443
1580
  if (tag.toLowerCase() === 'img') {
1444
- return renderImageElement(propsWithStyleAndAttrs, classAttr, styleAttr, attrs, ctx);
1581
+ return renderImageElement(propsWithStyleAndAttrs, classAttr, styleAttr, attrs + ea, ctx);
1445
1582
  }
1446
1583
 
1447
- return `<${tag}${classAttr}${styleAttr}${attrs} />`;
1584
+ return `<${tag}${classAttr}${styleAttr}${attrs}${ea} />`;
1448
1585
  }
1449
1586
 
1450
- return `<${tag}${classAttr}${styleAttr}${attrs}>${childrenHTML}</${tag}>`;
1587
+ return `<${tag}${classAttr}${styleAttr}${attrs}${ea}>${childrenHTML}</${tag}>`;
1451
1588
  }
1452
1589
 
1453
1590
  /**
@@ -1578,10 +1715,16 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
1578
1715
  }
1579
1716
  }
1580
1717
 
1718
+ const localeStyleOpts = {
1719
+ fluidActive:
1720
+ ctx.responsiveScales?.enabled === true && ctx.responsiveScales?.mode === 'fluid',
1721
+ responsiveScales: ctx.responsiveScales,
1722
+ };
1723
+
1581
1724
  // Convert container styles to utility classes
1582
1725
  let containerClasses: string[] = [];
1583
1726
  if (nodeStyle) {
1584
- containerClasses = responsiveStylesToClasses(nodeStyle as ResponsiveStyleObject);
1727
+ containerClasses = responsiveStylesToClasses(nodeStyle as ResponsiveStyleObject, localeStyleOpts);
1585
1728
  }
1586
1729
 
1587
1730
  // Handle interactive styles for locale-list wrapper
@@ -1607,7 +1750,7 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
1607
1750
  // Convert item styles to utility classes
1608
1751
  let itemClasses: string[] = [];
1609
1752
  if (node.itemStyle) {
1610
- itemClasses = responsiveStylesToClasses(node.itemStyle as ResponsiveStyleObject);
1753
+ itemClasses = responsiveStylesToClasses(node.itemStyle as ResponsiveStyleObject, localeStyleOpts);
1611
1754
  }
1612
1755
  const itemClassAttr = itemClasses.length > 0
1613
1756
  ? ` class="${escapeHtml(itemClasses.join(' '))}"`
@@ -1616,13 +1759,13 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
1616
1759
  // Convert active item styles to utility classes
1617
1760
  let activeItemClasses: string[] = [];
1618
1761
  if (node.activeItemStyle) {
1619
- activeItemClasses = responsiveStylesToClasses(node.activeItemStyle as ResponsiveStyleObject);
1762
+ activeItemClasses = responsiveStylesToClasses(node.activeItemStyle as ResponsiveStyleObject, localeStyleOpts);
1620
1763
  }
1621
1764
 
1622
1765
  // Convert separator styles to utility classes
1623
1766
  let separatorClasses: string[] = [];
1624
1767
  if (node.separatorStyle) {
1625
- separatorClasses = responsiveStylesToClasses(node.separatorStyle as ResponsiveStyleObject);
1768
+ separatorClasses = responsiveStylesToClasses(node.separatorStyle as ResponsiveStyleObject, localeStyleOpts);
1626
1769
  }
1627
1770
  const separatorClassAttr = separatorClasses.length > 0
1628
1771
  ? ` class="${escapeHtml(separatorClasses.join(' '))}"`
@@ -1631,7 +1774,7 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
1631
1774
  // Convert flag styles to utility classes
1632
1775
  let flagClasses: string[] = [];
1633
1776
  if (node.flagStyle) {
1634
- flagClasses = responsiveStylesToClasses(node.flagStyle as ResponsiveStyleObject);
1777
+ flagClasses = responsiveStylesToClasses(node.flagStyle as ResponsiveStyleObject, localeStyleOpts);
1635
1778
  }
1636
1779
  const flagClassAttr = flagClasses.length > 0
1637
1780
  ? ` class="${escapeHtml(flagClasses.join(' '))}"`
@@ -1686,7 +1829,7 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
1686
1829
  const nodeAttributes = extractAttributesFromNode(node);
1687
1830
  const attrsStr = buildAttributes(nodeAttributes);
1688
1831
 
1689
- const localeListResult = `<div data-locale-list="true"${containerClassAttr}${localeListStyleAttr}${attrsStr}>${linksHTML}</div>`;
1832
+ const localeListResult = `<div data-locale-list="true"${containerClassAttr}${localeListStyleAttr}${attrsStr}${editorAttrs(ctx)}>${linksHTML}</div>`;
1690
1833
 
1691
1834
  // Store SSR fallback for Astro export (locale-list nodes can't be expressed as static Astro components)
1692
1835
  if (ctx.ssrFallbackCollector && ctx.elementPath) {
@@ -1712,7 +1855,8 @@ export async function renderPageSSR(
1712
1855
  slugMappings?: SlugMap[],
1713
1856
  cmsContext?: CMSContext,
1714
1857
  cmsService?: CMSService,
1715
- isProductionBuild?: boolean
1858
+ isProductionBuild?: boolean,
1859
+ injectEditorAttrs?: boolean
1716
1860
  ): 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> }> {
1717
1861
  // Extract page content
1718
1862
  const rootNode = pageData?.root || undefined;
@@ -1750,7 +1894,7 @@ export async function renderPageSSR(
1750
1894
  // Render the component tree to HTML with i18n and CMS support
1751
1895
  // Also collect interactive styles, preload images, and needed collections during render
1752
1896
  const { html: contentHTML, interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector, processedRawHtmlCollector } = rootNode
1753
- ? await buildComponentHTML(rootNode, globalComponents, pageComponents, effectiveLocale, config, slugMappings, pagePath, cmsContext, cmsService, isProductionBuild)
1897
+ ? await buildComponentHTML(rootNode, globalComponents, pageComponents, effectiveLocale, config, slugMappings, pagePath, cmsContext, cmsService, isProductionBuild, injectEditorAttrs)
1754
1898
  : { html: '', interactiveStylesMap: new Map<string, InteractiveStyles>(), preloadImages: [], neededCollections: new Set<string>(), ssrFallbackCollector: new Map<string, string>(), processedRawHtmlCollector: new Map<string, string>() };
1755
1899
 
1756
1900
  // Collect JavaScript and CSS from all components
@@ -70,7 +70,27 @@ describe("jsonLineMapper", () => {
70
70
  }`;
71
71
 
72
72
  const lineMap = buildLineMap(json);
73
- expect(lineMap.size).toBe(0);
73
+ // Only the root entry (empty-string key) is recorded; no children.
74
+ expect(lineMap.size).toBe(1);
75
+ expect(lineMap.get("")).toBeDefined();
76
+ });
77
+
78
+ test("records the root's own line range under the empty-string key", () => {
79
+ const json = `{
80
+ "root": {
81
+ "type": "node",
82
+ "children": [
83
+ {
84
+ "type": "node",
85
+ "tag": "div"
86
+ }
87
+ ]
88
+ }
89
+ }`;
90
+
91
+ const lineMap = buildLineMap(json);
92
+ // Root value starts at line 2 and closes at line 10.
93
+ expect(lineMap.get("")).toEqual({ startLine: 2, endLine: 10 });
74
94
  });
75
95
 
76
96
  test("should handle JSON without root", () => {
@@ -83,6 +103,38 @@ describe("jsonLineMapper", () => {
83
103
  const lineMap = buildLineMap(json);
84
104
  expect(lineMap.size).toBe(0);
85
105
  });
106
+
107
+ test("tracks component format via component.structure", () => {
108
+ const json = `{
109
+ "component": {
110
+ "interface": {
111
+ "size": { "type": "text" }
112
+ },
113
+ "structure": {
114
+ "type": "node",
115
+ "tag": "h1",
116
+ "children": [
117
+ {
118
+ "type": "node",
119
+ "tag": "span"
120
+ },
121
+ {
122
+ "type": "component",
123
+ "component": "Icon"
124
+ }
125
+ ]
126
+ }
127
+ }
128
+ }`;
129
+
130
+ const lineMap = buildLineMap(json);
131
+ // Structure spans from its opening `{` (line 6) to its closing `}` (line 19).
132
+ expect(lineMap.get("")).toEqual({ startLine: 6, endLine: 19 });
133
+ // First child: the <span>.
134
+ expect(lineMap.get("0")?.startLine).toBe(10);
135
+ // Second child: the Icon component instance.
136
+ expect(lineMap.get("1")?.startLine).toBe(14);
137
+ });
86
138
  });
87
139
 
88
140
  describe("lineMapToObject", () => {