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