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
@@ -32,6 +32,7 @@ import type { BreakpointConfig } from '../../shared/breakpoints';
32
32
  import type { ResponsiveScales } from '../../shared/responsiveScaling';
33
33
  import { generateElementClassName, type ElementClassContext } from '../../shared/elementClassName';
34
34
  import { isVoidElement, hasIf, hasChildren, isSlotContent as isSlotContentNode } from '../../shared/nodeUtils';
35
+ import { deepMergeStyles } from '../../shared/styleNodeUtils';
35
36
  import { NODE_TYPE, RAW_HTML_PREFIX } from '../../shared/constants';
36
37
  import { extractInteractiveStyleMappings, hasInteractiveStyleMappings } from '../../shared/interactiveStyleMappings';
37
38
  import type { ImageMetadataMap } from '../ssr/imageMetadata';
@@ -94,6 +95,18 @@ export interface AstroEmitContext {
94
95
  cmsSchema?: CMSSchema;
95
96
  /** Wrap function name for i18n resolution (e.g., 'r') */
96
97
  cmsWrapFn?: string;
98
+ /**
99
+ * Names of components that consume the CMS entry. When emitting an instance
100
+ * of one of these inside a CMS context, thread `cms={<entry binding>}` so the
101
+ * component (which has an isolated Astro scope) can resolve `{{cms.*}}`.
102
+ */
103
+ cmsConsumers?: Set<string>;
104
+ /**
105
+ * collectionId → JS template literal (over entry `e`) for an item's `_url`,
106
+ * used when flattening `sourceType: 'collection'` lists so list items expose
107
+ * a usable link (`{{item._url}}`).
108
+ */
109
+ collectionUrlExpr?: Map<string, string>;
97
110
  /** Default locale for i18n resolver (used in component defs) */
98
111
  defaultLocale?: string;
99
112
  /** Set to true during emission when an i18n object is encountered in a component def */
@@ -139,6 +152,16 @@ function ind(ctx: AstroEmitContext): string {
139
152
  return ' '.repeat(ctx.indent);
140
153
  }
141
154
 
155
+ /**
156
+ * Build the runtime href expression for a link-typed value. A link value may be
157
+ * an object `{ href, target? }` or a bare string URL — the prop validator
158
+ * coerces strings to `{ href }`, so collection lists and CMS fields legitimately
159
+ * pass a string (e.g. `post._url`). Mirror that coercion at render time.
160
+ */
161
+ function linkHrefExpr(expr: string): string {
162
+ return `(typeof ${expr} === 'string' ? ${expr} : ${expr}?.href) ?? "#"`;
163
+ }
164
+
142
165
  function localizeHref(href: string, ctx: AstroEmitContext): string {
143
166
  if (!href.startsWith('/') || href.startsWith('//')) return href;
144
167
  const { locale, i18nDefaultLocale, slugMappings } = ctx;
@@ -505,6 +528,70 @@ function buildElementClass(
505
528
  });
506
529
  }
507
530
 
531
+ function isResponsiveStyleObject(
532
+ style: StyleObject | ResponsiveStyleObject
533
+ ): style is ResponsiveStyleObject {
534
+ return 'base' in style || 'tablet' in style || 'mobile' in style;
535
+ }
536
+
537
+ /**
538
+ * The style on a component's root element — `node.style` for HTML/embed/list/
539
+ * link roots, or `props.style` for a component/link-instance root. Used to
540
+ * replicate SSR's per-breakpoint merge of an instance override into the root.
541
+ */
542
+ function getComponentRootStyle(
543
+ def: ComponentDefinition | undefined
544
+ ): StyleObject | ResponsiveStyleObject | undefined {
545
+ const root = def?.component?.structure as
546
+ | { style?: unknown; props?: { style?: unknown } }
547
+ | undefined;
548
+ if (!root || typeof root !== 'object') return undefined;
549
+ const s = (root.style ?? root.props?.style);
550
+ return s && typeof s === 'object' ? (s as StyleObject | ResponsiveStyleObject) : undefined;
551
+ }
552
+
553
+ /**
554
+ * True when a flat style has any property, or any responsive branch
555
+ * (base/tablet/mobile/custom breakpoint) is non-empty. Guards against emitting
556
+ * an empty `.class {}` rule / dangling `class=""` for the common empty override
557
+ * shape `{ base:{}, tablet:{}, mobile:{} }`.
558
+ */
559
+ function hasNonEmptyStyle(style: StyleObject | ResponsiveStyleObject): boolean {
560
+ if (isResponsiveStyleObject(style)) {
561
+ return Object.values(style).some(
562
+ (branch) => !!branch && typeof branch === 'object' && Object.keys(branch as object).length > 0
563
+ );
564
+ }
565
+ return Object.keys(style).length > 0;
566
+ }
567
+
568
+ /**
569
+ * Drop properties whose value is a `{{...}}` template expression — these can't
570
+ * be serialized to static CSS. Recurses into responsive branches. Template-
571
+ * expression instance overrides are a pre-existing unsupported case; this just
572
+ * avoids emitting invalid CSS like `padding: {{x}}`.
573
+ */
574
+ function stripTemplateExpressionStyle<T extends StyleObject | ResponsiveStyleObject>(style: T): T {
575
+ const cleanFlat = (s: Record<string, unknown>): Record<string, unknown> => {
576
+ const out: Record<string, unknown> = {};
577
+ for (const [k, v] of Object.entries(s)) {
578
+ if (typeof v === 'string' && hasTemplates(v)) continue;
579
+ out[k] = v;
580
+ }
581
+ return out;
582
+ };
583
+ if (isResponsiveStyleObject(style)) {
584
+ const out: Record<string, unknown> = {};
585
+ for (const [bp, branch] of Object.entries(style as Record<string, unknown>)) {
586
+ out[bp] = branch && typeof branch === 'object'
587
+ ? cleanFlat(branch as Record<string, unknown>)
588
+ : branch;
589
+ }
590
+ return out as T;
591
+ }
592
+ return cleanFlat(style as Record<string, unknown>) as T;
593
+ }
594
+
508
595
 
509
596
  /**
510
597
  * Build HTML attributes string from node attributes
@@ -529,7 +616,7 @@ function buildAttributesString(
529
616
  if (ctx.listIndexVar) expr = replaceItemMetaVars(expr, ctx.listIndexVar, ctx.listSourceVar);
530
617
  const propDef = ctx.componentProps[expr];
531
618
  if (propDef && propDef.type === 'link') {
532
- parts.push(`${key}={${expr}?.href ?? "#"}`);
619
+ parts.push(`${key}={${linkHrefExpr(expr)}}`);
533
620
  } else {
534
621
  parts.push(`${key}={${expr} || undefined}`);
535
622
  }
@@ -540,7 +627,7 @@ function buildAttributesString(
540
627
  if (ctx.listItemBinding) trimmed = rewriteItemVar(trimmed, ctx.listItemBinding);
541
628
  if (ctx.listIndexVar) trimmed = replaceItemMetaVars(trimmed, ctx.listIndexVar, ctx.listSourceVar);
542
629
  const pd = ctx.componentProps[trimmed];
543
- return pd?.type === 'link' ? `\${${trimmed}?.href ?? "#"}` : `\${${trimmed}}`;
630
+ return pd?.type === 'link' ? `\${${linkHrefExpr(trimmed)}}` : `\${${trimmed}}`;
544
631
  });
545
632
  parts.push(`${key}={\`${resolved}\`}`);
546
633
  }
@@ -1310,11 +1397,54 @@ function emitComponentInstance(node: ComponentInstanceNode, ctx: AstroEmitContex
1310
1397
  }
1311
1398
  }
1312
1399
 
1313
- // Instance-level style overrides as className (Tailwind)
1314
- if (node.style) {
1315
- const { classes: instanceClasses } = responsiveStylesToTailwind(node.style as StyleObject | ResponsiveStyleObject, ctx.breakpoints, ctx.responsiveScales);
1316
- if (instanceClasses.length > 0) {
1317
- propParts.push(`class="${instanceClasses.join(' ')}"`);
1400
+ // Thread the CMS entry into consumer components. Astro components have
1401
+ // isolated scopes, so a component referencing `{{cms.*}}` can't see the
1402
+ // page's `entry`/`cms` binding unless we pass it explicitly. The binding is
1403
+ // `entry` at the CMS page level and `cms` inside a consumer component, so
1404
+ // this also forwards correctly through nested consumers.
1405
+ if (ctx.cmsMode && ctx.cmsConsumers?.has(node.component) && !('cms' in (node.props ?? {}))) {
1406
+ propParts.push(`cms={${ctx.cmsEntryBinding || 'entry'}}`);
1407
+ }
1408
+
1409
+ // Instance-level style overrides (+ interactive styles) via element-class CSS.
1410
+ // Route them through the same machinery the export uses for hover styles: an
1411
+ // UNLAYERED `<style is:global>` rule, which beats Tailwind v4's `@layer
1412
+ // utilities` deterministically — so the instance override wins over the
1413
+ // component's base utilities. This mirrors live SSR, which deep-merges the
1414
+ // instance style into the component root. (The previous approach appended the
1415
+ // override as more utility classes at equal specificity, so CSS output order
1416
+ // decided the winner and the override frequently lost.)
1417
+ {
1418
+ const instanceStyle = node.style as StyleObject | ResponsiveStyleObject | undefined;
1419
+ const instanceInteractive = node.interactiveStyles as InteractiveStyles | undefined;
1420
+ const hasStyle = !!instanceStyle && hasNonEmptyStyle(instanceStyle);
1421
+ const hasInteractive = Array.isArray(instanceInteractive) && instanceInteractive.length > 0;
1422
+
1423
+ if ((hasStyle || hasInteractive) && ctx.collectedInteractiveStyles) {
1424
+ const elementClass = buildElementClass(ctx, node.label);
1425
+ const rules: InteractiveStyles = [];
1426
+ if (hasStyle) {
1427
+ // Per-breakpoint merge the component's root style UNDER the instance
1428
+ // override (mirrors SSR's mergeNodeStyles). This keeps the component's
1429
+ // own responsive breakpoint values for properties the instance only sets
1430
+ // at `base`, so a base override doesn't leak past tablet/mobile. The
1431
+ // merged rule is unlayered, so it fully shadows the component's own
1432
+ // (layered) root utilities. `_mapping`/`{{…}}` values carried in from the
1433
+ // root are dropped downstream (styleObjectToCSS skips mappings) /
1434
+ // stripped here, falling back to the component's own emission.
1435
+ const rootStyle = getComponentRootStyle(ctx.globalComponents[node.component]);
1436
+ const merged = (rootStyle
1437
+ ? deepMergeStyles(rootStyle, instanceStyle!)
1438
+ : instanceStyle!) as StyleObject | ResponsiveStyleObject;
1439
+ const cssSafe = stripTemplateExpressionStyle(merged);
1440
+ if (hasNonEmptyStyle(cssSafe)) rules.push({ style: cssSafe }); // no prefix/postfix → `.cls {…}`
1441
+ }
1442
+ if (hasInteractive) rules.push(...instanceInteractive!); // fold hover/etc. into the same class
1443
+ if (rules.length > 0) {
1444
+ const existing = ctx.collectedInteractiveStyles.get(elementClass);
1445
+ ctx.collectedInteractiveStyles.set(elementClass, existing ? [...existing, ...rules] : rules);
1446
+ propParts.push(`class="${elementClass}"`);
1447
+ }
1318
1448
  }
1319
1449
  }
1320
1450
 
@@ -1461,8 +1591,8 @@ function emitLinkNode(node: LinkNode, ctx: AstroEmitContext): string {
1461
1591
  if (isLinkMapping(nodeHref)) {
1462
1592
  if (ctx.isComponentDef) {
1463
1593
  const propRef = (nodeHref as LinkMapping).prop;
1464
- // Link props are objects with {href, target?}
1465
- hrefAttr = ` href={${propRef}?.href ?? "#"}`;
1594
+ // Link props are objects with {href, target?} — or a coerced string URL
1595
+ hrefAttr = ` href={${linkHrefExpr(propRef)}}`;
1466
1596
  } else {
1467
1597
  hrefAttr = ' href="#"';
1468
1598
  }
@@ -1476,7 +1606,7 @@ function emitLinkNode(node: LinkNode, ctx: AstroEmitContext): string {
1476
1606
  if (ctx.listIndexVar) expr = replaceItemMetaVars(expr, ctx.listIndexVar, ctx.listSourceVar);
1477
1607
  const propDef = ctx.componentProps[expr];
1478
1608
  if (propDef && propDef.type === 'link') {
1479
- hrefAttr = ` href={${expr}?.href ?? "#"}`;
1609
+ hrefAttr = ` href={${linkHrefExpr(expr)}}`;
1480
1610
  } else {
1481
1611
  hrefAttr = ` href={${expr}}`;
1482
1612
  }
@@ -1486,7 +1616,7 @@ function emitLinkNode(node: LinkNode, ctx: AstroEmitContext): string {
1486
1616
  if (ctx.listItemBinding) trimmed = rewriteItemVar(trimmed, ctx.listItemBinding);
1487
1617
  if (ctx.listIndexVar) trimmed = replaceItemMetaVars(trimmed, ctx.listIndexVar, ctx.listSourceVar);
1488
1618
  const pd = ctx.componentProps[trimmed];
1489
- return pd?.type === 'link' ? `\${${trimmed}?.href ?? "#"}` : `\${${trimmed}}`;
1619
+ return pd?.type === 'link' ? `\${${linkHrefExpr(trimmed)}}` : `\${${trimmed}}`;
1490
1620
  });
1491
1621
  hrefAttr = ` href={\`${resolved}\`}`;
1492
1622
  }
@@ -1742,6 +1872,14 @@ function emitCollectionListNode(node: ListNode, ctx: AstroEmitContext): string {
1742
1872
  queryChain += `.then(items => items.slice(${start}${end !== undefined ? `, ${end}` : ''}))`;
1743
1873
  }
1744
1874
 
1875
+ // Flatten content-collection entries ({ id, data: {...} }) into the flat item
1876
+ // shape the templates expect ({{item.field}} → item.field), and synthesize the
1877
+ // `_url`/`_id` meta the CMS data model exposes. Filter/sort above operate on the
1878
+ // raw entries (e.data.*); this map runs last so it sees the final set.
1879
+ const urlExpr = ctx.collectionUrlExpr?.get(source)
1880
+ ?? `\`/${source}/\${e.data.slug ?? e.id}\``;
1881
+ queryChain += `.then(items => items.map((e) => ({ ...e.data, _id: e.id, _url: ${urlExpr} })))`;
1882
+
1745
1883
  ctx.frontmatterLines.push(`const ${collectionVar} = ${queryChain};`);
1746
1884
 
1747
1885
  // Build children with item binding
@@ -0,0 +1,264 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { normalizeOrphanTemplateProps } from "./normalizeOrphanTemplateProps";
3
+ import type { ComponentDefinition } from "../../shared/types";
4
+
5
+ describe("normalizeOrphanTemplateProps", () => {
6
+ test("lifts an orphan {{x}} onto the component's interface as a string default", () => {
7
+ const components: Record<string, ComponentDefinition> = {
8
+ ArrowLink: {
9
+ component: {
10
+ interface: {},
11
+ structure: {
12
+ type: "node",
13
+ tag: "span",
14
+ children: ["{{ctaText}}"]
15
+ }
16
+ }
17
+ }
18
+ };
19
+
20
+ const out = normalizeOrphanTemplateProps(components);
21
+ const arrowIface = out.ArrowLink.component!.interface!;
22
+ expect(arrowIface.ctaText).toBeDefined();
23
+ expect((arrowIface.ctaText as any).type).toBe("string");
24
+ expect((arrowIface.ctaText as any).default).toBe("");
25
+ });
26
+
27
+ test("forwards lifted props on an instance ref when the host declares the prop", () => {
28
+ const components: Record<string, ComponentDefinition> = {
29
+ ArrowLink: {
30
+ component: {
31
+ interface: {},
32
+ structure: {
33
+ type: "node",
34
+ tag: "span",
35
+ children: ["{{ctaText}}"]
36
+ }
37
+ }
38
+ },
39
+ Card: {
40
+ component: {
41
+ interface: {
42
+ ctaText: { type: "string", default: "Click" } as any
43
+ },
44
+ structure: {
45
+ type: "node",
46
+ tag: "div",
47
+ children: [
48
+ { type: "component", component: "ArrowLink" } as any
49
+ ]
50
+ }
51
+ }
52
+ }
53
+ };
54
+
55
+ const out = normalizeOrphanTemplateProps(components);
56
+ const cardChildren = (out.Card.component!.structure as any).children;
57
+ expect(cardChildren[0].props).toBeDefined();
58
+ expect(cardChildren[0].props.ctaText).toBe("{{ctaText}}");
59
+ });
60
+
61
+ test("does NOT forward when the host doesn't declare the same prop", () => {
62
+ const components: Record<string, ComponentDefinition> = {
63
+ ArrowLink: {
64
+ component: {
65
+ interface: {},
66
+ structure: { type: "node", tag: "span", children: ["{{ctaText}}"] }
67
+ }
68
+ },
69
+ // Page-level wrapper that doesn't know about ctaText.
70
+ Wrapper: {
71
+ component: {
72
+ interface: {},
73
+ structure: {
74
+ type: "node",
75
+ tag: "div",
76
+ children: [{ type: "component", component: "ArrowLink" } as any]
77
+ }
78
+ }
79
+ }
80
+ };
81
+
82
+ const out = normalizeOrphanTemplateProps(components);
83
+ const wrapperChildren = (out.Wrapper.component!.structure as any).children;
84
+ // No forward — would have made Wrapper produce a new orphan ref.
85
+ expect(wrapperChildren[0].props).toBeUndefined();
86
+ });
87
+
88
+ test("does not override an instance prop the host already passes", () => {
89
+ const components: Record<string, ComponentDefinition> = {
90
+ ArrowLink: {
91
+ component: {
92
+ interface: {},
93
+ structure: { type: "node", tag: "span", children: ["{{ctaText}}"] }
94
+ }
95
+ },
96
+ Card: {
97
+ component: {
98
+ interface: {
99
+ ctaText: { type: "string", default: "Click" } as any
100
+ },
101
+ structure: {
102
+ type: "node",
103
+ tag: "div",
104
+ children: [
105
+ {
106
+ type: "component",
107
+ component: "ArrowLink",
108
+ props: { ctaText: "Explicit value" }
109
+ } as any
110
+ ]
111
+ }
112
+ }
113
+ }
114
+ };
115
+
116
+ const out = normalizeOrphanTemplateProps(components);
117
+ const cardChildren = (out.Card.component!.structure as any).children;
118
+ expect(cardChildren[0].props.ctaText).toBe("Explicit value");
119
+ });
120
+
121
+ test("ignores cms.*, item.*, page.* refs (already namespaced)", () => {
122
+ const components: Record<string, ComponentDefinition> = {
123
+ Title: {
124
+ component: {
125
+ interface: {},
126
+ structure: {
127
+ type: "node",
128
+ tag: "h1",
129
+ children: ["{{cms.title}} - {{page.slug}}"]
130
+ }
131
+ }
132
+ }
133
+ };
134
+
135
+ const out = normalizeOrphanTemplateProps(components);
136
+ expect(Object.keys(out.Title.component!.interface!)).toHaveLength(0);
137
+ });
138
+
139
+ test("ignores list loop variables", () => {
140
+ const components: Record<string, ComponentDefinition> = {
141
+ List: {
142
+ component: {
143
+ interface: {
144
+ items: { type: "list", default: [], itemSchema: {} } as any
145
+ },
146
+ structure: {
147
+ type: "list",
148
+ sourceType: "prop",
149
+ source: "items",
150
+ itemAs: "row",
151
+ children: [
152
+ { type: "node", tag: "div", children: ["{{row.label}}"] }
153
+ ]
154
+ }
155
+ }
156
+ }
157
+ };
158
+
159
+ const out = normalizeOrphanTemplateProps(components);
160
+ // `row` is the loop var — must NOT be auto-lifted as a string prop.
161
+ expect((out.List.component!.interface as any).row).toBeUndefined();
162
+ });
163
+
164
+ test("ignores expressions with operators (not a bare ident)", () => {
165
+ const components: Record<string, ComponentDefinition> = {
166
+ Cmp: {
167
+ component: {
168
+ interface: {},
169
+ structure: {
170
+ type: "node",
171
+ tag: "div",
172
+ children: ["{{a + b}} {{x ? y : z}}"]
173
+ }
174
+ }
175
+ }
176
+ };
177
+
178
+ const out = normalizeOrphanTemplateProps(components);
179
+ // Don't lift `a`, `b`, `x`, `y`, `z` from compound expressions — too
180
+ // risky to guess types or what's actually intended.
181
+ expect(Object.keys(out.Cmp.component!.interface!)).toHaveLength(0);
182
+ });
183
+
184
+ test("does not mutate the input component definitions", () => {
185
+ const components: Record<string, ComponentDefinition> = {
186
+ ArrowLink: {
187
+ component: {
188
+ interface: {},
189
+ structure: { type: "node", tag: "span", children: ["{{ctaText}}"] }
190
+ }
191
+ }
192
+ };
193
+ const before = JSON.stringify(components);
194
+ normalizeOrphanTemplateProps(components);
195
+ expect(JSON.stringify(components)).toBe(before);
196
+ });
197
+
198
+ test("lifts a prop ref from a style _mapping object", () => {
199
+ const components: Record<string, ComponentDefinition> = {
200
+ Cmp: {
201
+ component: {
202
+ interface: {},
203
+ structure: {
204
+ type: "node",
205
+ tag: "div",
206
+ style: {
207
+ base: {
208
+ color: {
209
+ _mapping: true,
210
+ prop: "variant",
211
+ values: { default: "black", featured: "white" }
212
+ }
213
+ }
214
+ }
215
+ } as any
216
+ }
217
+ }
218
+ };
219
+
220
+ const out = normalizeOrphanTemplateProps(components);
221
+ expect((out.Cmp.component!.interface as any).variant).toBeDefined();
222
+ expect((out.Cmp.component!.interface as any).variant.type).toBe("string");
223
+ });
224
+
225
+ test("lifts a prop ref from an if boolean mapping", () => {
226
+ const components: Record<string, ComponentDefinition> = {
227
+ Cmp: {
228
+ component: {
229
+ interface: {},
230
+ structure: {
231
+ type: "node",
232
+ tag: "span",
233
+ if: {
234
+ _mapping: true,
235
+ prop: "showBadge",
236
+ values: { true: true, false: false }
237
+ }
238
+ } as any
239
+ }
240
+ }
241
+ };
242
+
243
+ const out = normalizeOrphanTemplateProps(components);
244
+ expect((out.Cmp.component!.interface as any).showBadge).toBeDefined();
245
+ });
246
+
247
+ test("dotted ref {{link.href}} lifts the base name", () => {
248
+ const components: Record<string, ComponentDefinition> = {
249
+ Anchor: {
250
+ component: {
251
+ interface: {},
252
+ structure: {
253
+ type: "node",
254
+ tag: "a",
255
+ attributes: { href: "{{link.href}}" }
256
+ }
257
+ }
258
+ }
259
+ };
260
+
261
+ const out = normalizeOrphanTemplateProps(components);
262
+ expect((out.Anchor.component!.interface as any).link).toBeDefined();
263
+ });
264
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Normalize orphan template props for Astro emission.
3
+ *
4
+ * Each Meno component becomes its own `.astro` file. Templates like
5
+ * `{{ctaText}}` are emitted as `{ctaText}` — a JSX reference to a destructured
6
+ * `Astro.props` variable. If the component's `interface` doesn't declare the
7
+ * prop, the variable isn't in scope and Astro errors at build time.
8
+ *
9
+ * SSR/Next.js handles this at runtime via `processStructure`'s `parentProps`
10
+ * fallback (see templateEngine.ts). Astro can't fall back at runtime — each
11
+ * .astro file is its own scope — so we normalize the component graph before
12
+ * emission:
13
+ *
14
+ * 1. For each component, find orphan `{{x}}` refs in its structure (not
15
+ * declared in the interface, not item/cms/page namespaced, not a list
16
+ * loop var) and add them as optional string props with `default: ""`.
17
+ *
18
+ * 2. For each component-instance ref `<X />` inside any host's structure,
19
+ * forward each of X's auto-lifted props by writing
20
+ * `props.x = "{{x}}"` on the instance — but ONLY when the host itself
21
+ * already declares `x`. This mirrors the SSR cascade's one-level scope:
22
+ * the cascade fixes the single-step host→child case, never the leap to
23
+ * a grandparent.
24
+ *
25
+ * Multi-level chains (host doesn't have the prop either) gracefully degrade
26
+ * to the lifted default ("") instead of failing the build — same outcome as
27
+ * the SSR cascade for that shape.
28
+ */
29
+
30
+ import type { ComponentDefinition, PropDefinition } from '../../shared/types';
31
+
32
+ // Bare or dotted identifier inside `{{...}}`, with whitespace allowed around
33
+ // the body. Skips anything containing operators, calls, ternaries, etc. —
34
+ // liftable refs are *only* simple prop reads.
35
+ const BARE_IDENT_BODY = /^\s*([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*\.\s*[a-zA-Z_$][a-zA-Z0-9_$]*)*\s*$/;
36
+
37
+ // Namespaces and meta-vars that are never component-instance props.
38
+ const RESERVED_BASES = new Set([
39
+ 'cms', 'item', 'page',
40
+ 'itemIndex', 'itemFirst', 'itemLast',
41
+ ]);
42
+
43
+ function recordRef(
44
+ base: string,
45
+ declared: Set<string>,
46
+ listVars: Set<string>,
47
+ out: Set<string>
48
+ ): void {
49
+ if (RESERVED_BASES.has(base)) return;
50
+ if (listVars.has(base)) return;
51
+ if (declared.has(base)) return;
52
+ out.add(base);
53
+ }
54
+
55
+ function findOrphanRefs(value: unknown, declared: Set<string>, listVars: Set<string>, out: Set<string>): void {
56
+ if (value == null) return;
57
+ if (typeof value === 'string') {
58
+ const re = /\{\{([^}]+)\}\}/g;
59
+ let m: RegExpExecArray | null;
60
+ while ((m = re.exec(value)) !== null) {
61
+ const body = m[1];
62
+ const idMatch = body.match(BARE_IDENT_BODY);
63
+ if (!idMatch) continue; // expressions, operators, calls — skip
64
+ recordRef(idMatch[1], declared, listVars, out);
65
+ }
66
+ return;
67
+ }
68
+ if (Array.isArray(value)) {
69
+ for (const v of value) findOrphanRefs(v, declared, listVars, out);
70
+ return;
71
+ }
72
+ if (typeof value !== 'object') return;
73
+ const obj = value as Record<string, unknown>;
74
+
75
+ // Mapping objects (`{ _mapping: true, prop: "variant", values: {...} }`)
76
+ // reference a component prop by name. The Astro emitter turns these into
77
+ // bare identifier references like `String(variant) === "default"`, so the
78
+ // `prop` field is just as much a binding site as a `{{variant}}` template.
79
+ // Used for style mappings, link mappings, HTML mappings, and `if` boolean
80
+ // mappings — all share the same shape.
81
+ if (obj._mapping === true && typeof obj.prop === 'string') {
82
+ recordRef(obj.prop, declared, listVars, out);
83
+ // Keep walking — `values` may contain nested templates too (rare, but
84
+ // not impossible).
85
+ }
86
+
87
+ // A `list` node introduces a new loop variable into the scope of its
88
+ // children (the template instance). Default name is `item` when
89
+ // `itemAs` is omitted.
90
+ let nextListVars = listVars;
91
+ if (obj.type === 'list') {
92
+ const itemAs = typeof obj.itemAs === 'string' && obj.itemAs ? obj.itemAs : 'item';
93
+ nextListVars = new Set(listVars);
94
+ nextListVars.add(itemAs);
95
+ }
96
+
97
+ for (const key of Object.keys(obj)) {
98
+ // Don't peek into a nested component instance's `props` — those refs
99
+ // are resolved in the host's scope, not the target's. We handle them
100
+ // when walking the host itself.
101
+ if (obj.type === 'component' && key === 'props') continue;
102
+ findOrphanRefs(obj[key], declared, nextListVars, out);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Walk a host's structure and forward each target component's lifted props
108
+ * from the host's scope. Only forwards props the host itself declares — see
109
+ * the module docstring for why.
110
+ */
111
+ function forwardLiftedPropsOnInstances(
112
+ structure: unknown,
113
+ hostProps: Set<string>,
114
+ liftedByComp: ReadonlyMap<string, ReadonlySet<string>>
115
+ ): void {
116
+ if (structure == null || typeof structure !== 'object') return;
117
+ if (Array.isArray(structure)) {
118
+ for (const s of structure) forwardLiftedPropsOnInstances(s, hostProps, liftedByComp);
119
+ return;
120
+ }
121
+ const obj = structure as Record<string, unknown>;
122
+ if (obj.type === 'component' && typeof obj.component === 'string') {
123
+ const lifted = liftedByComp.get(obj.component);
124
+ if (lifted && lifted.size > 0) {
125
+ const props = (obj.props as Record<string, unknown> | undefined) ?? {};
126
+ let changed = false;
127
+ for (const propName of lifted) {
128
+ if (propName in props) continue;
129
+ if (!hostProps.has(propName)) continue;
130
+ props[propName] = `{{${propName}}}`;
131
+ changed = true;
132
+ }
133
+ if (changed) obj.props = props;
134
+ }
135
+ }
136
+ for (const key of Object.keys(obj)) {
137
+ forwardLiftedPropsOnInstances(obj[key], hostProps, liftedByComp);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Produce a normalized copy of the component map suitable for Astro emission.
143
+ * The input map is not mutated.
144
+ */
145
+ export function normalizeOrphanTemplateProps(
146
+ components: Record<string, ComponentDefinition>
147
+ ): Record<string, ComponentDefinition> {
148
+ const next: Record<string, ComponentDefinition> = {};
149
+ const liftedByComp = new Map<string, Set<string>>();
150
+
151
+ // Pass 1: clone every component and lift orphan refs onto its interface.
152
+ for (const [name, def] of Object.entries(components)) {
153
+ // structuredClone preserves Map/Set/Date but plain JSON deep-clone is
154
+ // enough here — component defs are JSON-serializable by construction.
155
+ const cloned = structuredClone(def) as ComponentDefinition;
156
+ next[name] = cloned;
157
+
158
+ const comp = cloned.component;
159
+ if (!comp || !comp.structure) continue;
160
+ const iface = (comp.interface ?? {}) as Record<string, PropDefinition>;
161
+ const declared = new Set(Object.keys(iface));
162
+ const orphans = new Set<string>();
163
+ findOrphanRefs(comp.structure, declared, new Set(), orphans);
164
+ if (orphans.size === 0) continue;
165
+
166
+ const augmented: Record<string, PropDefinition> = { ...iface };
167
+ for (const propName of orphans) {
168
+ augmented[propName] = { type: 'string', default: '' } as PropDefinition;
169
+ }
170
+ comp.interface = augmented;
171
+ liftedByComp.set(name, orphans);
172
+ }
173
+
174
+ // Pass 2: forward lifted props from each host where the host declares the
175
+ // same prop. Mutates the cloned structures in `next`.
176
+ for (const [, hostDef] of Object.entries(next)) {
177
+ const comp = hostDef.component;
178
+ if (!comp || !comp.structure) continue;
179
+ const hostProps = new Set(Object.keys(comp.interface ?? {}));
180
+ forwardLiftedPropsOnInstances(comp.structure, hostProps, liftedByComp);
181
+ }
182
+
183
+ return next;
184
+ }