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
@@ -7,6 +7,7 @@ import type { CMSProvider, CMSSchemaInfo } from '../../shared/interfaces/content
7
7
  import type {
8
8
  CMSSchema,
9
9
  CMSItem,
10
+ CMSItemVersions,
10
11
  CMSRouteMatch,
11
12
  CMSListQuery,
12
13
  CMSFilterCondition,
@@ -30,6 +31,19 @@ interface CachedItems {
30
31
  timestamp: number;
31
32
  }
32
33
 
34
+ export interface CMSServiceOptions {
35
+ /**
36
+ * When true, the SSR-facing read methods (`queryItems`, `getItemsByIds`,
37
+ * `matchRoute`) merge drafts over published content so the editor preview /
38
+ * dev server reflects unpublished edits. The editor management methods
39
+ * (`getItemVersions`, `listItemsWithDraftFlag`) keep strict published
40
+ * semantics regardless of this flag — they need to surface both versions
41
+ * separately. Static builds and production runtimes leave this off so
42
+ * deployed sites only ever serve published content.
43
+ */
44
+ previewMode?: boolean;
45
+ }
46
+
33
47
  /**
34
48
  * CMS Service
35
49
  * Manages CMS schemas, route matching, and content querying
@@ -38,6 +52,7 @@ export class CMSService {
38
52
  private schemaCache = new Map<string, CMSSchemaInfo>();
39
53
  private routePatterns: RoutePattern[] = [];
40
54
  private provider?: CMSProvider;
55
+ private readonly previewMode: boolean;
41
56
 
42
57
  /** Item cache with TTL-based expiration */
43
58
  private itemsCache = new Map<string, CachedItems>();
@@ -47,9 +62,11 @@ export class CMSService {
47
62
  /**
48
63
  * Creates a new CMSService instance
49
64
  * @param provider - Optional CMSProvider for loading data (enables DI for testing)
65
+ * @param options - Service-level flags (preview mode for dev server)
50
66
  */
51
- constructor(provider?: CMSProvider) {
67
+ constructor(provider?: CMSProvider, options: CMSServiceOptions = {}) {
52
68
  this.provider = provider;
69
+ this.previewMode = options.previewMode === true;
53
70
  }
54
71
 
55
72
  /**
@@ -62,9 +79,14 @@ export class CMSService {
62
79
  }
63
80
 
64
81
  /**
65
- * Get items with caching
66
- * Returns cached items if available and not expired, otherwise fetches fresh data
67
- * Rich-text fields are automatically preprocessed to HTML for template interpolation
82
+ * Get items with caching, used by SSR-facing read methods.
83
+ *
84
+ * In preview mode (dev server) drafts are merged over published — drafts
85
+ * win on a per-`_filename` basis and draft-only items are included — so
86
+ * the editor preview reflects unpublished edits. Returns published-only
87
+ * otherwise. Rich-text fields are preprocessed to HTML for template
88
+ * interpolation either way.
89
+ *
68
90
  * @param collection - Collection ID to fetch items for
69
91
  * @returns Array of CMSItems with rich-text fields converted to HTML markers
70
92
  */
@@ -78,7 +100,21 @@ export class CMSService {
78
100
  }
79
101
 
80
102
  // Fetch fresh items
81
- const rawItems = await this.provider!.getItems(collection);
103
+ let rawItems = await this.provider!.getItems(collection);
104
+
105
+ if (this.previewMode) {
106
+ const drafts = await this.provider!.getAllDrafts(collection);
107
+ if (drafts.length > 0) {
108
+ const byFilename = new Map<string, CMSItem>();
109
+ for (const item of rawItems) {
110
+ if (item._filename) byFilename.set(item._filename, item);
111
+ }
112
+ for (const draft of drafts) {
113
+ if (draft._filename) byFilename.set(draft._filename, draft);
114
+ }
115
+ rawItems = Array.from(byFilename.values());
116
+ }
117
+ }
82
118
 
83
119
  // Preprocess rich-text fields for template interpolation
84
120
  const items = this.preprocessRichTextFields(collection, rawItems);
@@ -405,6 +441,87 @@ export class CMSService {
405
441
  * Only clears items cache and provider cache before re-initializing.
406
442
  * Schema/route caches are swapped atomically inside initialize().
407
443
  */
444
+ // ----------------------------------------------------------------------
445
+ // Draft-version methods (Studio-only — never used by SSR / static export)
446
+ // ----------------------------------------------------------------------
447
+
448
+ /**
449
+ * Load both published and draft versions of an item, used by the editor.
450
+ * Returns `{}` when neither version exists.
451
+ */
452
+ async getItemVersions(collection: string, filename: string): Promise<CMSItemVersions> {
453
+ if (!this.provider) return {};
454
+ const [published, draft] = await Promise.all([
455
+ this.provider.getItemByFilename(collection, filename),
456
+ this.provider.getDraft(collection, filename),
457
+ ]);
458
+ const result: CMSItemVersions = {};
459
+ if (published) result.published = published;
460
+ if (draft) result.draft = draft;
461
+ return result;
462
+ }
463
+
464
+ /**
465
+ * List all items in a collection for the Studio item list:
466
+ * - Published items annotated with `_hasDraft: true` when a draft sibling exists.
467
+ * - Draft-only items (no published file yet) returned with `_isDraft: true`.
468
+ */
469
+ async listItemsWithDraftFlag(collection: string): Promise<CMSItem[]> {
470
+ if (!this.provider) return [];
471
+
472
+ const [published, drafts] = await Promise.all([
473
+ this.provider.getItems(collection),
474
+ this.provider.getAllDrafts(collection),
475
+ ]);
476
+
477
+ const draftFilenames = new Set(drafts.map(d => d._filename).filter(Boolean) as string[]);
478
+
479
+ const annotatedPublished: CMSItem[] = published.map(item => {
480
+ if (item._filename && draftFilenames.has(item._filename)) {
481
+ return { ...item, _hasDraft: true };
482
+ }
483
+ return item;
484
+ });
485
+
486
+ const publishedFilenames = new Set(published.map(i => i._filename).filter(Boolean) as string[]);
487
+ const draftOnly = drafts.filter(d => d._filename && !publishedFilenames.has(d._filename));
488
+
489
+ return [...annotatedPublished, ...draftOnly];
490
+ }
491
+
492
+ /** Pass-through to provider; no caching (drafts are read fresh in the editor). */
493
+ async getDraft(collection: string, filename: string): Promise<CMSItem | null> {
494
+ if (!this.provider) return null;
495
+ return this.provider.getDraft(collection, filename);
496
+ }
497
+
498
+ async hasDraft(collection: string, filename: string): Promise<boolean> {
499
+ if (!this.provider) return false;
500
+ return this.provider.hasDraft(collection, filename);
501
+ }
502
+
503
+ async saveDraft(collection: string, item: CMSItem): Promise<void> {
504
+ if (!this.provider) throw new Error('CMS provider not configured');
505
+ await this.provider.saveDraft(collection, item);
506
+ // Drafts don't enter the published items cache, but we invalidate it anyway
507
+ // so listItemsWithDraftFlag reflects the new draft on the next read.
508
+ this.itemsCache.delete(collection);
509
+ }
510
+
511
+ async discardDraft(collection: string, filename: string): Promise<void> {
512
+ if (!this.provider) throw new Error('CMS provider not configured');
513
+ await this.provider.discardDraft(collection, filename);
514
+ this.itemsCache.delete(collection);
515
+ }
516
+
517
+ async publishDraft(collection: string, filename: string): Promise<CMSItem> {
518
+ if (!this.provider) throw new Error('CMS provider not configured');
519
+ const item = await this.provider.publishDraft(collection, filename);
520
+ // Promotion changes the published file → invalidate cached items.
521
+ this.itemsCache.delete(collection);
522
+ return item;
523
+ }
524
+
408
525
  async refreshSchemas(): Promise<void> {
409
526
  if (!this.provider) {
410
527
  return;
@@ -175,7 +175,12 @@ export class ConfigService {
175
175
 
176
176
  return {
177
177
  enabled: userScales.enabled ?? DEFAULT_RESPONSIVE_SCALES.enabled,
178
+ mode: (userScales as { mode?: 'breakpoints' | 'fluid' }).mode ?? DEFAULT_RESPONSIVE_SCALES.mode,
178
179
  baseReference: userScales.baseReference ?? DEFAULT_RESPONSIVE_SCALES.baseReference,
180
+ fluidRange: (userScales as { fluidRange?: { min: number; max: number } }).fluidRange
181
+ ?? (DEFAULT_RESPONSIVE_SCALES.fluidRange ? { ...DEFAULT_RESPONSIVE_SCALES.fluidRange } : undefined),
182
+ siteMargin: (userScales as { siteMargin?: { min: number; max: number } }).siteMargin
183
+ ?? (DEFAULT_RESPONSIVE_SCALES.siteMargin ? { ...DEFAULT_RESPONSIVE_SCALES.siteMargin } : undefined),
179
184
  fontSize: this.mergeScaleCategory(
180
185
  userScales.fontSize as BreakpointScales | undefined,
181
186
  DEFAULT_RESPONSIVE_SCALES.fontSize
@@ -75,6 +75,47 @@ export function buildAttributes(props: Record<string, unknown>, exclude: string[
75
75
  return attrs.length > 0 ? ' ' + attrs.join(' ') : '';
76
76
  }
77
77
 
78
+ /**
79
+ * Build the editor-only attribute string used by XRay and selection sync.
80
+ * Mirrors what client-side ComponentBuilder.ts writes via ref callbacks.
81
+ * Returns a leading-space-prefixed attribute string, or '' when there's
82
+ * nothing to emit (or no elementPath supplied).
83
+ */
84
+ export function buildEditorAttrs(opts: {
85
+ elementPath?: number[];
86
+ cmsItemIndexPath?: number[];
87
+ cmsListPaths?: number[][];
88
+ componentContext?: string;
89
+ parentComponentName?: string;
90
+ isComponentRoot?: boolean;
91
+ isSlotContent?: boolean;
92
+ isCMSListContainer?: boolean;
93
+ }): string {
94
+ const { elementPath } = opts;
95
+ if (!elementPath) return '';
96
+
97
+ const parts: string[] = [`data-element-path="${escapeHtml(elementPath.join(','))}"`];
98
+
99
+ if (opts.cmsItemIndexPath && opts.cmsItemIndexPath.length > 0) {
100
+ parts.push(`data-cms-item-index="${escapeHtml(opts.cmsItemIndexPath.join('.'))}"`);
101
+ if (opts.cmsListPaths && opts.cmsListPaths.length === opts.cmsItemIndexPath.length) {
102
+ const ctx = JSON.stringify({ itemIndexPath: opts.cmsItemIndexPath, listPaths: opts.cmsListPaths });
103
+ parts.push(`data-cms-context="${escapeHtml(ctx)}"`);
104
+ }
105
+ }
106
+
107
+ if (opts.isCMSListContainer) parts.push(`data-cms-list="true"`);
108
+ if (opts.isComponentRoot) parts.push(`data-component-root="true"`);
109
+ if (opts.parentComponentName) {
110
+ parts.push(`data-parent-component="${escapeHtml(opts.parentComponentName)}"`);
111
+ }
112
+ if (opts.componentContext && !opts.isSlotContent) {
113
+ parts.push(`data-component-context="${escapeHtml(opts.componentContext)}"`);
114
+ }
115
+
116
+ return ' ' + parts.join(' ');
117
+ }
118
+
78
119
  /**
79
120
  * Convert a style object to inline CSS string
80
121
  * Handles CSS variables (--is-0: value) and regular properties
@@ -27,6 +27,10 @@ mock.module('./ssrRenderer', () => ({
27
27
  const mockConfigService = {
28
28
  load: mock(async () => {}),
29
29
  getLibraries: mock(() => undefined),
30
+ getResponsiveScales: mock(() => ({ enabled: false, baseReference: 16 })),
31
+ getCustomCode: mock(() => ({})),
32
+ getShowMenoBadge: mock(() => false),
33
+ getRemConversion: mock(() => ({ enabled: false, baseFontSize: 16 })),
30
34
  };
31
35
 
32
36
  mock.module('../services/configService', () => ({
@@ -34,13 +38,11 @@ mock.module('../services/configService', () => ({
34
38
  }));
35
39
 
36
40
  const mockLoadBreakpointConfig = mock(async () => ({ breakpoints: [] }));
37
- const mockLoadResponsiveScalesConfig = mock(async () => ({}));
38
41
  const mockLoadIconsConfig = mock(async () => ({ favicon: '', appleTouchIcon: '' }));
39
42
  const mockLoadPrefetchConfig = mock(async () => ({ enabled: false }));
40
43
 
41
44
  mock.module('../jsonLoader', () => ({
42
45
  loadBreakpointConfig: mockLoadBreakpointConfig,
43
- loadResponsiveScalesConfig: mockLoadResponsiveScalesConfig,
44
46
  loadIconsConfig: mockLoadIconsConfig,
45
47
  loadPrefetchConfig: mockLoadPrefetchConfig,
46
48
  }));
@@ -674,6 +676,116 @@ describe('generateSSRHTML', () => {
674
676
  const result = (await generateSSRHTML(minimalPage)) as string;
675
677
  expect(result).not.toContain('GET_SCROLL_POSITION');
676
678
  });
679
+
680
+ // Content-aware hotReload: each section should only be rebuilt when its
681
+ // serialized content actually differs from the freshly-fetched HTML. The
682
+ // `/_scripts/{hash}.js` URL is content-addressed, so an unchanged hash is
683
+ // the explicit signal that user JS has not changed — in that case the
684
+ // script tag is left in place and DOMContentLoaded is NOT re-dispatched,
685
+ // which is what preserves runtime JS state (e.g. open dropdowns) across
686
+ // pure style edits in the editor.
687
+ describe('hotReload is content-aware', () => {
688
+ test('compares meno-styles textContent before replacing', async () => {
689
+ const result = (await generateSSRHTML({
690
+ pageData: minimalPage as any,
691
+ injectLiveReload: true,
692
+ })) as string;
693
+ expect(result).toContain('os.textContent!==ns.textContent');
694
+ });
695
+
696
+ test('compares /_scripts/ src (cache-buster stripped) before reloading', async () => {
697
+ const result = (await generateSSRHTML({
698
+ pageData: minimalPage as any,
699
+ injectLiveReload: true,
700
+ })) as string;
701
+ // strip() removes ?_r=... so reloads triggered only by content-hash change
702
+ expect(result).toContain('strip(oscr.getAttribute');
703
+ expect(result).toContain('strip(nscr.getAttribute');
704
+ expect(result).toContain('oss===nss');
705
+ });
706
+
707
+ test('falls back to innerHTML replace only on structural changes', async () => {
708
+ const result = (await generateSSRHTML({
709
+ pageData: minimalPage as any,
710
+ injectLiveReload: true,
711
+ })) as string;
712
+ // smartUpdate's fallback branch (different element counts or
713
+ // unmatched data-element-path) is the only place innerHTML is touched.
714
+ expect(result).toContain('curR.innerHTML!==srvR.innerHTML');
715
+ });
716
+
717
+ test('patches #root in place via smartUpdate (not innerHTML replace)', async () => {
718
+ const result = (await generateSSRHTML({
719
+ pageData: minimalPage as any,
720
+ injectLiveReload: true,
721
+ })) as string;
722
+ // smartUpdate is what runs on every hot reload; it walks the tree by
723
+ // data-element-path and updates attrs in place so event handlers and
724
+ // DOM identity survive.
725
+ expect(result).toContain('smartUpdate(or,nr,lastSrvRoot)');
726
+ expect(result).toContain('querySelectorAll(\'[data-element-path]\')');
727
+ });
728
+
729
+ test('preserves runtime-added classes against cached server snapshot', async () => {
730
+ const result = (await generateSSRHTML({
731
+ pageData: minimalPage as any,
732
+ injectLiveReload: true,
733
+ })) as string;
734
+ // Runtime classes = current classes that weren't in the previous
735
+ // server snapshot. They get re-applied on top of the new server class
736
+ // list so JS-controlled state (e.g. `nav-dropdown-open`) survives.
737
+ expect(result).toContain('cc.filter(function(c){return !oc.has(c)})');
738
+ });
739
+
740
+ test('caches root snapshot at script init for first-HMR class diff', async () => {
741
+ const result = (await generateSSRHTML({
742
+ pageData: minimalPage as any,
743
+ injectLiveReload: true,
744
+ })) as string;
745
+ // Without this seed the very first HMR has no oldRoot to diff against,
746
+ // so any class JS added before the first edit would be wiped.
747
+ expect(result).toContain("var iR=document.getElementById('root');if(iR)lastSrvRoot=iR.cloneNode(true)");
748
+ });
749
+
750
+ test('preserves server-side attribute removals (does not treat them as runtime)', async () => {
751
+ const result = (await generateSSRHTML({
752
+ pageData: minimalPage as any,
753
+ injectLiveReload: true,
754
+ })) as string;
755
+ // For non-class attrs: only remove from current DOM if the attr was
756
+ // present in the previous server snapshot — that way we don't strip
757
+ // runtime-added attrs (e.g. `data-nav-dropdown-initialized`).
758
+ expect(result).toContain('!srv.hasAttribute(a.name)&&cur.hasAttribute(a.name)');
759
+ });
760
+
761
+ test('compares CMS inline scripts before replacing', async () => {
762
+ const result = (await generateSSRHTML({
763
+ pageData: minimalPage as any,
764
+ injectLiveReload: true,
765
+ })) as string;
766
+ expect(result).toContain('script[id^="meno-cms-"]');
767
+ expect(result).toContain('ock!==nck');
768
+ });
769
+
770
+ test('compares /libraries/ script srcs before replacing', async () => {
771
+ const result = (await generateSSRHTML({
772
+ pageData: minimalPage as any,
773
+ injectLiveReload: true,
774
+ })) as string;
775
+ expect(result).toContain('script[src^="/libraries/"]');
776
+ expect(result).toContain('olk!==nlk');
777
+ });
778
+
779
+ test('does NOT unconditionally remove and re-append /_scripts/ tag', async () => {
780
+ const result = (await generateSSRHTML({
781
+ pageData: minimalPage as any,
782
+ injectLiveReload: true,
783
+ })) as string;
784
+ // Must not contain the old unconditional pattern where oscr is removed
785
+ // outside of a hash-mismatch branch.
786
+ expect(result).not.toContain('if(nscr){var src=nscr.getAttribute(\'src\');if(oscr)oscr.remove()');
787
+ });
788
+ });
677
789
  });
678
790
 
679
791
  describe('Options object vs legacy positional args', () => {
@@ -8,7 +8,7 @@ import type { ComponentDefinition, JSONPage, PageLibrariesConfig, CustomCodeConf
8
8
  import type { SlugMap } from '../../shared/slugTranslator';
9
9
  import type { CMSService } from '../services/cmsService';
10
10
  import { configService } from '../services/configService';
11
- import { loadBreakpointConfig, loadResponsiveScalesConfig, loadIconsConfig, loadPrefetchConfig } from '../jsonLoader';
11
+ import { loadBreakpointConfig, loadIconsConfig, loadPrefetchConfig } from '../jsonLoader';
12
12
  import { generateFontCSS, generateFontPreloadTags, loadProjectConfig } from '../../shared/fontLoader';
13
13
  import { colorService } from '../services/ColorService';
14
14
  import { generateThemeColorVariablesCSS, generateVariablesCSS } from '../cssGenerator';
@@ -47,8 +47,16 @@ function minifyCSS(code: string): string {
47
47
  return code
48
48
  // Remove CSS comments
49
49
  .replace(/\/\*[\s\S]*?\*\//g, '')
50
- // Remove whitespace around special characters
51
- .replace(/\s*([{};:,>~+])\s*/g, '$1')
50
+ // Remove whitespace around block / declaration delimiters and selector
51
+ // combinators. We deliberately exclude `+` and `-` from this set: they
52
+ // are selector combinators (e.g. `a + b`, but `~` is also rare; siblings)
53
+ // AND arithmetic operators inside `calc()` / `clamp()` / `min()` / `max()`,
54
+ // where they REQUIRE surrounding spaces. Stripping there produces invalid
55
+ // CSS like `padding-top: clamp(.5*16px,((.5 - .5)/(90 - 20))*16px+(...))`,
56
+ // which the browser silently drops, collapsing paddings/margins to 0.
57
+ // The cost of leaving spaces around `+` selectors is a few extra bytes;
58
+ // the cost of stripping them is broken layout.
59
+ .replace(/\s*([{};:,>~])\s*/g, '$1')
52
60
  // Collapse multiple spaces/newlines into single space
53
61
  .replace(/\s+/g, ' ')
54
62
  // Remove space after opening brace
@@ -126,6 +134,15 @@ export interface GenerateSSRHTMLOptions {
126
134
  * When true, adds a WebSocket client that reloads the page on HMR messages.
127
135
  */
128
136
  injectLiveReload?: boolean;
137
+ /**
138
+ * Emit selection-tracking attributes (data-element-path, data-cms-item-index,
139
+ * data-cms-context, data-component-root, data-parent-component, data-component-context)
140
+ * so XRayOverlay and click-to-select can hook into the SSR DOM.
141
+ * Should ONLY be true when the request originates from the editor preview iframe
142
+ * (the studio's /__static__/ proxy sends an `x-meno-editor` header).
143
+ * Direct browser access to the SSR preview server must produce clean output.
144
+ */
145
+ injectEditorAttrs?: boolean;
129
146
  /** Whether this is the visual editor (studio). Affects library filtering. */
130
147
  isEditor?: boolean;
131
148
  /** Actual bound server port for live reload WS (connects directly to SSR server) */
@@ -214,11 +231,16 @@ export async function generateSSRHTML(
214
231
  pageCustomCode,
215
232
  clientDataCollections,
216
233
  injectLiveReload = false,
234
+ injectEditorAttrs = false,
217
235
  isEditor = false,
218
236
  isProductionBuild = false,
219
237
  serverPort,
220
238
  } = options;
221
- const rendered = await renderPageSSR(pageData, components, path, base, loc, undefined, slugs, cms, cmsServ, isProductionBuild);
239
+ // Editor selection attributes (data-element-path, data-cms-context, ...) are gated
240
+ // on injectEditorAttrs — set ONLY when the request comes from the editor preview iframe
241
+ // via the studio's /__static__/ proxy (which sends an `x-meno-editor` header).
242
+ // Direct hits to the SSR preview server (e.g., http://localhost:8082/) get clean output.
243
+ const rendered = await renderPageSSR(pageData, components, path, base, loc, undefined, slugs, cms, cmsServ, isProductionBuild, injectEditorAttrs);
222
244
 
223
245
  // Auto-inject data for nested collections (detected during SSR)
224
246
  // This enables client-side hydration for nested cms-list placeholders
@@ -378,7 +400,7 @@ export async function generateSSRHTML(
378
400
  // Extract and generate utility CSS from rendered HTML
379
401
  const usedUtilityClasses = extractUtilityClassesFromHTML(rendered.html);
380
402
  const breakpointConfig = await loadBreakpointConfig();
381
- const responsiveScalesConfig = await loadResponsiveScalesConfig();
403
+ const responsiveScalesConfig = configService.getResponsiveScales();
382
404
 
383
405
  // Load and generate CSS custom properties from variables.json
384
406
  const variablesConfig = await variableService.loadConfig();
@@ -489,8 +511,33 @@ picture {
489
511
  const wsUrl = serverPort
490
512
  ? `'ws://localhost:${serverPort}/hmr'`
491
513
  : `location.origin.replace('http','ws')+'/hmr'`;
514
+ // True hot reload for the studio's static preview iframe.
515
+ //
516
+ // hotReload() is content-aware on every level — sections are rebuilt only
517
+ // when their serialized content actually differs from the freshly fetched
518
+ // HTML, and #root is patched in place via smartUpdate() instead of being
519
+ // wholesale-replaced. That means:
520
+ // * DOM elements keep their identity across edits, so attached event
521
+ // listeners (e.g. NavDropdown's click handler) survive.
522
+ // * Classes/attributes that user JS added at runtime (e.g.
523
+ // `nav-dropdown-open`) are preserved by diffing the live DOM against
524
+ // a cached snapshot of the previous server HTML — anything in current
525
+ // that wasn't in that snapshot is treated as a runtime addition and
526
+ // re-applied on top of the new server attrs.
527
+ // * The `/_scripts/{hash}.js` URL is content-addressed (see
528
+ // scriptCache.hashContent), so an unchanged hash means user JS hasn't
529
+ // changed — we skip both the script reload and the DOMContentLoaded
530
+ // re-dispatch, leaving JS module state (closures, isOpen flags, etc.)
531
+ // intact.
532
+ //
533
+ // Element identity for the smartUpdate() walk relies on
534
+ // `data-element-path`, which the SSR pipeline emits on every element when
535
+ // the request comes through the studio's `/__static__/` proxy (which sets
536
+ // `x-meno-editor: 1`). On structural changes that smartUpdate can't safely
537
+ // diff (different element counts or unknown paths) it falls back to a
538
+ // straight innerHTML replace.
492
539
  const liveReloadScript = injectLiveReload
493
- ? `<script>(function(){var ws,timer,gen=0;function connect(){ws=new WebSocket(${wsUrl});ws.onmessage=function(e){var d=JSON.parse(e.data);if(d.type==='hmr:libraries-update'){location.reload()}else if(d.type==='hmr:update'||d.type==='hmr:cms-update'||d.type==='hmr:colors-update'||d.type==='hmr:variables-update')hotReload()};ws.onclose=function(){clearTimeout(timer);timer=setTimeout(connect,1000)}}function hotReload(){var g=++gen;var sx=window.scrollX,sy=window.scrollY;fetch(location.href,{cache:'no-store'}).then(function(r){return r.text()}).then(function(html){if(g!==gen)return;var p=new DOMParser();var d=p.parseFromString(html,'text/html');var or=document.getElementById('root');var nr=d.getElementById('root');if(or&&nr)or.innerHTML=nr.innerHTML;var os=document.getElementById('meno-styles');var ns=d.getElementById('meno-styles');if(os&&ns){os.parentNode.replaceChild(ns.cloneNode(true),os)}var nh=d.documentElement;if(nh){document.documentElement.setAttribute('lang',nh.getAttribute('lang')||'en');document.documentElement.setAttribute('theme',nh.getAttribute('theme')||'light')}document.querySelectorAll('script[id^="meno-cms-"]').forEach(function(s){s.remove()});d.querySelectorAll('script[id^="meno-cms-"]').forEach(function(s){var c=document.createElement('script');c.type=s.type;c.id=s.id;c.textContent=s.textContent;document.head.appendChild(c)});window.__menoHotReload=true;document.querySelectorAll('body > script[src^="/libraries/"]').forEach(function(o){o.remove()});d.querySelectorAll('body > script[src^="/libraries/"]').forEach(function(n){var ls=document.createElement('script');ls.src=n.getAttribute('src')+(n.getAttribute('src').indexOf('?')>-1?'&':'?')+'_r='+Date.now();document.body.appendChild(ls)});var oscr=document.querySelector('script[src^="/_scripts/"]');var nscr=d.querySelector('script[src^="/_scripts/"]');if(nscr){var src=nscr.getAttribute('src');if(oscr)oscr.remove();var s=document.createElement('script');s.src=src+(src.indexOf('?')>-1?'&':'?')+'_r='+Date.now();s.onload=function(){document.dispatchEvent(new Event('DOMContentLoaded'));window.scrollTo(sx,sy)};s.onerror=function(){window.scrollTo(sx,sy)};document.body.appendChild(s)}else{if(oscr)oscr.remove();document.dispatchEvent(new Event('DOMContentLoaded'));window.scrollTo(sx,sy)}}).catch(function(){location.reload()})}connect()})()</script>`
540
+ ? `<script>(function(){var ws,timer,gen=0,lastSrvRoot=null;function strip(s){return s?s.replace(/[?&]_r=\\d+/,''):''}function classList(el){return (el.getAttribute('class')||'').split(/\\s+/).filter(Boolean)}function syncEl(cur,srv,old){var cc=classList(cur),sc=classList(srv),oc=old?new Set(classList(old)):new Set();var rt=cc.filter(function(c){return !oc.has(c)});var seen=new Set(),fin=[];sc.concat(rt).forEach(function(c){if(!seen.has(c)){seen.add(c);fin.push(c)}});var fs=fin.join(' ');if((cur.getAttribute('class')||'')!==fs){if(fs)cur.setAttribute('class',fs);else cur.removeAttribute('class')}for(var i=0;i<srv.attributes.length;i++){var a=srv.attributes[i];if(a.name==='class')continue;if(cur.getAttribute(a.name)!==a.value)cur.setAttribute(a.name,a.value)}if(old){for(var i=0;i<old.attributes.length;i++){var a=old.attributes[i];if(a.name==='class')continue;if(!srv.hasAttribute(a.name)&&cur.hasAttribute(a.name))cur.removeAttribute(a.name)}}}function syncText(cur,srv){var cc=cur.childNodes,sc=srv.childNodes;for(var i=0;i<sc.length;i++){var s=sc[i],c=cc[i];if(s.nodeType===3&&c&&c.nodeType===3){if(c.textContent!==s.textContent)c.textContent=s.textContent}}}function smartUpdate(curR,srvR,oldR){var ce=curR.querySelectorAll('[data-element-path]'),se=srvR.querySelectorAll('[data-element-path]');if(ce.length!==se.length){if(curR.innerHTML!==srvR.innerHTML)curR.innerHTML=srvR.innerHTML;return}var sbp={};for(var i=0;i<se.length;i++)sbp[se[i].getAttribute('data-element-path')]=se[i];var obp={};if(oldR){var oe=oldR.querySelectorAll('[data-element-path]');for(var i=0;i<oe.length;i++)obp[oe[i].getAttribute('data-element-path')]=oe[i]}for(var i=0;i<ce.length;i++){var c=ce[i],p=c.getAttribute('data-element-path'),s=sbp[p];if(!s){if(curR.innerHTML!==srvR.innerHTML)curR.innerHTML=srvR.innerHTML;return}syncEl(c,s,obp[p]);syncText(c,s)}syncText(curR,srvR)}function connect(){ws=new WebSocket(${wsUrl});ws.onmessage=function(e){var d=JSON.parse(e.data);if(d.type==='hmr:libraries-update'){location.reload()}else if(d.type==='hmr:update'||d.type==='hmr:cms-update'||d.type==='hmr:colors-update'||d.type==='hmr:variables-update')hotReload()};ws.onclose=function(){clearTimeout(timer);timer=setTimeout(connect,1000)}}function hotReload(){var g=++gen;var sx=window.scrollX,sy=window.scrollY;fetch(location.href,{cache:'no-store'}).then(function(r){return r.text()}).then(function(html){if(g!==gen)return;var p=new DOMParser();var d=p.parseFromString(html,'text/html');var or=document.getElementById('root'),nr=d.getElementById('root');if(or&&nr)smartUpdate(or,nr,lastSrvRoot);if(nr)lastSrvRoot=nr.cloneNode(true);var os=document.getElementById('meno-styles'),ns=d.getElementById('meno-styles');if(os&&ns&&os.textContent!==ns.textContent)os.parentNode.replaceChild(ns.cloneNode(true),os);var nh=d.documentElement;if(nh){var nl=nh.getAttribute('lang')||'en',nt=nh.getAttribute('theme')||'light';if(document.documentElement.getAttribute('lang')!==nl)document.documentElement.setAttribute('lang',nl);if(document.documentElement.getAttribute('theme')!==nt)document.documentElement.setAttribute('theme',nt)}var ocms=document.querySelectorAll('script[id^="meno-cms-"]'),ncms=d.querySelectorAll('script[id^="meno-cms-"]');var ock=JSON.stringify(Array.prototype.map.call(ocms,function(s){return [s.id,s.textContent]}));var nck=JSON.stringify(Array.prototype.map.call(ncms,function(s){return [s.id,s.textContent]}));if(ock!==nck){ocms.forEach(function(s){s.remove()});ncms.forEach(function(s){var c=document.createElement('script');c.type=s.type;c.id=s.id;c.textContent=s.textContent;document.head.appendChild(c)})}window.__menoHotReload=true;var olib=document.querySelectorAll('body > script[src^="/libraries/"]'),nlib=d.querySelectorAll('body > script[src^="/libraries/"]');var olk=JSON.stringify(Array.prototype.map.call(olib,function(s){return strip(s.getAttribute('src'))}).sort());var nlk=JSON.stringify(Array.prototype.map.call(nlib,function(s){return strip(s.getAttribute('src'))}).sort());if(olk!==nlk){olib.forEach(function(o){o.remove()});nlib.forEach(function(n){var src=n.getAttribute('src');var ls=document.createElement('script');ls.src=src+(src.indexOf('?')>-1?'&':'?')+'_r='+Date.now();document.body.appendChild(ls)})}var oscr=document.querySelector('script[src^="/_scripts/"]'),nscr=d.querySelector('script[src^="/_scripts/"]');var oss=oscr?strip(oscr.getAttribute('src')):'',nss=nscr?strip(nscr.getAttribute('src')):'';if(oss===nss){window.scrollTo(sx,sy)}else{if(oscr)oscr.remove();if(nscr){var src=nscr.getAttribute('src');var s=document.createElement('script');s.src=src+(src.indexOf('?')>-1?'&':'?')+'_r='+Date.now();s.onload=function(){document.dispatchEvent(new Event('DOMContentLoaded'));window.scrollTo(sx,sy)};s.onerror=function(){window.scrollTo(sx,sy)};document.body.appendChild(s)}else{document.dispatchEvent(new Event('DOMContentLoaded'));window.scrollTo(sx,sy)}}}).catch(function(){location.reload()})}var iR=document.getElementById('root');if(iR)lastSrvRoot=iR.cloneNode(true);connect()})()</script>`
494
541
  : '';
495
542
 
496
543
  // Scroll position handlers for preview mode iframe switching