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