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.
Files changed (135) hide show
  1. package/build-astro.ts +183 -13
  2. package/build-next.ts +1361 -0
  3. package/build-static.ts +7 -5
  4. package/dist/bin/cli.js +2 -2
  5. package/dist/build-static.js +6 -6
  6. package/dist/chunks/{chunk-HNLUO36W.js → chunk-GZHGVVW3.js} +2 -2
  7. package/dist/chunks/chunk-GZHGVVW3.js.map +7 -0
  8. package/dist/chunks/{chunk-LPVETICS.js → chunk-H3GJ4H2U.js} +185 -1
  9. package/dist/chunks/chunk-H3GJ4H2U.js.map +7 -0
  10. package/dist/chunks/{chunk-CXCBV2M7.js → chunk-IGYR22T6.js} +76 -270
  11. package/dist/chunks/chunk-IGYR22T6.js.map +7 -0
  12. package/dist/chunks/{chunk-LHLHPYSP.js → chunk-JGP5A3Y5.js} +12 -11
  13. package/dist/chunks/chunk-JGP5A3Y5.js.map +7 -0
  14. package/dist/chunks/{chunk-7NIC4I3V.js → chunk-JGWFTO6P.js} +167 -21
  15. package/dist/chunks/chunk-JGWFTO6P.js.map +7 -0
  16. package/dist/chunks/{chunk-EDQSMAMP.js → chunk-O3NAGJP4.js} +85 -4
  17. package/dist/chunks/chunk-O3NAGJP4.js.map +7 -0
  18. package/dist/chunks/{chunk-H4JSCDNW.js → chunk-QB2LNO4W.js} +24 -1
  19. package/dist/chunks/chunk-QB2LNO4W.js.map +7 -0
  20. package/dist/chunks/{chunk-A725KYFK.js → chunk-R6XHAFBF.js} +561 -112
  21. package/dist/chunks/chunk-R6XHAFBF.js.map +7 -0
  22. package/dist/chunks/{chunk-J23ZX5AP.js → chunk-X754AHS5.js} +277 -1
  23. package/dist/chunks/chunk-X754AHS5.js.map +7 -0
  24. package/dist/chunks/{chunk-2QK6U5UK.js → chunk-YBLHKYFF.js} +12 -2
  25. package/dist/chunks/chunk-YBLHKYFF.js.map +7 -0
  26. package/dist/chunks/{constants-GWBAD66U.js → constants-STK2YBIW.js} +2 -2
  27. package/dist/entries/server-router.js +7 -7
  28. package/dist/lib/client/index.js +354 -59
  29. package/dist/lib/client/index.js.map +4 -4
  30. package/dist/lib/server/index.js +1458 -190
  31. package/dist/lib/server/index.js.map +4 -4
  32. package/dist/lib/shared/index.js +202 -34
  33. package/dist/lib/shared/index.js.map +4 -4
  34. package/dist/lib/test-utils/index.js +1 -1
  35. package/entries/client-router.tsx +5 -165
  36. package/lib/client/ErrorBoundary.test.tsx +27 -25
  37. package/lib/client/ErrorBoundary.tsx +34 -19
  38. package/lib/client/core/ComponentBuilder.ts +19 -2
  39. package/lib/client/core/builders/embedBuilder.ts +8 -4
  40. package/lib/client/core/builders/listBuilder.ts +23 -4
  41. package/lib/client/fontFamiliesService.test.ts +76 -0
  42. package/lib/client/fontFamiliesService.ts +69 -0
  43. package/lib/client/hmrCssReload.ts +160 -0
  44. package/lib/client/hooks/useColorVariables.ts +2 -0
  45. package/lib/client/index.ts +4 -0
  46. package/lib/client/meno-filter/ui.ts +2 -0
  47. package/lib/client/routing/RouteLoader.test.ts +2 -2
  48. package/lib/client/routing/RouteLoader.ts +8 -2
  49. package/lib/client/routing/Router.tsx +81 -15
  50. package/lib/client/scripts/ScriptExecutor.test.ts +143 -0
  51. package/lib/client/scripts/ScriptExecutor.ts +56 -2
  52. package/lib/client/styles/StyleInjector.ts +20 -5
  53. package/lib/client/styles/UtilityClassCollector.ts +7 -1
  54. package/lib/client/styles/cspNonce.test.ts +67 -0
  55. package/lib/client/styles/cspNonce.ts +63 -0
  56. package/lib/client/templateEngine.test.ts +80 -0
  57. package/lib/client/templateEngine.ts +5 -0
  58. package/lib/server/astro/cmsPageEmitter.ts +35 -5
  59. package/lib/server/astro/componentEmitter.ts +61 -5
  60. package/lib/server/astro/nodeToAstro.ts +149 -11
  61. package/lib/server/astro/normalizeOrphanTemplateProps.test.ts +264 -0
  62. package/lib/server/astro/normalizeOrphanTemplateProps.ts +184 -0
  63. package/lib/server/createServer.ts +11 -0
  64. package/lib/server/draftPageStore.ts +49 -0
  65. package/lib/server/fileWatcher.ts +62 -2
  66. package/lib/server/index.ts +13 -1
  67. package/lib/server/providers/fileSystemPageProvider.ts +8 -0
  68. package/lib/server/routes/api/components.ts +9 -4
  69. package/lib/server/routes/api/core-routes.ts +2 -2
  70. package/lib/server/routes/api/pages.ts +14 -22
  71. package/lib/server/routes/api/shared.ts +56 -0
  72. package/lib/server/routes/index.ts +90 -0
  73. package/lib/server/routes/pages.ts +13 -6
  74. package/lib/server/services/componentService.test.ts +199 -2
  75. package/lib/server/services/componentService.ts +354 -49
  76. package/lib/server/services/fileWatcherService.ts +4 -24
  77. package/lib/server/services/pageService.test.ts +23 -0
  78. package/lib/server/services/pageService.ts +124 -6
  79. package/lib/server/ssr/attributeBuilder.ts +8 -2
  80. package/lib/server/ssr/buildErrorOverlay.ts +1 -1
  81. package/lib/server/ssr/errorOverlay.test.ts +21 -2
  82. package/lib/server/ssr/errorOverlay.ts +38 -11
  83. package/lib/server/ssr/htmlGenerator.test.ts +53 -13
  84. package/lib/server/ssr/htmlGenerator.ts +71 -27
  85. package/lib/server/ssr/liveReloadIntegration.test.ts +123 -2
  86. package/lib/server/ssr/ssrRenderer.test.ts +67 -0
  87. package/lib/server/ssr/ssrRenderer.ts +94 -9
  88. package/lib/server/websocketManager.ts +0 -1
  89. package/lib/shared/componentRefs.ts +45 -0
  90. package/lib/shared/constants.ts +8 -0
  91. package/lib/shared/cssGeneration.ts +2 -0
  92. package/lib/shared/cssProperties.ts +184 -0
  93. package/lib/shared/expressionEvaluator.ts +54 -0
  94. package/lib/shared/fontCss.ts +101 -0
  95. package/lib/shared/fontLoader.ts +8 -86
  96. package/lib/shared/friendlyError.test.ts +87 -0
  97. package/lib/shared/friendlyError.ts +121 -0
  98. package/lib/shared/hrefRefs.test.ts +130 -0
  99. package/lib/shared/hrefRefs.ts +100 -0
  100. package/lib/shared/index.ts +52 -0
  101. package/lib/shared/inlineSvgStyleRules.test.ts +108 -0
  102. package/lib/shared/inlineSvgStyleRules.ts +134 -0
  103. package/lib/shared/interfaces/contentProvider.ts +13 -0
  104. package/lib/shared/itemTemplateUtils.test.ts +14 -0
  105. package/lib/shared/itemTemplateUtils.ts +4 -1
  106. package/lib/shared/registry/NodeTypeDefinition.ts +1 -1
  107. package/lib/shared/registry/nodeTypes/LinkNodeType.ts +1 -1
  108. package/lib/shared/slugTranslator.test.ts +24 -0
  109. package/lib/shared/slugTranslator.ts +24 -0
  110. package/lib/shared/styleNodeUtils.ts +4 -1
  111. package/lib/shared/tree/PathBuilder.test.ts +128 -1
  112. package/lib/shared/tree/PathBuilder.ts +83 -31
  113. package/lib/shared/types/comment.ts +99 -0
  114. package/lib/shared/types/index.ts +12 -0
  115. package/lib/shared/types/rendering.ts +8 -0
  116. package/lib/shared/utilityClassConfig.ts +4 -2
  117. package/lib/shared/utilityClassMapper.test.ts +24 -0
  118. package/lib/shared/validation/commentValidators.ts +69 -0
  119. package/lib/shared/validation/index.ts +1 -0
  120. package/lib/shared/viewportUnits.integration.test.ts +42 -0
  121. package/lib/shared/viewportUnits.test.ts +103 -0
  122. package/lib/shared/viewportUnits.ts +63 -0
  123. package/lib/test-utils/dom-setup.ts +6 -0
  124. package/package.json +1 -1
  125. package/dist/chunks/chunk-2QK6U5UK.js.map +0 -7
  126. package/dist/chunks/chunk-7NIC4I3V.js.map +0 -7
  127. package/dist/chunks/chunk-A725KYFK.js.map +0 -7
  128. package/dist/chunks/chunk-CXCBV2M7.js.map +0 -7
  129. package/dist/chunks/chunk-EDQSMAMP.js.map +0 -7
  130. package/dist/chunks/chunk-H4JSCDNW.js.map +0 -7
  131. package/dist/chunks/chunk-HNLUO36W.js.map +0 -7
  132. package/dist/chunks/chunk-J23ZX5AP.js.map +0 -7
  133. package/dist/chunks/chunk-LHLHPYSP.js.map +0 -7
  134. package/dist/chunks/chunk-LPVETICS.js.map +0 -7
  135. /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
- if (value === undefined) return undefined;
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
@@ -48,7 +48,7 @@ export const LinkNodeType = createNodeType({
48
48
  },
49
49
 
50
50
  treeDisplay: {
51
- icon: 'HTML_ELEMENT',
51
+ icon: 'LINK',
52
52
  getLabel: () => 'Link',
53
53
  },
54
54
 
@@ -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 { buildTreePathsWithRendered, buildComponentTreePaths, type NavigationContext } from './PathBuilder';
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
- let cumulativeInstancePath: Path | null = null;
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
- let cumulativeInstancePath: Path | null = null;
322
- if (navigationHistory && navigationHistory.length > 0) {
323
- cumulativeInstancePath = navigationHistory[0].componentInstanceRenderedPath ?? navigationHistory[0].componentInstancePath;
324
-
325
- for (let i = 1; i < navigationHistory.length; i++) {
326
- const nextPath = navigationHistory[i].componentInstanceRenderedPath ?? navigationHistory[i].componentInstancePath;
327
- if (nextPath && cumulativeInstancePath) {
328
- cumulativeInstancePath = [
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-e',
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-s',
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', () => {