meno-core 1.0.52 → 1.0.53
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 +183 -13
- package/build-next.ts +1361 -0
- package/build-static.ts +7 -5
- package/dist/bin/cli.js +2 -2
- package/dist/build-static.js +6 -6
- package/dist/chunks/{chunk-HNLUO36W.js → chunk-GZHGVVW3.js} +2 -2
- package/dist/chunks/chunk-GZHGVVW3.js.map +7 -0
- package/dist/chunks/{chunk-LPVETICS.js → chunk-H3GJ4H2U.js} +185 -1
- package/dist/chunks/chunk-H3GJ4H2U.js.map +7 -0
- package/dist/chunks/{chunk-CXCBV2M7.js → chunk-IGYR22T6.js} +76 -270
- package/dist/chunks/chunk-IGYR22T6.js.map +7 -0
- package/dist/chunks/{chunk-LHLHPYSP.js → chunk-JGP5A3Y5.js} +12 -11
- package/dist/chunks/chunk-JGP5A3Y5.js.map +7 -0
- package/dist/chunks/{chunk-7NIC4I3V.js → chunk-JGWFTO6P.js} +167 -21
- package/dist/chunks/chunk-JGWFTO6P.js.map +7 -0
- package/dist/chunks/{chunk-EDQSMAMP.js → chunk-O3NAGJP4.js} +85 -4
- package/dist/chunks/chunk-O3NAGJP4.js.map +7 -0
- package/dist/chunks/{chunk-H4JSCDNW.js → chunk-QB2LNO4W.js} +24 -1
- package/dist/chunks/chunk-QB2LNO4W.js.map +7 -0
- package/dist/chunks/{chunk-A725KYFK.js → chunk-R6XHAFBF.js} +561 -112
- package/dist/chunks/chunk-R6XHAFBF.js.map +7 -0
- package/dist/chunks/{chunk-J23ZX5AP.js → chunk-X754AHS5.js} +277 -1
- package/dist/chunks/chunk-X754AHS5.js.map +7 -0
- package/dist/chunks/{chunk-2QK6U5UK.js → chunk-YBLHKYFF.js} +12 -2
- package/dist/chunks/chunk-YBLHKYFF.js.map +7 -0
- package/dist/chunks/{constants-GWBAD66U.js → constants-STK2YBIW.js} +2 -2
- package/dist/entries/server-router.js +7 -7
- package/dist/lib/client/index.js +354 -59
- package/dist/lib/client/index.js.map +4 -4
- package/dist/lib/server/index.js +1458 -190
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +202 -34
- package/dist/lib/shared/index.js.map +4 -4
- package/dist/lib/test-utils/index.js +1 -1
- package/entries/client-router.tsx +5 -165
- package/lib/client/ErrorBoundary.test.tsx +27 -25
- package/lib/client/ErrorBoundary.tsx +34 -19
- package/lib/client/core/ComponentBuilder.ts +19 -2
- package/lib/client/core/builders/embedBuilder.ts +8 -4
- package/lib/client/core/builders/listBuilder.ts +23 -4
- package/lib/client/fontFamiliesService.test.ts +76 -0
- package/lib/client/fontFamiliesService.ts +69 -0
- package/lib/client/hmrCssReload.ts +160 -0
- package/lib/client/hooks/useColorVariables.ts +2 -0
- package/lib/client/index.ts +4 -0
- package/lib/client/meno-filter/ui.ts +2 -0
- package/lib/client/routing/RouteLoader.test.ts +2 -2
- package/lib/client/routing/RouteLoader.ts +8 -2
- package/lib/client/routing/Router.tsx +81 -15
- package/lib/client/scripts/ScriptExecutor.test.ts +143 -0
- package/lib/client/scripts/ScriptExecutor.ts +56 -2
- package/lib/client/styles/StyleInjector.ts +20 -5
- package/lib/client/styles/UtilityClassCollector.ts +7 -1
- package/lib/client/styles/cspNonce.test.ts +67 -0
- package/lib/client/styles/cspNonce.ts +63 -0
- package/lib/client/templateEngine.test.ts +80 -0
- package/lib/client/templateEngine.ts +5 -0
- package/lib/server/astro/cmsPageEmitter.ts +35 -5
- package/lib/server/astro/componentEmitter.ts +61 -5
- package/lib/server/astro/nodeToAstro.ts +149 -11
- package/lib/server/astro/normalizeOrphanTemplateProps.test.ts +264 -0
- package/lib/server/astro/normalizeOrphanTemplateProps.ts +184 -0
- package/lib/server/createServer.ts +11 -0
- package/lib/server/draftPageStore.ts +49 -0
- package/lib/server/fileWatcher.ts +62 -2
- package/lib/server/index.ts +13 -1
- package/lib/server/providers/fileSystemPageProvider.ts +8 -0
- package/lib/server/routes/api/components.ts +9 -4
- package/lib/server/routes/api/core-routes.ts +2 -2
- package/lib/server/routes/api/pages.ts +14 -22
- package/lib/server/routes/api/shared.ts +56 -0
- package/lib/server/routes/index.ts +90 -0
- package/lib/server/routes/pages.ts +13 -6
- package/lib/server/services/componentService.test.ts +199 -2
- package/lib/server/services/componentService.ts +354 -49
- package/lib/server/services/fileWatcherService.ts +4 -24
- package/lib/server/services/pageService.test.ts +23 -0
- package/lib/server/services/pageService.ts +124 -6
- package/lib/server/ssr/attributeBuilder.ts +8 -2
- package/lib/server/ssr/buildErrorOverlay.ts +1 -1
- package/lib/server/ssr/errorOverlay.test.ts +21 -2
- package/lib/server/ssr/errorOverlay.ts +38 -11
- package/lib/server/ssr/htmlGenerator.test.ts +53 -13
- package/lib/server/ssr/htmlGenerator.ts +71 -27
- package/lib/server/ssr/liveReloadIntegration.test.ts +123 -2
- package/lib/server/ssr/ssrRenderer.test.ts +67 -0
- package/lib/server/ssr/ssrRenderer.ts +94 -9
- package/lib/server/websocketManager.ts +0 -1
- package/lib/shared/componentRefs.ts +45 -0
- package/lib/shared/constants.ts +8 -0
- package/lib/shared/cssGeneration.ts +2 -0
- package/lib/shared/cssProperties.ts +184 -0
- package/lib/shared/expressionEvaluator.ts +54 -0
- package/lib/shared/fontCss.ts +101 -0
- package/lib/shared/fontLoader.ts +8 -86
- package/lib/shared/friendlyError.test.ts +87 -0
- package/lib/shared/friendlyError.ts +121 -0
- package/lib/shared/hrefRefs.test.ts +130 -0
- package/lib/shared/hrefRefs.ts +100 -0
- package/lib/shared/index.ts +52 -0
- package/lib/shared/inlineSvgStyleRules.test.ts +108 -0
- package/lib/shared/inlineSvgStyleRules.ts +134 -0
- package/lib/shared/interfaces/contentProvider.ts +13 -0
- package/lib/shared/itemTemplateUtils.test.ts +14 -0
- package/lib/shared/itemTemplateUtils.ts +4 -1
- package/lib/shared/registry/NodeTypeDefinition.ts +1 -1
- package/lib/shared/registry/nodeTypes/LinkNodeType.ts +1 -1
- package/lib/shared/slugTranslator.test.ts +24 -0
- package/lib/shared/slugTranslator.ts +24 -0
- package/lib/shared/styleNodeUtils.ts +4 -1
- package/lib/shared/tree/PathBuilder.test.ts +128 -1
- package/lib/shared/tree/PathBuilder.ts +83 -31
- package/lib/shared/types/comment.ts +99 -0
- package/lib/shared/types/index.ts +12 -0
- package/lib/shared/types/rendering.ts +8 -0
- package/lib/shared/utilityClassConfig.ts +4 -2
- package/lib/shared/utilityClassMapper.test.ts +24 -0
- package/lib/shared/validation/commentValidators.ts +69 -0
- package/lib/shared/validation/index.ts +1 -0
- package/lib/shared/viewportUnits.integration.test.ts +42 -0
- package/lib/shared/viewportUnits.test.ts +103 -0
- package/lib/shared/viewportUnits.ts +63 -0
- package/lib/test-utils/dom-setup.ts +6 -0
- package/package.json +1 -1
- package/dist/chunks/chunk-2QK6U5UK.js.map +0 -7
- package/dist/chunks/chunk-7NIC4I3V.js.map +0 -7
- package/dist/chunks/chunk-A725KYFK.js.map +0 -7
- package/dist/chunks/chunk-CXCBV2M7.js.map +0 -7
- package/dist/chunks/chunk-EDQSMAMP.js.map +0 -7
- package/dist/chunks/chunk-H4JSCDNW.js.map +0 -7
- package/dist/chunks/chunk-HNLUO36W.js.map +0 -7
- package/dist/chunks/chunk-J23ZX5AP.js.map +0 -7
- package/dist/chunks/chunk-LHLHPYSP.js.map +0 -7
- package/dist/chunks/chunk-LPVETICS.js.map +0 -7
- /package/dist/chunks/{constants-GWBAD66U.js.map → constants-STK2YBIW.js.map} +0 -0
|
@@ -43,6 +43,19 @@ export interface PageProvider {
|
|
|
43
43
|
* @returns True if page exists
|
|
44
44
|
*/
|
|
45
45
|
exists(path: string): Promise<boolean>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Absolute base directory where page files live. Used by folder/move/rename
|
|
49
|
+
* operations that work on the filesystem directly. Optional — callers fall back
|
|
50
|
+
* to `projectPaths.pages()` when not provided.
|
|
51
|
+
*/
|
|
52
|
+
baseDir?(): string;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Page file extension including the leading dot (e.g. ".json" or ".astro").
|
|
56
|
+
* Optional — callers fall back to ".json" when not provided.
|
|
57
|
+
*/
|
|
58
|
+
extension?(): string;
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
/**
|
|
@@ -316,6 +316,20 @@ describe('itemTemplateUtils', () => {
|
|
|
316
316
|
expect(result).toEqual({ text: 'Static text', number: 42 });
|
|
317
317
|
});
|
|
318
318
|
|
|
319
|
+
it('resolves a null field to an empty string, not null (C2 null-safety)', () => {
|
|
320
|
+
// A null CMS field must not become a null prop value — otherwise it can
|
|
321
|
+
// flow into a list `source` and crash with `null.length`.
|
|
322
|
+
const ctx: ItemContext = {
|
|
323
|
+
item: { _id: '1', title: 'Test', tags: null },
|
|
324
|
+
itemIndex: 0,
|
|
325
|
+
itemFirst: true,
|
|
326
|
+
itemLast: false,
|
|
327
|
+
};
|
|
328
|
+
const result = processItemPropsTemplate({ tags: '{{item.tags}}' }, ctx);
|
|
329
|
+
expect(result.tags).toBe('');
|
|
330
|
+
expect(result.tags).not.toBeNull();
|
|
331
|
+
});
|
|
332
|
+
|
|
319
333
|
it('should process nested objects recursively', () => {
|
|
320
334
|
const props = {
|
|
321
335
|
outer: {
|
|
@@ -227,7 +227,10 @@ function resolveCompleteTemplateRaw(
|
|
|
227
227
|
if (!item || typeof item !== 'object') return undefined;
|
|
228
228
|
|
|
229
229
|
const value = getNestedValue(item as Record<string, unknown>, trimmedField);
|
|
230
|
-
|
|
230
|
+
// Treat a resolved null like undefined: fall back to string processing (yields
|
|
231
|
+
// "") instead of letting a null CMS field become a null prop value, which can
|
|
232
|
+
// then flow into a list `source` and crash with `null.length`.
|
|
233
|
+
if (value === undefined || value === null) return undefined;
|
|
231
234
|
|
|
232
235
|
// Apply resolveValue if provided (e.g., i18n resolution)
|
|
233
236
|
if (resolveValue) {
|
|
@@ -11,7 +11,7 @@ import type { StyleValue } from '../types/styles';
|
|
|
11
11
|
/**
|
|
12
12
|
* Tree icon types for node display
|
|
13
13
|
*/
|
|
14
|
-
export type TreeIcon = 'TEXT' | 'COMPONENT' | 'HTML_ELEMENT' | 'IMAGE' | 'OBJECT' | 'SLOT_MARKER' | 'ARRAY' | 'UNKNOWN' | 'FORM';
|
|
14
|
+
export type TreeIcon = 'TEXT' | 'COMPONENT' | 'HTML_ELEMENT' | 'IMAGE' | 'OBJECT' | 'SLOT_MARKER' | 'ARRAY' | 'UNKNOWN' | 'FORM' | 'LINK';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Node category for grouping in command palette
|
|
@@ -5,10 +5,34 @@ import {
|
|
|
5
5
|
translatePath,
|
|
6
6
|
getLocaleLinks,
|
|
7
7
|
resolveSlugToPageId,
|
|
8
|
+
buildPageUrlForLocale,
|
|
8
9
|
type SlugMap,
|
|
9
10
|
} from './slugTranslator';
|
|
10
11
|
import type { I18nConfig } from './types';
|
|
11
12
|
|
|
13
|
+
describe('buildPageUrlForLocale', () => {
|
|
14
|
+
test('default locale produces bare path', () => {
|
|
15
|
+
expect(buildPageUrlForLocale('about', 'en', 'en')).toBe('/about');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('default locale empty slug collapses to root', () => {
|
|
19
|
+
expect(buildPageUrlForLocale('', 'en', 'en')).toBe('/');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('non-default locale gets locale prefix', () => {
|
|
23
|
+
expect(buildPageUrlForLocale('o-nas', 'pl', 'en')).toBe('/pl/o-nas');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('non-default locale empty slug → just locale prefix', () => {
|
|
27
|
+
expect(buildPageUrlForLocale('', 'pl', 'en')).toBe('/pl');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('strips leading slash from slug input', () => {
|
|
31
|
+
expect(buildPageUrlForLocale('/about', 'en', 'en')).toBe('/about');
|
|
32
|
+
expect(buildPageUrlForLocale('/o-nas', 'pl', 'en')).toBe('/pl/o-nas');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
12
36
|
describe('slugTranslator', () => {
|
|
13
37
|
let slugMappings: SlugMap[];
|
|
14
38
|
let slugIndex: Map<string, any>;
|
|
@@ -161,6 +161,30 @@ export function getLocaleLinks(
|
|
|
161
161
|
}));
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Build the URL a page is served at for a given locale.
|
|
166
|
+
*
|
|
167
|
+
* Conventions (match what `translatePath` / the routing layer at
|
|
168
|
+
* packages/core/lib/server/routes/pages.ts produces):
|
|
169
|
+
* - Default locale paths are bare: "/" or "/{slug}"
|
|
170
|
+
* - Non-default locale paths are locale-prefixed: "/{locale}" or "/{locale}/{slug}"
|
|
171
|
+
* - An empty slug (e.g. for the index page) collapses to the root.
|
|
172
|
+
*
|
|
173
|
+
* Used by the slug/page-rename flow to compute the (old, new) href pair
|
|
174
|
+
* for cascade rewrites in incoming links.
|
|
175
|
+
*/
|
|
176
|
+
export function buildPageUrlForLocale(
|
|
177
|
+
slug: string,
|
|
178
|
+
locale: string,
|
|
179
|
+
defaultLocale: string,
|
|
180
|
+
): string {
|
|
181
|
+
const cleanSlug = slug.replace(/^\/+/, '');
|
|
182
|
+
if (locale === defaultLocale) {
|
|
183
|
+
return cleanSlug === '' ? '/' : `/${cleanSlug}`;
|
|
184
|
+
}
|
|
185
|
+
return cleanSlug === '' ? `/${locale}` : `/${locale}/${cleanSlug}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
164
188
|
/**
|
|
165
189
|
* Resolve a slug to its pageId (for server-side page loading)
|
|
166
190
|
*
|
|
@@ -39,8 +39,11 @@ export function applyStylesToNode(
|
|
|
39
39
|
* When both are responsive ({ base, tablet, mobile }), merges per breakpoint
|
|
40
40
|
* so that instance properties override structure properties within each breakpoint
|
|
41
41
|
* rather than replacing the entire breakpoint object.
|
|
42
|
+
*
|
|
43
|
+
* Pure (returns a new object; does not mutate inputs). Exported so the Astro
|
|
44
|
+
* exporter can replicate SSR's instance-into-root style merge at build time.
|
|
42
45
|
*/
|
|
43
|
-
function deepMergeStyles(
|
|
46
|
+
export function deepMergeStyles(
|
|
44
47
|
existing: StyleValue | Record<string, any>,
|
|
45
48
|
instance: StyleValue | Record<string, any> | null | undefined
|
|
46
49
|
): StyleValue {
|
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, it, expect } from 'bun:test';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
buildTreePathsWithRendered,
|
|
13
|
+
buildComponentTreePaths,
|
|
14
|
+
computeCumulativeInstancePath,
|
|
15
|
+
type NavigationContext,
|
|
16
|
+
} from './PathBuilder';
|
|
12
17
|
import type { Path } from '../pathArrayUtils';
|
|
13
18
|
|
|
14
19
|
// Helper to wrap component structure in expected PageData format
|
|
@@ -393,3 +398,125 @@ describe('PathBuilder - post-slot element path calculation', () => {
|
|
|
393
398
|
});
|
|
394
399
|
});
|
|
395
400
|
});
|
|
401
|
+
|
|
402
|
+
describe('computeCumulativeInstancePath', () => {
|
|
403
|
+
it('returns null for empty / missing history', () => {
|
|
404
|
+
expect(computeCumulativeInstancePath(undefined)).toBeNull();
|
|
405
|
+
expect(computeCumulativeInstancePath([])).toBeNull();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('returns the first entry path verbatim when there is one entry', () => {
|
|
409
|
+
expect(
|
|
410
|
+
computeCumulativeInstancePath([
|
|
411
|
+
{ componentInstancePath: [0], componentInstanceRenderedPath: [0] },
|
|
412
|
+
]),
|
|
413
|
+
).toEqual([0]);
|
|
414
|
+
expect(
|
|
415
|
+
computeCumulativeInstancePath([
|
|
416
|
+
{ componentInstancePath: [0, 2], componentInstanceRenderedPath: [0, 2] },
|
|
417
|
+
]),
|
|
418
|
+
).toEqual([0, 2]);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('stacks deeper entries via slice(1) onto the first', () => {
|
|
422
|
+
expect(
|
|
423
|
+
computeCumulativeInstancePath([
|
|
424
|
+
{ componentInstancePath: [0], componentInstanceRenderedPath: [0] },
|
|
425
|
+
{ componentInstancePath: [0, 0, 0, 2], componentInstanceRenderedPath: [0, 0, 0, 8] },
|
|
426
|
+
]),
|
|
427
|
+
).toEqual([0, 0, 0, 8]);
|
|
428
|
+
expect(
|
|
429
|
+
computeCumulativeInstancePath([
|
|
430
|
+
{ componentInstancePath: [0], componentInstanceRenderedPath: [0] },
|
|
431
|
+
{ componentInstancePath: [0, 0, 0, 2], componentInstanceRenderedPath: [0, 0, 0, 8] },
|
|
432
|
+
{ componentInstancePath: [0, 1], componentInstanceRenderedPath: [0, 1] },
|
|
433
|
+
]),
|
|
434
|
+
).toEqual([0, 0, 0, 8, 1]);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('falls back to componentInstancePath when componentInstanceRenderedPath is missing', () => {
|
|
438
|
+
expect(
|
|
439
|
+
computeCumulativeInstancePath([
|
|
440
|
+
{ componentInstancePath: [0, 2] },
|
|
441
|
+
{ componentInstancePath: [0, 1] },
|
|
442
|
+
]),
|
|
443
|
+
).toEqual([0, 2, 1]);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
describe('slotRenderedPaths on slot entries', () => {
|
|
448
|
+
const layoutStructure = {
|
|
449
|
+
type: 'node' as const,
|
|
450
|
+
tag: 'div',
|
|
451
|
+
children: [
|
|
452
|
+
{ type: 'component' as const, component: 'Nav' },
|
|
453
|
+
{ type: 'slot' as const },
|
|
454
|
+
{ type: 'component' as const, component: 'Footer' },
|
|
455
|
+
],
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
it('emits N occupied paths for a slot with N instance children (N > 1)', () => {
|
|
459
|
+
const navigationHistory: NavigationContext[] = [
|
|
460
|
+
{
|
|
461
|
+
componentInstancePath: [0],
|
|
462
|
+
componentInstanceRenderedPath: [0],
|
|
463
|
+
instanceChildren: Array.from({ length: 7 }, (_, i) => ({
|
|
464
|
+
type: 'component' as const,
|
|
465
|
+
component: `Section${i + 1}`,
|
|
466
|
+
})) as any,
|
|
467
|
+
},
|
|
468
|
+
];
|
|
469
|
+
|
|
470
|
+
const paths = buildTreePathsWithRendered(
|
|
471
|
+
wrapAsComponentData(layoutStructure),
|
|
472
|
+
undefined,
|
|
473
|
+
navigationHistory,
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
// Slot's logical path is [0, 1] (second child of structure root).
|
|
477
|
+
const slotEntry = paths.find(
|
|
478
|
+
(p) => p.path.length === 2 && p.path[0] === 0 && p.path[1] === 1,
|
|
479
|
+
);
|
|
480
|
+
expect(slotEntry).toBeDefined();
|
|
481
|
+
expect(slotEntry!.renderedPath).toEqual([0, 1]);
|
|
482
|
+
// With 7 instance children, the slot occupies positions 1..7 under the
|
|
483
|
+
// parent (which is at [0]).
|
|
484
|
+
expect(slotEntry!.slotRenderedPaths).toEqual([
|
|
485
|
+
[0, 1],
|
|
486
|
+
[0, 2],
|
|
487
|
+
[0, 3],
|
|
488
|
+
[0, 4],
|
|
489
|
+
[0, 5],
|
|
490
|
+
[0, 6],
|
|
491
|
+
[0, 7],
|
|
492
|
+
]);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('omits slotRenderedPaths when slot has 0 or 1 instance children', () => {
|
|
496
|
+
const noChildren: NavigationContext[] = [
|
|
497
|
+
{ componentInstancePath: [0], componentInstanceRenderedPath: [0], instanceChildren: [] },
|
|
498
|
+
];
|
|
499
|
+
const oneChild: NavigationContext[] = [
|
|
500
|
+
{
|
|
501
|
+
componentInstancePath: [0],
|
|
502
|
+
componentInstanceRenderedPath: [0],
|
|
503
|
+
instanceChildren: [{ type: 'component' as const, component: 'Only' }] as any,
|
|
504
|
+
},
|
|
505
|
+
];
|
|
506
|
+
const noChildrenPaths = buildTreePathsWithRendered(
|
|
507
|
+
wrapAsComponentData(layoutStructure),
|
|
508
|
+
undefined,
|
|
509
|
+
noChildren,
|
|
510
|
+
);
|
|
511
|
+
const oneChildPaths = buildTreePathsWithRendered(
|
|
512
|
+
wrapAsComponentData(layoutStructure),
|
|
513
|
+
undefined,
|
|
514
|
+
oneChild,
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
const slotEntryNo = noChildrenPaths.find((p) => p.path.length === 2 && p.path[1] === 1);
|
|
518
|
+
const slotEntryOne = oneChildPaths.find((p) => p.path.length === 2 && p.path[1] === 1);
|
|
519
|
+
expect(slotEntryNo?.slotRenderedPaths).toBeUndefined();
|
|
520
|
+
expect(slotEntryOne?.slotRenderedPaths).toBeUndefined();
|
|
521
|
+
});
|
|
522
|
+
});
|
|
@@ -18,6 +18,14 @@ import { isComponentNode, isSlotMarker, isLocaleListNode, isCMSListNode, isListN
|
|
|
18
18
|
export interface NodePathData {
|
|
19
19
|
path: Path; // Logical path in data structure
|
|
20
20
|
renderedPath: Path; // Path adjusted for component structure wrapping
|
|
21
|
+
/**
|
|
22
|
+
* For slot markers being edited from inside their host component: the
|
|
23
|
+
* page-absolute rendered paths of every DOM position the slot occupies
|
|
24
|
+
* after the renderer inlines its N instance children. Length === N.
|
|
25
|
+
* Undefined for non-slot nodes and for slot markers with 0 or 1 children
|
|
26
|
+
* (where the single `renderedPath` already represents the whole region).
|
|
27
|
+
*/
|
|
28
|
+
slotRenderedPaths?: Path[];
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
/**
|
|
@@ -201,6 +209,32 @@ export interface NavigationContext {
|
|
|
201
209
|
cmsItemContext?: CMSItemContext | null; // CMS context when editing component inside CMS List
|
|
202
210
|
}
|
|
203
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Stack a navigation history's componentInstanceRenderedPath entries into one
|
|
214
|
+
* page-absolute path. The first entry's path is taken as-is; every subsequent
|
|
215
|
+
* entry contributes `path.slice(1)` (i.e. its component-internal suffix), so
|
|
216
|
+
* the storage convention is "first entry is page-absolute, deeper entries are
|
|
217
|
+
* component-internal-with-offset". Used to compute the prefix that
|
|
218
|
+
* buildTreePathsWithRendered prepends, and to know how many leading elements
|
|
219
|
+
* to strip when extracting a component-internal path back out.
|
|
220
|
+
*/
|
|
221
|
+
export function computeCumulativeInstancePath(
|
|
222
|
+
navigationHistory: NavigationContext[] | undefined,
|
|
223
|
+
): Path | null {
|
|
224
|
+
if (!navigationHistory || navigationHistory.length === 0) return null;
|
|
225
|
+
let cumulative: Path | null =
|
|
226
|
+
navigationHistory[0].componentInstanceRenderedPath ?? navigationHistory[0].componentInstancePath;
|
|
227
|
+
if (!cumulative) return null;
|
|
228
|
+
for (let i = 1; i < navigationHistory.length; i++) {
|
|
229
|
+
const next =
|
|
230
|
+
navigationHistory[i].componentInstanceRenderedPath ?? navigationHistory[i].componentInstancePath;
|
|
231
|
+
if (next) {
|
|
232
|
+
cumulative = [...cumulative, ...next.slice(1)];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return cumulative;
|
|
236
|
+
}
|
|
237
|
+
|
|
204
238
|
/**
|
|
205
239
|
* Build all paths from tree with both logical and rendered paths
|
|
206
240
|
*
|
|
@@ -237,26 +271,21 @@ export function buildTreePathsWithRendered(
|
|
|
237
271
|
|
|
238
272
|
// Calculate cumulative component instance path for nested editing
|
|
239
273
|
// Use rendered paths to ensure DOM queries have correct element paths
|
|
240
|
-
|
|
241
|
-
if (navigationHistory && navigationHistory.length > 0) {
|
|
242
|
-
// Use rendered path for accurate DOM queries
|
|
243
|
-
cumulativeInstancePath = navigationHistory[0].componentInstanceRenderedPath ?? navigationHistory[0].componentInstancePath;
|
|
244
|
-
|
|
245
|
-
// For nested components, chain the paths
|
|
246
|
-
for (let i = 1; i < navigationHistory.length; i++) {
|
|
247
|
-
const nextPath = navigationHistory[i].componentInstanceRenderedPath ?? navigationHistory[i].componentInstancePath;
|
|
248
|
-
if (nextPath && cumulativeInstancePath) {
|
|
249
|
-
// Append path segments except root
|
|
250
|
-
cumulativeInstancePath = [
|
|
251
|
-
...cumulativeInstancePath,
|
|
252
|
-
...nextPath.slice(1)
|
|
253
|
-
];
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
274
|
+
const cumulativeInstancePath = computeCumulativeInstancePath(navigationHistory);
|
|
257
275
|
|
|
258
276
|
const result: NodePathData[] = [];
|
|
259
277
|
|
|
278
|
+
// The slot under the host component being edited renders into N DOM
|
|
279
|
+
// positions equal to the host's `instanceChildren` count. Snapshot that
|
|
280
|
+
// count once: it's the same source `calculateRenderedPath` uses for the
|
|
281
|
+
// post-slot offset (the LAST navigation entry's instanceChildren).
|
|
282
|
+
const hostInstanceChildrenCount =
|
|
283
|
+
navigationHistory && navigationHistory.length > 0
|
|
284
|
+
? (Array.isArray(navigationHistory[navigationHistory.length - 1].instanceChildren)
|
|
285
|
+
? navigationHistory[navigationHistory.length - 1].instanceChildren!.length
|
|
286
|
+
: 0)
|
|
287
|
+
: 0;
|
|
288
|
+
|
|
260
289
|
function traverse(node: ComponentNode, currentPath: Path = [0]): void {
|
|
261
290
|
// Calculate rendered path for this node
|
|
262
291
|
// Call calculateRenderedPath when:
|
|
@@ -273,9 +302,15 @@ export function buildTreePathsWithRendered(
|
|
|
273
302
|
? [...cumulativeInstancePath, ...renderedPath.slice(1)]
|
|
274
303
|
: renderedPath;
|
|
275
304
|
|
|
305
|
+
const slotRenderedPaths =
|
|
306
|
+
isSlotMarker(node) && hostInstanceChildrenCount > 1
|
|
307
|
+
? expandSlotRenderedPaths(adjustedRenderedPath, hostInstanceChildrenCount)
|
|
308
|
+
: undefined;
|
|
309
|
+
|
|
276
310
|
result.push({
|
|
277
311
|
path: currentPath, // Keep logical path component-relative
|
|
278
312
|
renderedPath: adjustedRenderedPath, // Adjust rendered path for page-relative queries
|
|
313
|
+
...(slotRenderedPaths ? { slotRenderedPaths } : {}),
|
|
279
314
|
});
|
|
280
315
|
|
|
281
316
|
// Traverse children
|
|
@@ -299,6 +334,23 @@ export function buildTreePathsWithRendered(
|
|
|
299
334
|
return result;
|
|
300
335
|
}
|
|
301
336
|
|
|
337
|
+
/**
|
|
338
|
+
* Given the slot marker's primary rendered path (its position in the parent
|
|
339
|
+
* AFTER all path adjustments) and the count N of injected instance children,
|
|
340
|
+
* return the N page-absolute paths the slot's children occupy in the DOM:
|
|
341
|
+
* positions [slotIdx, slotIdx+1, ..., slotIdx+N-1] under the same parent.
|
|
342
|
+
*/
|
|
343
|
+
function expandSlotRenderedPaths(slotRenderedPath: Path, instanceChildrenCount: number): Path[] {
|
|
344
|
+
if (instanceChildrenCount <= 0 || slotRenderedPath.length === 0) return [];
|
|
345
|
+
const prefix = slotRenderedPath.slice(0, -1);
|
|
346
|
+
const slotIdx = slotRenderedPath[slotRenderedPath.length - 1];
|
|
347
|
+
const out: Path[] = [];
|
|
348
|
+
for (let i = 0; i < instanceChildrenCount; i++) {
|
|
349
|
+
out.push([...prefix, slotIdx + i]);
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
302
354
|
/**
|
|
303
355
|
* Build tree paths for a specific component context
|
|
304
356
|
*
|
|
@@ -318,20 +370,14 @@ export function buildComponentTreePaths(
|
|
|
318
370
|
const result: NodePathData[] = [];
|
|
319
371
|
|
|
320
372
|
// Calculate cumulative component instance path for navigation context
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
...cumulativeInstancePath,
|
|
330
|
-
...nextPath.slice(1)
|
|
331
|
-
];
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
373
|
+
const cumulativeInstancePath = computeCumulativeInstancePath(navigationHistory);
|
|
374
|
+
|
|
375
|
+
const hostInstanceChildrenCount =
|
|
376
|
+
navigationHistory && navigationHistory.length > 0
|
|
377
|
+
? (Array.isArray(navigationHistory[navigationHistory.length - 1].instanceChildren)
|
|
378
|
+
? navigationHistory[navigationHistory.length - 1].instanceChildren!.length
|
|
379
|
+
: 0)
|
|
380
|
+
: 0;
|
|
335
381
|
|
|
336
382
|
function traverse(node: ComponentNode, currentPath: Path = [0]): void {
|
|
337
383
|
// In component context, logical and rendered paths may differ
|
|
@@ -346,9 +392,15 @@ export function buildComponentTreePaths(
|
|
|
346
392
|
? [...cumulativeInstancePath, ...renderedPath.slice(1)]
|
|
347
393
|
: renderedPath;
|
|
348
394
|
|
|
395
|
+
const slotRenderedPaths =
|
|
396
|
+
isSlotMarker(node) && hostInstanceChildrenCount > 1
|
|
397
|
+
? expandSlotRenderedPaths(adjustedRenderedPath, hostInstanceChildrenCount)
|
|
398
|
+
: undefined;
|
|
399
|
+
|
|
349
400
|
result.push({
|
|
350
401
|
path: currentPath,
|
|
351
402
|
renderedPath: adjustedRenderedPath,
|
|
403
|
+
...(slotRenderedPaths ? { slotRenderedPaths } : {}),
|
|
352
404
|
});
|
|
353
405
|
|
|
354
406
|
// Traverse children
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comment / Pin Types
|
|
3
|
+
*
|
|
4
|
+
* Comments are Figma-style pins placed on components in the studio canvas.
|
|
5
|
+
* One file per pin (thread embedded). Storage layout (status as top folder):
|
|
6
|
+
* {projectRoot}/comments/<status>/<pageSlug>--<seq>--<titleSlug>.json
|
|
7
|
+
* e.g. comments/open/blog__post--3--hero-is-too-dark.json
|
|
8
|
+
*
|
|
9
|
+
* Pins are an authoring-time concept — they live in studio only.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Workflow status. No custom statuses for MVP. */
|
|
13
|
+
export type CommentStatus =
|
|
14
|
+
| 'open'
|
|
15
|
+
| 'in-progress'
|
|
16
|
+
| 'ready-for-review'
|
|
17
|
+
| 'resolved'
|
|
18
|
+
| 'closed';
|
|
19
|
+
|
|
20
|
+
export const COMMENT_STATUSES: readonly CommentStatus[] = [
|
|
21
|
+
'open',
|
|
22
|
+
'in-progress',
|
|
23
|
+
'ready-for-review',
|
|
24
|
+
'resolved',
|
|
25
|
+
'closed',
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
/** Identifies the GitHub user who authored a thread entry. */
|
|
29
|
+
export interface CommentAuthor {
|
|
30
|
+
/** GitHub login. `"local"` for non-electron / unauthenticated runs. */
|
|
31
|
+
login: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
avatarUrl?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Identity fingerprint for the anchored node — used to detect orphan pins
|
|
38
|
+
* when the tree shifts. Mirrors the shape of `nodesMatch()` in NodeStore.
|
|
39
|
+
*/
|
|
40
|
+
export interface CommentNodeIdentity {
|
|
41
|
+
/** `'component'` or `'node'` (HTML). */
|
|
42
|
+
kind: 'component' | 'node';
|
|
43
|
+
/** Component name (when kind=component) or HTML tag (when kind=node). */
|
|
44
|
+
name: string;
|
|
45
|
+
/** Editor-set label, used to disambiguate same-type siblings. */
|
|
46
|
+
label?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Where the pin attaches on the page. nodePath is positional; nodeIdentity
|
|
51
|
+
* is used at render time to detect that the pin has been orphaned (Phase 4).
|
|
52
|
+
*/
|
|
53
|
+
export interface CommentAnchor {
|
|
54
|
+
/** Path array into the page tree, e.g. [0, 1, 2]. */
|
|
55
|
+
nodePath: number[];
|
|
56
|
+
/** Identity fingerprint for orphan detection. */
|
|
57
|
+
nodeIdentity: CommentNodeIdentity;
|
|
58
|
+
/** Horizontal offset inside the component's bounding box, 0..1. */
|
|
59
|
+
offsetXPercent: number;
|
|
60
|
+
/** Vertical offset inside the component's bounding box, 0..1. */
|
|
61
|
+
offsetYPercent: number;
|
|
62
|
+
/**
|
|
63
|
+
* Breakpoint frame this pin was placed on (e.g. `'base'`, `'tablet'`,
|
|
64
|
+
* `'mobile'`). Only set for pins created in Design Mode's multi-frame
|
|
65
|
+
* canvas, so the pin is only shown on the frame where it was pinned.
|
|
66
|
+
* When unset, the pin renders on every frame (single-frame / page mode,
|
|
67
|
+
* or pins created before this field existed).
|
|
68
|
+
*/
|
|
69
|
+
breakpoint?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One message in a comment's thread (initial + replies). */
|
|
73
|
+
export interface CommentThreadEntry {
|
|
74
|
+
id: string;
|
|
75
|
+
author: CommentAuthor;
|
|
76
|
+
/** ISO timestamp. */
|
|
77
|
+
createdAt: string;
|
|
78
|
+
text: string;
|
|
79
|
+
/** When this entry transitioned the comment status, the target status. */
|
|
80
|
+
statusChange: CommentStatus | null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A pinned comment. */
|
|
84
|
+
export interface Comment {
|
|
85
|
+
_id: string;
|
|
86
|
+
_filename: string;
|
|
87
|
+
/** Page path the pin lives on (e.g. `"blog/post"`). */
|
|
88
|
+
_pagePath: string;
|
|
89
|
+
/** Stable per-page badge number. Server assigns at create time. */
|
|
90
|
+
_seq: number;
|
|
91
|
+
/** ISO timestamps. */
|
|
92
|
+
_createdAt: string;
|
|
93
|
+
_updatedAt: string;
|
|
94
|
+
/** Git HEAD SHA captured at create time, or null when unavailable. */
|
|
95
|
+
_commitSha: string | null;
|
|
96
|
+
anchor: CommentAnchor;
|
|
97
|
+
status: CommentStatus;
|
|
98
|
+
thread: CommentThreadEntry[];
|
|
99
|
+
}
|
|
@@ -173,5 +173,17 @@ export type {
|
|
|
173
173
|
VariantResult,
|
|
174
174
|
} from './experiments';
|
|
175
175
|
|
|
176
|
+
// Comment / Pin types
|
|
177
|
+
export type {
|
|
178
|
+
CommentStatus,
|
|
179
|
+
CommentAuthor,
|
|
180
|
+
CommentNodeIdentity,
|
|
181
|
+
CommentAnchor,
|
|
182
|
+
CommentThreadEntry,
|
|
183
|
+
Comment,
|
|
184
|
+
} from './comment';
|
|
185
|
+
|
|
186
|
+
export { COMMENT_STATUSES } from './comment';
|
|
187
|
+
|
|
176
188
|
// Note: Path types are exported from ../paths/index.ts, not from here
|
|
177
189
|
|
|
@@ -57,6 +57,14 @@ export interface TemplateContext {
|
|
|
57
57
|
props: Record<string, unknown>;
|
|
58
58
|
/** Component definition (for variants, defaults) */
|
|
59
59
|
componentDef?: ComponentDefinition | StructuredComponentDefinition;
|
|
60
|
+
/**
|
|
61
|
+
* Resolved props of the component that hosts this one (one level up).
|
|
62
|
+
* Layered into template evaluation at lower precedence than `props`, so a
|
|
63
|
+
* `{{x}}` in a child component's structure falls back to the parent's `x`
|
|
64
|
+
* when the child doesn't declare it. Aligned with `buildListResolutionScope`
|
|
65
|
+
* for list `items` / `filter` templates.
|
|
66
|
+
*/
|
|
67
|
+
parentProps?: Record<string, unknown>;
|
|
60
68
|
/** Future extension points (cms, page, etc.) */
|
|
61
69
|
[namespace: string]: unknown;
|
|
62
70
|
}
|
|
@@ -321,13 +321,13 @@ export const specialValueMappings: Record<string, Record<string, string>> = {
|
|
|
321
321
|
'flex-end': 'jc-e',
|
|
322
322
|
'space-between': 'jc-b',
|
|
323
323
|
'space-around': 'jc-a',
|
|
324
|
-
'space-evenly': 'jc-
|
|
324
|
+
'space-evenly': 'jc-ev',
|
|
325
325
|
},
|
|
326
326
|
alignItems: {
|
|
327
327
|
center: 'ai-c',
|
|
328
328
|
'flex-start': 'ai-s',
|
|
329
329
|
'flex-end': 'ai-e',
|
|
330
|
-
stretch: 'ai-
|
|
330
|
+
stretch: 'ai-st',
|
|
331
331
|
baseline: 'ai-b',
|
|
332
332
|
},
|
|
333
333
|
overflow: {
|
|
@@ -378,9 +378,11 @@ export const classToStyleSpecialCases: Record<string, { prop: string; value: str
|
|
|
378
378
|
'jc-e': { prop: 'justifyContent', value: 'flex-end' },
|
|
379
379
|
'jc-b': { prop: 'justifyContent', value: 'space-between' },
|
|
380
380
|
'jc-a': { prop: 'justifyContent', value: 'space-around' },
|
|
381
|
+
'jc-ev': { prop: 'justifyContent', value: 'space-evenly' },
|
|
381
382
|
'ai-c': { prop: 'alignItems', value: 'center' },
|
|
382
383
|
'ai-s': { prop: 'alignItems', value: 'flex-start' },
|
|
383
384
|
'ai-e': { prop: 'alignItems', value: 'flex-end' },
|
|
385
|
+
'ai-st': { prop: 'alignItems', value: 'stretch' },
|
|
384
386
|
'ai-b': { prop: 'alignItems', value: 'baseline' },
|
|
385
387
|
'o-h': { prop: 'overflow', value: 'hidden' },
|
|
386
388
|
'o-a': { prop: 'overflow', value: 'auto' },
|
|
@@ -65,6 +65,30 @@ describe('utilityClassMapper', () => {
|
|
|
65
65
|
const classes = stylesToClasses({ aspectRatio: '1/1' });
|
|
66
66
|
expect(classes).toContain('ar-1s1');
|
|
67
67
|
});
|
|
68
|
+
|
|
69
|
+
test('align-items stretch maps to a distinct class from flex-start', () => {
|
|
70
|
+
// Regression: stretch used to share the `ai-s` class with flex-start,
|
|
71
|
+
// so imported `align-items: stretch` silently rendered as flex-start
|
|
72
|
+
// — exactly what made Finsweet's mobile CTA buttons line up to the
|
|
73
|
+
// left instead of stretching to full width.
|
|
74
|
+
const stretchClasses = stylesToClasses({ alignItems: 'stretch' });
|
|
75
|
+
const flexStartClasses = stylesToClasses({ alignItems: 'flex-start' });
|
|
76
|
+
expect(stretchClasses).toContain('ai-st');
|
|
77
|
+
expect(flexStartClasses).toContain('ai-s');
|
|
78
|
+
expect(stretchClasses).not.toContain('ai-s');
|
|
79
|
+
expect(classToStyle('ai-st')).toEqual({ prop: 'alignItems', value: 'stretch' });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('justify-content space-evenly maps to a distinct class from flex-end', () => {
|
|
83
|
+
// Same bug shape as align-items: `space-evenly` previously aliased to
|
|
84
|
+
// `jc-e` (flex-end), silently changing layout.
|
|
85
|
+
const evenly = stylesToClasses({ justifyContent: 'space-evenly' });
|
|
86
|
+
const end = stylesToClasses({ justifyContent: 'flex-end' });
|
|
87
|
+
expect(evenly).toContain('jc-ev');
|
|
88
|
+
expect(end).toContain('jc-e');
|
|
89
|
+
expect(evenly).not.toContain('jc-e');
|
|
90
|
+
expect(classToStyle('jc-ev')).toEqual({ prop: 'justifyContent', value: 'space-evenly' });
|
|
91
|
+
});
|
|
68
92
|
});
|
|
69
93
|
|
|
70
94
|
describe('responsiveStylesToClasses', () => {
|