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.
- package/build-astro.ts +2 -2
- package/dist/build-static.js +7 -7
- package/dist/chunks/{chunk-UUA5LEWF.js → chunk-6IVUG7FY.js} +138 -7
- package/dist/chunks/chunk-6IVUG7FY.js.map +7 -0
- package/dist/chunks/{chunk-XSWR3QLI.js → chunk-AZQYF6KE.js} +261 -130
- package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
- package/dist/chunks/{chunk-47UNLQUU.js → chunk-CHD5UCFF.js} +57 -12
- package/dist/chunks/chunk-CHD5UCFF.js.map +7 -0
- package/dist/chunks/{chunk-FGUZOYJX.js → chunk-EQYDSPBB.js} +435 -131
- package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
- package/dist/chunks/{chunk-IF3RATBY.js → chunk-H4JSCDNW.js} +2 -2
- package/dist/chunks/{chunk-KITQJYZV.js → chunk-J23ZX5AP.js} +40 -4
- package/dist/chunks/chunk-J23ZX5AP.js.map +7 -0
- package/dist/chunks/{chunk-LJFB5EBT.js → chunk-JER5NQVM.js} +5 -5
- package/dist/chunks/{chunk-ZTKHJQ2Z.js → chunk-KPU2XHOS.js} +5 -2
- package/dist/chunks/{chunk-ZTKHJQ2Z.js.map → chunk-KPU2XHOS.js.map} +2 -2
- package/dist/chunks/{chunk-BCLGRZ3U.js → chunk-LKAGAQ3M.js} +2 -2
- package/dist/chunks/{chunk-FED5MME6.js → chunk-S2CX6HFM.js} +262 -26
- package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
- package/dist/chunks/{configService-DYCUEURL.js → configService-CCA6AIDI.js} +3 -3
- package/dist/entries/server-router.js +9 -9
- package/dist/entries/server-router.js.map +2 -2
- package/dist/lib/client/index.js +64 -20
- package/dist/lib/client/index.js.map +3 -3
- package/dist/lib/server/index.js +1737 -296
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +50 -10
- package/dist/lib/shared/index.js.map +3 -3
- package/entries/server-router.tsx +6 -2
- package/lib/client/core/ComponentBuilder.test.ts +17 -0
- package/lib/client/core/ComponentBuilder.ts +25 -1
- package/lib/client/core/builders/embedBuilder.ts +15 -2
- package/lib/client/core/builders/linkNodeBuilder.ts +15 -2
- package/lib/client/core/builders/localeListBuilder.ts +17 -6
- package/lib/client/styles/StyleInjector.ts +3 -2
- package/lib/client/theme.ts +4 -4
- package/lib/server/cssGenerator.test.ts +64 -1
- package/lib/server/cssGenerator.ts +48 -9
- package/lib/server/index.ts +1 -1
- package/lib/server/jsonLoader.test.ts +0 -17
- package/lib/server/jsonLoader.ts +0 -81
- package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
- package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
- package/lib/server/routes/api/variables.ts +4 -2
- package/lib/server/routes/index.ts +1 -1
- package/lib/server/routes/pages.ts +23 -1
- package/lib/server/services/cmsService.test.ts +246 -0
- package/lib/server/services/cmsService.ts +122 -5
- package/lib/server/services/configService.ts +5 -0
- package/lib/server/ssr/attributeBuilder.ts +41 -0
- package/lib/server/ssr/htmlGenerator.test.ts +114 -2
- package/lib/server/ssr/htmlGenerator.ts +53 -6
- package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
- package/lib/server/ssr/ssrRenderer.test.ts +362 -1
- package/lib/server/ssr/ssrRenderer.ts +216 -72
- package/lib/server/utils/jsonLineMapper.test.ts +53 -1
- package/lib/server/utils/jsonLineMapper.ts +43 -3
- package/lib/server/webflow/buildWebflow.ts +343 -123
- package/lib/server/webflow/index.ts +1 -0
- package/lib/server/webflow/nodeToWebflow.test.ts +3170 -0
- package/lib/server/webflow/nodeToWebflow.ts +2141 -129
- package/lib/server/webflow/styleMapper.test.ts +389 -0
- package/lib/server/webflow/styleMapper.ts +517 -63
- package/lib/server/webflow/templateWrapper.ts +49 -0
- package/lib/server/webflow/types.ts +218 -18
- package/lib/shared/cssGeneration.test.ts +267 -1
- package/lib/shared/cssGeneration.ts +240 -18
- package/lib/shared/cssProperties.test.ts +247 -1
- package/lib/shared/cssProperties.ts +196 -6
- package/lib/shared/elementClassName.test.ts +15 -0
- package/lib/shared/elementClassName.ts +7 -3
- package/lib/shared/interfaces/contentProvider.ts +39 -6
- package/lib/shared/pathSecurity.ts +16 -0
- package/lib/shared/registry/nodeTypes/ListNodeType.ts +1 -1
- package/lib/shared/responsiveScaling.test.ts +143 -0
- package/lib/shared/responsiveScaling.ts +253 -2
- package/lib/shared/themeDefaults.test.ts +3 -3
- package/lib/shared/themeDefaults.ts +3 -3
- package/lib/shared/types/cms.ts +28 -3
- package/lib/shared/types/index.ts +2 -0
- package/lib/shared/types/variables.ts +37 -0
- package/lib/shared/utilityClassConfig.ts +3 -0
- package/lib/shared/utilityClassMapper.test.ts +123 -0
- package/lib/shared/utilityClassMapper.ts +179 -8
- package/lib/shared/validation/schemas.ts +15 -1
- package/lib/shared/validation/validators.ts +26 -1
- package/package.json +1 -1
- package/dist/chunks/chunk-47UNLQUU.js.map +0 -7
- package/dist/chunks/chunk-FED5MME6.js.map +0 -7
- package/dist/chunks/chunk-FGUZOYJX.js.map +0 -7
- package/dist/chunks/chunk-KITQJYZV.js.map +0 -7
- package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
- package/dist/chunks/chunk-XSWR3QLI.js.map +0 -7
- /package/dist/chunks/{chunk-IF3RATBY.js.map → chunk-H4JSCDNW.js.map} +0 -0
- /package/dist/chunks/{chunk-LJFB5EBT.js.map → chunk-JER5NQVM.js.map} +0 -0
- /package/dist/chunks/{chunk-BCLGRZ3U.js.map → chunk-LKAGAQ3M.js.map} +0 -0
- /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
|
-
*
|
|
67
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
|
|
51
|
-
.
|
|
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
|
-
|
|
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 =
|
|
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')
|
|
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
|