meno-core 1.0.52 → 1.0.54

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 (399) hide show
  1. package/.claude/settings.local.json +1 -3
  2. package/bin/cli.ts +48 -57
  3. package/build-astro.ts +296 -108
  4. package/build-next.ts +1374 -0
  5. package/build-static.test.ts +39 -10
  6. package/build-static.ts +127 -127
  7. package/dist/bin/cli.js +34 -38
  8. package/dist/bin/cli.js.map +2 -2
  9. package/dist/build-static.js +12 -11
  10. package/dist/chunks/chunk-2AR55GYH.js +42 -0
  11. package/dist/chunks/chunk-2AR55GYH.js.map +7 -0
  12. package/dist/chunks/{chunk-CXCBV2M7.js → chunk-2FN4UOVO.js} +581 -502
  13. package/dist/chunks/chunk-2FN4UOVO.js.map +7 -0
  14. package/dist/chunks/chunk-3XER4E5W.js +168 -0
  15. package/dist/chunks/chunk-3XER4E5W.js.map +7 -0
  16. package/dist/chunks/chunk-5ETZFREW.js +514 -0
  17. package/dist/chunks/chunk-5ETZFREW.js.map +7 -0
  18. package/dist/chunks/{chunk-2MHDV5BF.js → chunk-7E4IF5L7.js} +15 -21
  19. package/dist/chunks/chunk-7E4IF5L7.js.map +7 -0
  20. package/dist/chunks/{chunk-7NIC4I3V.js → chunk-7HWQUVTU.js} +1691 -1316
  21. package/dist/chunks/chunk-7HWQUVTU.js.map +7 -0
  22. package/dist/chunks/{chunk-EDQSMAMP.js → chunk-AE3QK5QW.js} +189 -19
  23. package/dist/chunks/chunk-AE3QK5QW.js.map +7 -0
  24. package/dist/chunks/{chunk-HNLUO36W.js → chunk-F6KTJYGV.js} +8 -8
  25. package/dist/chunks/chunk-F6KTJYGV.js.map +7 -0
  26. package/dist/chunks/{chunk-WQFG7PAH.js → chunk-FZITJSSS.js} +2 -6
  27. package/dist/chunks/chunk-FZITJSSS.js.map +7 -0
  28. package/dist/chunks/{chunk-H4JSCDNW.js → chunk-GSYYA5GX.js} +25 -2
  29. package/dist/chunks/chunk-GSYYA5GX.js.map +7 -0
  30. package/dist/chunks/{chunk-2QK6U5UK.js → chunk-HIZMY3EP.js} +12 -2
  31. package/dist/chunks/chunk-HIZMY3EP.js.map +7 -0
  32. package/dist/chunks/{chunk-AZQYF6KE.js → chunk-I2WEGYA7.js} +41 -176
  33. package/dist/chunks/chunk-I2WEGYA7.js.map +7 -0
  34. package/dist/chunks/{chunk-I7YIGZXT.js → chunk-JNO3CNLJ.js} +6 -9
  35. package/dist/chunks/chunk-JNO3CNLJ.js.map +7 -0
  36. package/dist/chunks/{chunk-UB44F4Z2.js → chunk-NVRBTSQG.js} +2 -4
  37. package/dist/chunks/chunk-NVRBTSQG.js.map +7 -0
  38. package/dist/chunks/{chunk-LHLHPYSP.js → chunk-Q4OBWKXG.js} +48 -40
  39. package/dist/chunks/chunk-Q4OBWKXG.js.map +7 -0
  40. package/dist/chunks/{chunk-A725KYFK.js → chunk-QTE32Y53.js} +780 -323
  41. package/dist/chunks/chunk-QTE32Y53.js.map +7 -0
  42. package/dist/chunks/{chunk-LPVETICS.js → chunk-STDY3OVM.js} +397 -84
  43. package/dist/chunks/chunk-STDY3OVM.js.map +7 -0
  44. package/dist/chunks/configService-PRJZF7Y6.js +14 -0
  45. package/dist/chunks/{constants-GWBAD66U.js → constants-KIQEYMAM.js} +2 -2
  46. package/dist/chunks/{fs-JGINUXGL.js → fs-ZI5JEU7V.js} +2 -2
  47. package/dist/entries/server-router.js +14 -19
  48. package/dist/entries/server-router.js.map +2 -2
  49. package/dist/lib/client/index.js +957 -356
  50. package/dist/lib/client/index.js.map +4 -4
  51. package/dist/lib/server/index.js +1538 -328
  52. package/dist/lib/server/index.js.map +4 -4
  53. package/dist/lib/shared/index.js +277 -73
  54. package/dist/lib/shared/index.js.map +4 -4
  55. package/dist/lib/shared/richtext/index.js +1 -1
  56. package/dist/lib/test-utils/index.js +38 -60
  57. package/dist/lib/test-utils/index.js.map +2 -2
  58. package/entries/client-router.tsx +14 -172
  59. package/entries/server-router.tsx +1 -7
  60. package/lib/client/ClientInitializer.ts +8 -8
  61. package/lib/client/ErrorBoundary.test.tsx +156 -151
  62. package/lib/client/ErrorBoundary.tsx +184 -121
  63. package/lib/client/componentRegistry.test.ts +96 -108
  64. package/lib/client/componentRegistry.ts +1 -2
  65. package/lib/client/contexts/ThemeContext.tsx +3 -2
  66. package/lib/client/core/ComponentBuilder.test.ts +513 -560
  67. package/lib/client/core/ComponentBuilder.ts +335 -146
  68. package/lib/client/core/ComponentRenderer.test.tsx +1 -2
  69. package/lib/client/core/ComponentRenderer.tsx +46 -33
  70. package/lib/client/core/builders/embedBuilder.ts +246 -54
  71. package/lib/client/core/builders/linkBuilder.ts +71 -44
  72. package/lib/client/core/builders/linkNodeBuilder.ts +78 -53
  73. package/lib/client/core/builders/listBuilder.ts +137 -89
  74. package/lib/client/core/builders/localeListBuilder.ts +95 -60
  75. package/lib/client/core/builders/types.ts +5 -5
  76. package/lib/client/core/cmsTemplateProcessor.ts +7 -7
  77. package/lib/client/elementRegistry.ts +3 -3
  78. package/lib/client/fontFamiliesService.test.ts +68 -0
  79. package/lib/client/fontFamiliesService.ts +69 -0
  80. package/lib/client/hmr/HMRManager.tsx +8 -0
  81. package/lib/client/hmrCssReload.ts +166 -0
  82. package/lib/client/hmrWebSocket.ts +9 -14
  83. package/lib/client/hooks/useColorVariables.test.ts +21 -21
  84. package/lib/client/hooks/useColorVariables.ts +14 -10
  85. package/lib/client/hooks/usePropertyAutocomplete.ts +3 -5
  86. package/lib/client/hooks/useVariables.ts +4 -4
  87. package/lib/client/hydration/HydrationUtils.test.ts +24 -25
  88. package/lib/client/hydration/HydrationUtils.ts +3 -4
  89. package/lib/client/i18nConfigService.test.ts +2 -7
  90. package/lib/client/i18nConfigService.ts +2 -2
  91. package/lib/client/index.ts +4 -0
  92. package/lib/client/meno-filter/MenoFilter.test.ts +19 -21
  93. package/lib/client/meno-filter/MenoFilter.ts +5 -9
  94. package/lib/client/meno-filter/bindings.ts +15 -40
  95. package/lib/client/meno-filter/init.ts +1 -1
  96. package/lib/client/meno-filter/renderer.ts +23 -29
  97. package/lib/client/meno-filter/script.generated.ts +1 -3
  98. package/lib/client/meno-filter/ui.ts +5 -5
  99. package/lib/client/meno-filter/updates.ts +15 -21
  100. package/lib/client/navigation.test.ts +159 -159
  101. package/lib/client/navigation.ts +0 -1
  102. package/lib/client/responsiveStyleResolver.test.ts +230 -228
  103. package/lib/client/responsiveStyleResolver.ts +13 -16
  104. package/lib/client/routing/RouteLoader.test.ts +25 -26
  105. package/lib/client/routing/RouteLoader.ts +30 -37
  106. package/lib/client/routing/Router.tsx +112 -18
  107. package/lib/client/scripts/ScriptExecutor.test.ts +270 -128
  108. package/lib/client/scripts/ScriptExecutor.ts +69 -33
  109. package/lib/client/services/PrefetchService.test.ts +2 -2
  110. package/lib/client/services/PrefetchService.ts +10 -24
  111. package/lib/client/styleProcessor.test.ts +9 -9
  112. package/lib/client/styleProcessor.ts +18 -15
  113. package/lib/client/styles/StyleInjector.test.ts +122 -115
  114. package/lib/client/styles/StyleInjector.ts +25 -7
  115. package/lib/client/styles/UtilityClassCollector.ts +26 -27
  116. package/lib/client/styles/cspNonce.test.ts +64 -0
  117. package/lib/client/styles/cspNonce.ts +63 -0
  118. package/lib/client/templateEngine.test.ts +600 -448
  119. package/lib/client/templateEngine.ts +205 -64
  120. package/lib/client/theme.ts +0 -1
  121. package/lib/client/utils/toast.ts +0 -1
  122. package/lib/server/__integration__/api-routes.test.ts +8 -4
  123. package/lib/server/__integration__/cms-integration.test.ts +1 -4
  124. package/lib/server/__integration__/server-lifecycle.test.ts +2 -5
  125. package/lib/server/__integration__/ssr-rendering.test.ts +47 -37
  126. package/lib/server/__integration__/static-assets.test.ts +1 -1
  127. package/lib/server/__integration__/test-helpers.ts +84 -70
  128. package/lib/server/ab/generateFunctions.ts +12 -10
  129. package/lib/server/astro/cmsPageEmitter.ts +47 -32
  130. package/lib/server/astro/componentEmitter.ts +82 -37
  131. package/lib/server/astro/cssCollector.ts +10 -26
  132. package/lib/server/astro/nodeToAstro.test.ts +1750 -30
  133. package/lib/server/astro/nodeToAstro.ts +327 -178
  134. package/lib/server/astro/normalizeOrphanTemplateProps.test.ts +260 -0
  135. package/lib/server/astro/normalizeOrphanTemplateProps.ts +176 -0
  136. package/lib/server/astro/pageEmitter.ts +9 -13
  137. package/lib/server/astro/tailwindMapper.test.ts +10 -37
  138. package/lib/server/astro/tailwindMapper.ts +33 -40
  139. package/lib/server/astro/templateTransformer.ts +14 -17
  140. package/lib/server/createServer.ts +16 -17
  141. package/lib/server/cssGenerator.test.ts +35 -44
  142. package/lib/server/cssGenerator.ts +6 -17
  143. package/lib/server/draftPageStore.ts +49 -0
  144. package/lib/server/fileWatcher.test.ts +124 -10
  145. package/lib/server/fileWatcher.ts +164 -98
  146. package/lib/server/index.ts +20 -2
  147. package/lib/server/jsonLoader.test.ts +39 -2
  148. package/lib/server/jsonLoader.ts +33 -31
  149. package/lib/server/middleware/cors.test.ts +20 -20
  150. package/lib/server/middleware/cors.ts +28 -4
  151. package/lib/server/middleware/errorHandler.test.ts +5 -3
  152. package/lib/server/middleware/errorHandler.ts +3 -8
  153. package/lib/server/middleware/index.ts +0 -1
  154. package/lib/server/middleware/logger.test.ts +7 -5
  155. package/lib/server/middleware/logger.ts +10 -22
  156. package/lib/server/pageCache.test.ts +76 -77
  157. package/lib/server/pageCache.ts +0 -1
  158. package/lib/server/projectContext.ts +4 -3
  159. package/lib/server/providers/fileSystemCMSProvider.test.ts +124 -95
  160. package/lib/server/providers/fileSystemCMSProvider.ts +35 -20
  161. package/lib/server/providers/fileSystemPageProvider.test.ts +84 -0
  162. package/lib/server/providers/fileSystemPageProvider.ts +39 -12
  163. package/lib/server/routes/api/cms.test.ts +26 -14
  164. package/lib/server/routes/api/cms.ts +9 -14
  165. package/lib/server/routes/api/components.ts +35 -34
  166. package/lib/server/routes/api/config.ts +0 -1
  167. package/lib/server/routes/api/core-routes.ts +49 -76
  168. package/lib/server/routes/api/functions.ts +8 -16
  169. package/lib/server/routes/api/index.ts +3 -6
  170. package/lib/server/routes/api/pages.ts +21 -32
  171. package/lib/server/routes/api/shared.test.ts +1 -1
  172. package/lib/server/routes/api/shared.ts +65 -6
  173. package/lib/server/routes/api/variables.test.ts +1 -3
  174. package/lib/server/routes/api/variables.ts +1 -1
  175. package/lib/server/routes/index.ts +106 -19
  176. package/lib/server/routes/pages.ts +47 -35
  177. package/lib/server/routes/static.ts +16 -4
  178. package/lib/server/runtime/bundler.ts +47 -32
  179. package/lib/server/runtime/fs.ts +3 -13
  180. package/lib/server/runtime/httpServer.ts +5 -9
  181. package/lib/server/services/ColorService.ts +32 -27
  182. package/lib/server/services/EnumService.test.ts +2 -6
  183. package/lib/server/services/EnumService.ts +7 -2
  184. package/lib/server/services/VariableService.test.ts +1 -5
  185. package/lib/server/services/VariableService.ts +6 -1
  186. package/lib/server/services/cmsService.test.ts +116 -78
  187. package/lib/server/services/cmsService.ts +24 -54
  188. package/lib/server/services/componentService.test.ts +303 -20
  189. package/lib/server/services/componentService.ts +397 -76
  190. package/lib/server/services/configService.test.ts +9 -31
  191. package/lib/server/services/configService.ts +20 -27
  192. package/lib/server/services/fileWatcherService.ts +44 -30
  193. package/lib/server/services/index.ts +0 -1
  194. package/lib/server/services/pageService.test.ts +24 -3
  195. package/lib/server/services/pageService.ts +135 -16
  196. package/lib/server/ssr/attributeBuilder.ts +24 -8
  197. package/lib/server/ssr/buildErrorOverlay.ts +12 -13
  198. package/lib/server/ssr/clientDataInjector.ts +7 -21
  199. package/lib/server/ssr/cmsSSRProcessor.ts +3 -6
  200. package/lib/server/ssr/cssCollector.ts +1 -1
  201. package/lib/server/ssr/errorOverlay.test.ts +21 -2
  202. package/lib/server/ssr/errorOverlay.ts +39 -18
  203. package/lib/server/ssr/htmlGenerator.nonce.test.ts +3 -9
  204. package/lib/server/ssr/htmlGenerator.test.ts +173 -56
  205. package/lib/server/ssr/htmlGenerator.ts +191 -112
  206. package/lib/server/ssr/imageMetadata.test.ts +3 -1
  207. package/lib/server/ssr/imageMetadata.ts +25 -19
  208. package/lib/server/ssr/jsCollector.test.ts +3 -13
  209. package/lib/server/ssr/jsCollector.ts +3 -8
  210. package/lib/server/ssr/liveReloadIntegration.test.ts +184 -22
  211. package/lib/server/ssr/metaTagGenerator.ts +2 -2
  212. package/lib/server/ssr/ssrRenderer.branches.test.ts +1103 -0
  213. package/lib/server/ssr/ssrRenderer.test.ts +263 -246
  214. package/lib/server/ssr/ssrRenderer.ts +700 -231
  215. package/lib/server/ssrRenderer.test.ts +1044 -950
  216. package/lib/server/utils/jsonLineMapper.test.ts +28 -28
  217. package/lib/server/utils/jsonLineMapper.ts +1 -1
  218. package/lib/server/validateStyleCoverage.ts +18 -20
  219. package/lib/server/webflow/buildWebflow.ts +41 -53
  220. package/lib/server/webflow/nodeToWebflow.test.ts +150 -218
  221. package/lib/server/webflow/nodeToWebflow.ts +195 -258
  222. package/lib/server/webflow/styleMapper.test.ts +15 -56
  223. package/lib/server/webflow/styleMapper.ts +33 -41
  224. package/lib/server/webflow/types.ts +1 -8
  225. package/lib/server/websocketManager.ts +16 -20
  226. package/lib/shared/attributeNodeUtils.test.ts +15 -15
  227. package/lib/shared/attributeNodeUtils.ts +5 -12
  228. package/lib/shared/breakpoints.ts +4 -11
  229. package/lib/shared/cmsQueryParser.test.ts +50 -42
  230. package/lib/shared/cmsQueryParser.ts +49 -31
  231. package/lib/shared/colorVariableUtils.test.ts +5 -5
  232. package/lib/shared/colorVariableUtils.ts +3 -8
  233. package/lib/shared/componentRefs.ts +41 -0
  234. package/lib/shared/constants.test.ts +3 -3
  235. package/lib/shared/constants.ts +12 -8
  236. package/lib/shared/cssGeneration.test.ts +262 -144
  237. package/lib/shared/cssGeneration.ts +189 -514
  238. package/lib/shared/cssNamedColors.ts +152 -30
  239. package/lib/shared/cssProperties.test.ts +5 -6
  240. package/lib/shared/cssProperties.ts +479 -111
  241. package/lib/shared/elementClassName.test.ts +109 -109
  242. package/lib/shared/elementClassName.ts +1 -1
  243. package/lib/shared/elementUtils.ts +12 -16
  244. package/lib/shared/errorLogger.ts +2 -10
  245. package/lib/shared/errors.test.ts +2 -13
  246. package/lib/shared/errors.ts +2 -8
  247. package/lib/shared/expressionEvaluator.test.ts +119 -0
  248. package/lib/shared/expressionEvaluator.ts +95 -22
  249. package/lib/shared/fontCss.ts +101 -0
  250. package/lib/shared/fontLoader.test.ts +19 -5
  251. package/lib/shared/fontLoader.ts +8 -86
  252. package/lib/shared/friendlyError.test.ts +87 -0
  253. package/lib/shared/friendlyError.ts +120 -0
  254. package/lib/shared/gradientUtils.test.ts +1 -5
  255. package/lib/shared/gradientUtils.ts +2 -6
  256. package/lib/shared/hrefRefs.test.ts +130 -0
  257. package/lib/shared/hrefRefs.ts +92 -0
  258. package/lib/shared/i18n.test.ts +1 -1
  259. package/lib/shared/i18n.ts +13 -34
  260. package/lib/shared/index.ts +56 -0
  261. package/lib/shared/inlineSvgStyleRules.test.ts +108 -0
  262. package/lib/shared/inlineSvgStyleRules.ts +132 -0
  263. package/lib/shared/interactiveStyleMappings.test.ts +11 -33
  264. package/lib/shared/interactiveStyleMappings.ts +9 -16
  265. package/lib/shared/interactiveStyles.test.ts +165 -188
  266. package/lib/shared/interfaces/contentProvider.ts +14 -1
  267. package/lib/shared/itemTemplateUtils.test.ts +20 -12
  268. package/lib/shared/itemTemplateUtils.ts +23 -36
  269. package/lib/shared/jsonRepair.ts +8 -2
  270. package/lib/shared/libraryLoader.test.ts +15 -49
  271. package/lib/shared/libraryLoader.ts +7 -22
  272. package/lib/shared/netlifyLocale404.test.ts +179 -0
  273. package/lib/shared/netlifyLocale404.ts +110 -0
  274. package/lib/shared/nodeUtils.test.ts +24 -16
  275. package/lib/shared/nodeUtils.ts +49 -19
  276. package/lib/shared/pathArrayUtils.test.ts +1 -2
  277. package/lib/shared/pathArrayUtils.ts +1 -1
  278. package/lib/shared/pathSecurity.ts +1 -1
  279. package/lib/shared/pathUtils.test.ts +4 -6
  280. package/lib/shared/pathUtils.ts +42 -48
  281. package/lib/shared/paths/Path.test.ts +2 -2
  282. package/lib/shared/paths/Path.ts +0 -1
  283. package/lib/shared/paths/PathConverter.test.ts +1 -1
  284. package/lib/shared/paths/PathConverter.ts +14 -17
  285. package/lib/shared/paths/PathUtils.ts +9 -10
  286. package/lib/shared/paths/PathValidator.test.ts +2 -15
  287. package/lib/shared/paths/PathValidator.ts +11 -9
  288. package/lib/shared/paths/index.ts +1 -2
  289. package/lib/shared/propResolver.test.ts +240 -244
  290. package/lib/shared/propResolver.ts +14 -25
  291. package/lib/shared/pxToRem.test.ts +7 -6
  292. package/lib/shared/pxToRem.ts +2 -5
  293. package/lib/shared/registry/BaseNodeTypeRegistry.test.ts +9 -5
  294. package/lib/shared/registry/ClientRegistry.ts +0 -1
  295. package/lib/shared/registry/ComponentRegistry.test.ts +43 -29
  296. package/lib/shared/registry/ComponentRegistry.ts +9 -11
  297. package/lib/shared/registry/NodeTypeDefinition.ts +15 -8
  298. package/lib/shared/registry/RegistryManager.ts +1 -2
  299. package/lib/shared/registry/SSRRegistry.ts +0 -1
  300. package/lib/shared/registry/createNodeType.ts +7 -9
  301. package/lib/shared/registry/defineNodeType.ts +2 -6
  302. package/lib/shared/registry/index.ts +0 -1
  303. package/lib/shared/registry/nodeTypes/ComponentInstanceNodeType.ts +14 -15
  304. package/lib/shared/registry/nodeTypes/EmbedNodeType.ts +18 -11
  305. package/lib/shared/registry/nodeTypes/HtmlNodeType.ts +47 -18
  306. package/lib/shared/registry/nodeTypes/LinkNodeType.ts +22 -20
  307. package/lib/shared/registry/nodeTypes/ListNodeType.ts +78 -74
  308. package/lib/shared/registry/nodeTypes/LocaleListNodeType.ts +27 -21
  309. package/lib/shared/registry/nodeTypes/SlotMarkerType.ts +6 -7
  310. package/lib/shared/registry/nodeTypes/index.ts +10 -2
  311. package/lib/shared/responsiveScaling.test.ts +15 -31
  312. package/lib/shared/responsiveScaling.ts +55 -37
  313. package/lib/shared/responsiveStyleUtils.ts +11 -13
  314. package/lib/shared/richtext/htmlToTiptap.test.ts +23 -14
  315. package/lib/shared/richtext/htmlToTiptap.ts +1 -3
  316. package/lib/shared/richtext/tiptapToHtml.test.ts +5 -6
  317. package/lib/shared/richtext/types.ts +1 -8
  318. package/lib/shared/slugTranslator.test.ts +37 -13
  319. package/lib/shared/slugTranslator.ts +31 -11
  320. package/lib/shared/slugify.ts +9 -15
  321. package/lib/shared/styleNodeUtils.test.ts +8 -8
  322. package/lib/shared/styleNodeUtils.ts +9 -11
  323. package/lib/shared/styleUtils.test.ts +87 -61
  324. package/lib/shared/styleUtils.ts +5 -6
  325. package/lib/shared/themeDefaults.test.ts +11 -11
  326. package/lib/shared/themeDefaults.ts +3 -4
  327. package/lib/shared/tree/PathBuilder.test.ts +160 -109
  328. package/lib/shared/tree/PathBuilder.ts +121 -59
  329. package/lib/shared/treePathUtils.test.ts +2 -10
  330. package/lib/shared/treePathUtils.ts +54 -59
  331. package/lib/shared/types/api.ts +1 -2
  332. package/lib/shared/types/cms.ts +25 -21
  333. package/lib/shared/types/comment.ts +132 -0
  334. package/lib/shared/types/components.ts +27 -25
  335. package/lib/shared/types/errors.test.ts +1 -6
  336. package/lib/shared/types/errors.ts +3 -7
  337. package/lib/shared/types/experiments.ts +28 -28
  338. package/lib/shared/types/index.ts +14 -2
  339. package/lib/shared/types/rendering.ts +8 -0
  340. package/lib/shared/types/styles.ts +0 -1
  341. package/lib/shared/types/variables.test.ts +4 -13
  342. package/lib/shared/types/variables.ts +48 -27
  343. package/lib/shared/types.ts +1 -2
  344. package/lib/shared/utilityClassConfig.ts +648 -319
  345. package/lib/shared/utilityClassMapper.test.ts +213 -78
  346. package/lib/shared/utilityClassMapper.ts +188 -246
  347. package/lib/shared/utilityClassNames.ts +326 -0
  348. package/lib/shared/utils.test.ts +2 -10
  349. package/lib/shared/utils.ts +19 -10
  350. package/lib/shared/validation/cmsValidators.ts +2 -1
  351. package/lib/shared/validation/commentValidators.test.ts +53 -0
  352. package/lib/shared/validation/commentValidators.ts +80 -0
  353. package/lib/shared/validation/index.ts +1 -0
  354. package/lib/shared/validation/propValidator.test.ts +18 -20
  355. package/lib/shared/validation/propValidator.ts +12 -17
  356. package/lib/shared/validation/schemas.test.ts +24 -33
  357. package/lib/shared/validation/schemas.ts +469 -344
  358. package/lib/shared/validation/validators.test.ts +1 -6
  359. package/lib/shared/validation/validators.ts +89 -68
  360. package/lib/shared/viewportUnits.integration.test.ts +46 -0
  361. package/lib/shared/viewportUnits.test.ts +91 -0
  362. package/lib/shared/viewportUnits.ts +63 -0
  363. package/lib/test-utils/dom-setup.ts +7 -1
  364. package/lib/test-utils/factories/ConsoleMockFactory.ts +3 -7
  365. package/lib/test-utils/factories/DomMockFactory.ts +7 -19
  366. package/lib/test-utils/factories/EventMockFactory.ts +7 -13
  367. package/lib/test-utils/factories/FetchMockFactory.ts +39 -57
  368. package/lib/test-utils/factories/ServerMockFactory.ts +5 -9
  369. package/lib/test-utils/factories/StoreMockFactory.ts +14 -25
  370. package/lib/test-utils/fixtures.ts +45 -45
  371. package/lib/test-utils/helpers/asyncHelpers.test.ts +15 -18
  372. package/lib/test-utils/helpers/asyncHelpers.ts +11 -20
  373. package/lib/test-utils/helpers.ts +1 -5
  374. package/lib/test-utils/index.ts +0 -4
  375. package/lib/test-utils/mockFactories.ts +12 -18
  376. package/lib/test-utils/mocks.ts +4 -2
  377. package/package.json +1 -1
  378. package/scripts/build-meno-filter.ts +1 -4
  379. package/vite.config.ts +4 -4
  380. package/dist/chunks/chunk-2MHDV5BF.js.map +0 -7
  381. package/dist/chunks/chunk-2QK6U5UK.js.map +0 -7
  382. package/dist/chunks/chunk-7NIC4I3V.js.map +0 -7
  383. package/dist/chunks/chunk-A725KYFK.js.map +0 -7
  384. package/dist/chunks/chunk-AZQYF6KE.js.map +0 -7
  385. package/dist/chunks/chunk-CXCBV2M7.js.map +0 -7
  386. package/dist/chunks/chunk-EDQSMAMP.js.map +0 -7
  387. package/dist/chunks/chunk-H4JSCDNW.js.map +0 -7
  388. package/dist/chunks/chunk-HNLUO36W.js.map +0 -7
  389. package/dist/chunks/chunk-I7YIGZXT.js.map +0 -7
  390. package/dist/chunks/chunk-J23ZX5AP.js +0 -241
  391. package/dist/chunks/chunk-J23ZX5AP.js.map +0 -7
  392. package/dist/chunks/chunk-LHLHPYSP.js.map +0 -7
  393. package/dist/chunks/chunk-LPVETICS.js.map +0 -7
  394. package/dist/chunks/chunk-UB44F4Z2.js.map +0 -7
  395. package/dist/chunks/chunk-WQFG7PAH.js.map +0 -7
  396. package/dist/chunks/configService-R3OGU2UD.js +0 -13
  397. /package/dist/chunks/{configService-R3OGU2UD.js.map → configService-PRJZF7Y6.js.map} +0 -0
  398. /package/dist/chunks/{constants-GWBAD66U.js.map → constants-KIQEYMAM.js.map} +0 -0
  399. /package/dist/chunks/{fs-JGINUXGL.js.map → fs-ZI5JEU7V.js.map} +0 -0
@@ -3,24 +3,68 @@
3
3
  * Converts JSON component structures to HTML strings for SEO-friendly initial page loads
4
4
  */
5
5
 
6
- import type { ComponentNode, ComponentDefinition, JSONPage, CMSItem, EmbedNode, LinkNode, LocaleListNode, CMSFilterCondition } from '../../shared/types';
6
+ import type {
7
+ ComponentNode,
8
+ ComponentDefinition,
9
+ JSONPage,
10
+ CMSItem,
11
+ EmbedNode,
12
+ LinkNode,
13
+ LocaleListNode,
14
+ CMSFilterCondition,
15
+ } from '../../shared/types';
7
16
  import type { TemplateContext, NestedCMSListConfig } from '../../shared/types/cms';
8
- import { processItemTemplate, processItemPropsTemplate, hasItemTemplates, buildTemplateContext, resolveItemsTemplate, resolveTemplateRawValue, addItemUrl, getNestedValue, type ValueResolver } from '../../shared/itemTemplateUtils';
17
+ import {
18
+ processItemTemplate,
19
+ processItemPropsTemplate,
20
+ hasItemTemplates,
21
+ buildTemplateContext,
22
+ resolveItemsTemplate,
23
+ resolveTemplateRawValue,
24
+ addItemUrl,
25
+ getNestedValue,
26
+ type ValueResolver,
27
+ } from '../../shared/itemTemplateUtils';
9
28
  import { singularize, isItemDraftForLocale } from '../../shared/types/cms';
10
29
  import type { ResponsiveStyleObject, StyleObject } from '../../shared/types';
11
30
  import type { BreakpointConfig } from '../../shared/breakpoints';
12
31
  import type { ResponsiveScales } from '../../shared/responsiveScaling';
13
- import { evaluateTemplate, processStructure, isResponsiveStyle, isHtmlMapping, resolveHtmlMapping } from '../../client/templateEngine';
32
+ import {
33
+ evaluateTemplate,
34
+ processStructure,
35
+ isResponsiveStyle,
36
+ isHtmlMapping,
37
+ resolveHtmlMapping,
38
+ } from '../../client/templateEngine';
14
39
  import { resolvePropsFromDefinition, isRichTextMarker, richTextMarkerToHtml } from '../../shared/propResolver';
15
40
  import { loadBreakpointConfig, loadI18nConfig } from '../jsonLoader';
16
41
  import { configService } from '../services/configService';
17
42
  import type { I18nConfig } from '../../shared/types/components';
18
- import { extractLocaleFromPath, DEFAULT_I18N_CONFIG, resolveI18nValue, buildLocalizedPath, isI18nValue } from '../../shared/i18n';
43
+ import {
44
+ extractLocaleFromPath,
45
+ DEFAULT_I18N_CONFIG,
46
+ resolveI18nValue,
47
+ buildLocalizedPath,
48
+ isI18nValue,
49
+ } from '../../shared/i18n';
19
50
  import { NODE_TYPE } from '../../shared/constants';
20
- import { isComponentNode, isHtmlNode, isLinkNode, isEmbedNode, isLocaleListNode, isListNode, markAsSlotContent, isBooleanMapping, hasIf, isSlotContent, hasChildren } from '../../shared/nodeUtils';
51
+ import {
52
+ isComponentNode,
53
+ isHtmlNode,
54
+ isLinkNode,
55
+ isEmbedNode,
56
+ isLocaleListNode,
57
+ isListNode,
58
+ markAsSlotContent,
59
+ isBooleanMapping,
60
+ hasIf,
61
+ isSlotContent,
62
+ hasChildren,
63
+ } from '../../shared/nodeUtils';
21
64
  import type { ListNode } from '../../shared/registry/nodeTypes/ListNodeType';
22
65
  import type { CMSService } from '../services/cmsService';
23
66
  import { extractAttributesFromNode, skipEmptyTemplateAttributes } from '../../shared/attributeNodeUtils';
67
+ import { inlineSvgStyleRules } from '../../shared/inlineSvgStyleRules';
24
68
  import { mergeNodeStyles } from '../../shared/styleNodeUtils';
25
69
  import { SSRRegistry } from '../../shared/registry/SSRRegistry';
26
70
  import { mergeResponsiveStyles } from '../../shared/responsiveStyleUtils';
@@ -45,18 +89,31 @@ import { responsiveStylesToClasses } from '../../shared/utilityClassMapper';
45
89
  import { validateStyleCoverage } from '../validateStyleCoverage';
46
90
  import { generateElementClassName, type ElementClassContext } from '../../shared/elementClassName';
47
91
  import type { InteractiveStyles } from '../../shared/types/styles';
48
- import { extractInteractiveStyleMappings, resolveExtractedMappings, hasInteractiveStyleMappings } from '../../shared/interactiveStyleMappings';
92
+ import {
93
+ extractInteractiveStyleMappings,
94
+ resolveExtractedMappings,
95
+ hasInteractiveStyleMappings,
96
+ } from '../../shared/interactiveStyleMappings';
49
97
  import { isCurrentLink } from '../../shared/linkUtils';
50
98
  import type { SlugMap } from '../../shared/slugTranslator';
51
99
  import { buildSlugIndex, getLocaleLinks, translatePath } from '../../shared/slugTranslator';
52
100
 
53
101
  // Import from modularized files
54
102
  import { escapeHtml, buildAttributes, buildEditorAttrs, styleToString } from './attributeBuilder';
103
+ import { logRuntimeError } from '../../shared/errorLogger';
104
+ import { toFriendlyError } from '../../shared/friendlyError';
105
+ import { rewriteViewportUnits } from '../../shared/viewportUnits';
55
106
  import { extractPageMeta, generateMetaTags } from './metaTagGenerator';
56
107
  import { collectComponentCSS } from './cssCollector';
57
108
  import { collectComponentJavaScript } from './jsCollector';
58
- import { CMSContext, processCMSTemplate, processCMSPropsTemplate, createI18nResolver, RAW_HTML_PREFIX } from './cmsSSRProcessor';
59
- import { ImageMetadataMap, DEFAULT_SIZES, buildImageMetadataMap, rewriteRichTextImages } from './imageMetadata';
109
+ import {
110
+ type CMSContext,
111
+ processCMSTemplate,
112
+ processCMSPropsTemplate,
113
+ createI18nResolver,
114
+ RAW_HTML_PREFIX,
115
+ } from './cmsSSRProcessor';
116
+ import { type ImageMetadataMap, DEFAULT_SIZES, buildImageMetadataMap, rewriteRichTextImages } from './imageMetadata';
60
117
 
61
118
  /**
62
119
  * Image preload info for generating <link rel="preload"> tags in head
@@ -83,7 +140,7 @@ export type { CMSContext } from './cmsSSRProcessor';
83
140
  function resolveI18nAttrs<T extends Record<string, unknown>>(
84
141
  attrs: T,
85
142
  locale: string | undefined,
86
- i18nConfig: I18nConfig | undefined
143
+ i18nConfig: I18nConfig | undefined,
87
144
  ): T {
88
145
  let mutated: Record<string, unknown> | null = null;
89
146
  const config = i18nConfig ?? DEFAULT_I18N_CONFIG;
@@ -166,6 +223,13 @@ interface SSRContext {
166
223
  cmsListPaths?: number[][];
167
224
  /** Name of the enclosing component instance (for data-parent-component on descendants) */
168
225
  parentComponentName?: string;
226
+ /**
227
+ * Per-page counter of how many times each component name has been rendered so
228
+ * far. Used to stamp data-meno-component / data-meno-component-instance onto
229
+ * each component's outermost element so tooling (e.g. /screenshot) can target
230
+ * `[data-meno-component="PricingSection"][data-meno-component-instance="0"]`.
231
+ */
232
+ componentInstanceCounter?: Map<string, number>;
169
233
  /**
170
234
  * Element path of the current component instance's root, in the component's local
171
235
  * (reset) coordinate space. When ctx.elementPath deeply equals this, the element
@@ -233,47 +297,37 @@ function localizeHref(href: string, ctx: SSRContext): string {
233
297
  */
234
298
  function localizeRichTextLinks(html: string, ctx: SSRContext): string {
235
299
  if (ctx.templateMode || !ctx.locale || !ctx.i18nConfig) return html;
236
- return html.replace(
237
- /<a\b([^>]*?)href=(["'])([^"']*?)\2([^>]*?)>/gi,
238
- (match, before, quote, href, after) => {
239
- if (!href.startsWith('/') || href.startsWith('//')) return match;
240
- const localized = localizeHref(href, ctx);
241
- return localized === href ? match : `<a${before}href=${quote}${localized}${quote}${after}>`;
242
- }
243
- );
300
+ return html.replace(/<a\b([^>]*?)href=(["'])([^"']*?)\2([^>]*?)>/gi, (match, before, quote, href, after) => {
301
+ if (!href.startsWith('/') || href.startsWith('//')) return match;
302
+ const localized = localizeHref(href, ctx);
303
+ return localized === href ? match : `<a${before}href=${quote}${localized}${quote}${after}>`;
304
+ });
244
305
  }
245
306
 
246
307
  /**
247
308
  * Process style templates and convert to utility classes.
248
309
  * Handles {{item.field}} patterns within list contexts.
249
310
  */
250
- function processStyleToClasses(
251
- style: StyleObject | ResponsiveStyleObject | undefined,
252
- ctx: SSRContext
253
- ): string[] {
311
+ function processStyleToClasses(style: StyleObject | ResponsiveStyleObject | undefined, ctx: SSRContext): string[] {
254
312
  if (!style) return [];
255
313
 
256
314
  let processedStyle = style;
257
315
  const templateCtx = getTemplateContext(ctx);
258
316
  if (templateCtx && !ctx.templateMode) {
259
- processedStyle = processItemPropsTemplate(
260
- style as Record<string, unknown>,
261
- templateCtx,
262
- getI18nResolver(ctx)
263
- ) as StyleObject | ResponsiveStyleObject;
317
+ processedStyle = processItemPropsTemplate(style as Record<string, unknown>, templateCtx, getI18nResolver(ctx)) as
318
+ | StyleObject
319
+ | ResponsiveStyleObject;
264
320
  }
265
321
 
266
322
  // Fluid container pattern: in fluid mode `responsiveStylesToClasses` rewrites
267
323
  // any tier whose `width === maxWidth` to use `calc(100% - var(--site-margin)*2)`
268
324
  // with auto margins.
269
- const fluidActive =
270
- ctx.responsiveScales?.enabled === true &&
271
- ctx.responsiveScales?.mode === 'fluid';
325
+ const fluidActive = ctx.responsiveScales?.enabled === true && ctx.responsiveScales?.mode === 'fluid';
272
326
 
273
- return responsiveStylesToClasses(
274
- processedStyle as ResponsiveStyleObject,
275
- { fluidActive, responsiveScales: ctx.responsiveScales }
276
- );
327
+ return responsiveStylesToClasses(processedStyle as ResponsiveStyleObject, {
328
+ fluidActive,
329
+ responsiveScales: ctx.responsiveScales,
330
+ });
277
331
  }
278
332
 
279
333
  /**
@@ -334,50 +388,88 @@ function evaluateIfCondition(node: ComponentNode, ctx: SSRContext): boolean {
334
388
  return true;
335
389
  }
336
390
 
391
+ /**
392
+ * Sentinel returned by resolveFilterValue when a {{...}} template value can't
393
+ * be resolved from the available scope. Lets resolveFilterTemplates tell
394
+ * "resolved to a real value" apart from "unresolvable" so the condition can be
395
+ * dropped from the CMS query instead of being sent as a literal template
396
+ * string — which would never match a real value and silently return nothing.
397
+ */
398
+ const UNRESOLVED_FILTER_VALUE = Symbol('unresolved-filter-value');
399
+
337
400
  /**
338
401
  * Resolve a template value like "{{category.slug}}" from a flat scope.
339
- * Returns the original value if not a template or can't be resolved.
402
+ * Returns the original value for non-template values, and
403
+ * UNRESOLVED_FILTER_VALUE when a template can't be resolved.
340
404
  */
341
405
  function resolveFilterValue(value: unknown, scope: Record<string, unknown> | undefined): unknown {
342
- if (!scope || typeof value !== 'string' || !value.startsWith('{{') || !value.endsWith('}}')) {
406
+ if (typeof value !== 'string' || !value.startsWith('{{') || !value.endsWith('}}')) {
343
407
  return value;
344
408
  }
345
409
  const path = value.slice(2, -2).trim();
346
- const resolved = getNestedValue(scope, path);
347
- return resolved !== undefined ? resolved : value;
410
+ const resolved = scope ? getNestedValue(scope, path) : undefined;
411
+ return resolved !== undefined ? resolved : UNRESOLVED_FILTER_VALUE;
348
412
  }
349
413
 
350
414
  /**
351
415
  * Resolve template values in CMS filter for nested cms-list support.
352
416
  * Handles filter values like "{{category.slug}}" against a flat scope built from
353
417
  * parent template context overlaid on host-component resolved props.
418
+ *
419
+ * Conditions whose template value can't be resolved are dropped from the query
420
+ * (a verbatim "{{x.y}}" string would match nothing) and surfaced via
421
+ * logRuntimeError — dropping a condition changes results, so users must be
422
+ * able to see why.
354
423
  */
355
424
  function resolveFilterTemplates(
356
425
  filter: CMSFilterCondition | CMSFilterCondition[] | Record<string, unknown> | undefined,
357
- scope: Record<string, unknown> | undefined
426
+ scope: Record<string, unknown> | undefined,
427
+ collection: string,
358
428
  ): CMSFilterCondition | CMSFilterCondition[] | Record<string, unknown> | undefined {
359
- if (!filter || !scope) return filter;
429
+ if (!filter) return filter;
430
+
431
+ const reportDropped = (field: unknown, template: unknown): void => {
432
+ logRuntimeError(
433
+ 'ssrRenderer.resolveFilterTemplates',
434
+ new Error(`Unresolved filter template ${String(template)} — condition dropped from "${collection}" query`),
435
+ { collection, field: String(field), template: String(template) },
436
+ );
437
+ };
360
438
 
361
439
  // Handle array of conditions
362
440
  if (Array.isArray(filter)) {
363
- return filter.map(cond => ({
364
- ...cond,
365
- value: resolveFilterValue(cond.value, scope)
366
- }));
441
+ const conditions: CMSFilterCondition[] = [];
442
+ for (const cond of filter) {
443
+ const value = resolveFilterValue(cond.value, scope);
444
+ if (value === UNRESOLVED_FILTER_VALUE) {
445
+ reportDropped(cond.field, cond.value);
446
+ continue;
447
+ }
448
+ conditions.push({ ...cond, value } as CMSFilterCondition);
449
+ }
450
+ return conditions;
367
451
  }
368
452
 
369
453
  // Handle single condition object with field/value
370
454
  if ('field' in filter && 'value' in filter) {
371
- return {
372
- ...(filter as CMSFilterCondition),
373
- value: resolveFilterValue((filter as CMSFilterCondition).value, scope)
374
- };
455
+ const cond = filter as CMSFilterCondition;
456
+ const value = resolveFilterValue(cond.value, scope);
457
+ if (value === UNRESOLVED_FILTER_VALUE) {
458
+ reportDropped(cond.field, cond.value);
459
+ return undefined;
460
+ }
461
+ return { ...cond, value } as CMSFilterCondition;
375
462
  }
376
463
 
377
464
  // Handle simple object filter: { category: "{{category.slug}}" }
378
465
  const resolved: Record<string, unknown> = {};
379
466
  for (const [key, value] of Object.entries(filter)) {
380
- resolved[key] = resolveFilterValue(value, scope);
467
+ const resolvedValue = resolveFilterValue(value, scope);
468
+ if (resolvedValue === UNRESOLVED_FILTER_VALUE) {
469
+ reportDropped(key, value);
470
+ continue;
471
+ }
472
+ resolved[key] = resolvedValue;
381
473
  }
382
474
  return resolved;
383
475
  }
@@ -412,15 +504,16 @@ async function expandRichTextComponents(html: string, ctx: SSRContext): Promise<
412
504
  .replace(/&#039;/g, "'")
413
505
  .replace(/&amp;/g, '&');
414
506
  props = JSON.parse(propsStr);
415
- } catch {
416
- // ignore parse errors
507
+ } catch (error) {
508
+ // Malformed data-meno-props JSON: fall back to interface defaults so the
509
+ // marker never crashes the page, but surface the parse failure — the
510
+ // component silently losing its instance props is otherwise invisible.
511
+ logRuntimeError('ssrRenderer.expandRichTextComponents', error, { component: componentName });
417
512
  }
418
513
 
419
514
  // Render the component using SSR registry
420
515
  if (ssrComponentRegistry.has(componentName)) {
421
- parts.push(
422
- renderComponent(componentName, props, [], {}, ctx)
423
- );
516
+ parts.push(renderComponent(componentName, props, [], {}, ctx));
424
517
  } else {
425
518
  // Keep marker for unknown components
426
519
  parts.push(match[0]);
@@ -460,8 +553,15 @@ export async function buildComponentHTML(
460
553
  cmsContext?: CMSContext,
461
554
  cmsService?: CMSService,
462
555
  isProductionBuild?: boolean,
463
- injectEditorAttrs?: boolean
464
- ): Promise<{ html: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string>; ssrFallbackCollector: Map<string, string>; processedRawHtmlCollector: Map<string, string> }> {
556
+ injectEditorAttrs?: boolean,
557
+ ): Promise<{
558
+ html: string;
559
+ interactiveStylesMap: Map<string, InteractiveStyles>;
560
+ preloadImages: PreloadImage[];
561
+ neededCollections: Set<string>;
562
+ ssrFallbackCollector: Map<string, string>;
563
+ processedRawHtmlCollector: Map<string, string>;
564
+ }> {
465
565
  // Create map to collect interactive styles during render
466
566
  const interactiveStylesMap = new Map<string, InteractiveStyles>();
467
567
  // Create array to collect high-priority images for preloading
@@ -473,7 +573,15 @@ export async function buildComponentHTML(
473
573
  // Create map to collect raw-HTML → processed-HTML for Astro exporter parity
474
574
  const processedRawHtmlCollector = new Map<string, string>();
475
575
 
476
- if (!node) return { html: '', interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector, processedRawHtmlCollector };
576
+ if (!node)
577
+ return {
578
+ html: '',
579
+ interactiveStylesMap,
580
+ preloadImages,
581
+ neededCollections,
582
+ ssrFallbackCollector,
583
+ processedRawHtmlCollector,
584
+ };
477
585
 
478
586
  // Register components for this render
479
587
  ssrComponentRegistry.merge(globalComponents);
@@ -513,7 +621,14 @@ export async function buildComponentHTML(
513
621
 
514
622
  const html = await renderNode(node, ctx);
515
623
 
516
- return { html, interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector, processedRawHtmlCollector };
624
+ return {
625
+ html,
626
+ interactiveStylesMap,
627
+ preloadImages,
628
+ neededCollections,
629
+ ssrFallbackCollector,
630
+ processedRawHtmlCollector,
631
+ };
517
632
  }
518
633
 
519
634
  /**
@@ -521,10 +636,7 @@ export async function buildComponentHTML(
521
636
  * Used when parent list has emitTemplate: true.
522
637
  * The placeholder contains the configuration and template for MenoFilter to hydrate.
523
638
  */
524
- async function renderNestedListPlaceholder(
525
- node: ListNode,
526
- ctx: SSRContext
527
- ): Promise<string> {
639
+ async function renderNestedListPlaceholder(node: ListNode, ctx: SSRContext): Promise<string> {
528
640
  // Get source - handle both string and pre-resolved array sources
529
641
  const sourceValue = node.source || node.collection;
530
642
  const sourceStr = typeof sourceValue === 'string' ? sourceValue : '';
@@ -553,17 +665,37 @@ async function renderNestedListPlaceholder(
553
665
  templateContext: undefined, // Clear context for inner item templates
554
666
  nestedCMSListMode: false, // Reset for deeper nesting levels
555
667
  };
556
- const templateContent = node.children
557
- ? await renderChildrenAsync(node.children, childTemplateCtx)
558
- : '';
668
+ const templateContent = node.children ? await renderChildrenAsync(node.children, childTemplateCtx) : '';
559
669
 
560
670
  // Escape config for attribute
561
671
  const configJson = escapeHtml(JSON.stringify(config));
562
672
 
563
673
  // Emit placeholder with embedded config and template
564
- return `<div data-cms-list-nested="true" data-collection="${escapeHtml(sourceStr)}" data-cms-config="${configJson}"${editorAttrs(ctx, { isCMSListContainer: true })}>` +
674
+ return (
675
+ `<div data-cms-list-nested="true" data-collection="${escapeHtml(sourceStr)}" data-cms-config="${configJson}"${editorAttrs(ctx, { isCMSListContainer: true })}>` +
565
676
  `<template data-nested-template>${templateContent}</template>` +
566
- `</div>`;
677
+ `</div>`
678
+ );
679
+ }
680
+
681
+ /**
682
+ * Graceful fallback for a node that threw during SSR. Keeps one bad section from
683
+ * taking down the whole page: in the editor preview (injectEditorAttrs) it emits
684
+ * a small, plain-language placeholder so the user can see which section failed;
685
+ * everywhere else (static builds, the published site) it renders nothing so real
686
+ * visitors never see an error. Carries no XRay-selectable attributes.
687
+ */
688
+ function renderNodeFallback(ctx: SSRContext, error: unknown, label: string): string {
689
+ console.error(`❌ SSR render error in ${label}:`, error);
690
+ if (!ctx.injectEditorAttrs) return '';
691
+ const friendly = toFriendlyError(error);
692
+ const text = `${friendly.title} — ${friendly.friendlyMessage}`;
693
+ return (
694
+ `<div data-meno-render-error style="margin:8px 0;padding:12px 14px;` +
695
+ `border:1px dashed #e0a3a3;border-radius:6px;background:#fdf3f3;color:#9a3b3b;` +
696
+ `font:13px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">` +
697
+ `⚠️ ${escapeHtml(text)}</div>`
698
+ );
567
699
  }
568
700
 
569
701
  /**
@@ -576,7 +708,7 @@ async function processList(node: ListNode, ctx: SSRContext): Promise<string> {
576
708
  // Determine source type (default to 'prop', but handle legacy 'cms-list' type)
577
709
  const nodeType = node.type as string;
578
710
  const isLegacyCMSList = nodeType === 'cms-list';
579
- const sourceType = isLegacyCMSList ? 'collection' : (node.sourceType || 'prop');
711
+ const sourceType = isLegacyCMSList ? 'collection' : node.sourceType || 'prop';
580
712
 
581
713
  // For collection mode, check CMS service availability
582
714
  if (sourceType === 'collection' && !ctx.cmsService) {
@@ -627,6 +759,12 @@ async function processList(node: ListNode, ctx: SSRContext): Promise<string> {
627
759
  }
628
760
  }
629
761
 
762
+ // Defend against a source that resolved to null or a non-array (e.g. an unset
763
+ // CMS field, or an array-of-ID prop accidentally coerced to a string). Without
764
+ // this, the `items.length` check below throws
765
+ // "Cannot read properties of null (reading 'length')".
766
+ if (!Array.isArray(items)) items = [];
767
+
630
768
  // Apply shared options: offset and limit
631
769
  if (node.offset && sourceType === 'prop') {
632
770
  items = items.slice(node.offset);
@@ -640,26 +778,16 @@ async function processList(node: ListNode, ctx: SSRContext): Promise<string> {
640
778
  }
641
779
 
642
780
  // Get schema for URL computation (collection mode only)
643
- const schema = sourceType === 'collection' && ctx.cmsService
644
- ? ctx.cmsService.getSchema(source)
645
- : undefined;
781
+ const schema = sourceType === 'collection' && ctx.cmsService ? ctx.cmsService.getSchema(source) : undefined;
646
782
 
647
783
  // Render children for each item
648
784
  const renderedItems: string[] = [];
649
785
 
650
786
  for (let i = 0; i < items.length; i++) {
651
787
  const rawItem = items[i] as Record<string, unknown>;
652
- const item = schema
653
- ? addItemUrl(rawItem as CMSItem, schema, ctx.locale, ctx.i18nConfig)
654
- : rawItem;
655
-
656
- const templateContext = buildTemplateContext(
657
- variableName,
658
- item as CMSItem,
659
- i,
660
- items.length,
661
- ctx.templateContext
662
- );
788
+ const item = schema ? addItemUrl(rawItem as CMSItem, schema, ctx.locale, ctx.i18nConfig) : rawItem;
789
+
790
+ const templateContext = buildTemplateContext(variableName, item as CMSItem, i, items.length, ctx.templateContext);
663
791
 
664
792
  const itemCtx: SSRContext = {
665
793
  ...ctx,
@@ -726,7 +854,7 @@ async function getCollectionItems(node: ListNode, source: string, ctx: SSRContex
726
854
  }
727
855
  }
728
856
  if (value !== null && value !== undefined) {
729
- resolvedIds = Array.isArray(value) ? value.map(v => String(v)) : String(value);
857
+ resolvedIds = Array.isArray(value) ? value.map((v) => String(v)) : String(value);
730
858
  }
731
859
  } else {
732
860
  // Otherwise resolve from a merged scope: parent list loop variables (highest precedence)
@@ -750,14 +878,18 @@ async function getCollectionItems(node: ListNode, source: string, ctx: SSRContex
750
878
  }
751
879
  // Filter draft items from ID-based queries
752
880
  if (ctx.locale) {
753
- items = items.filter(item => !isItemDraftForLocale(item, ctx.locale!));
881
+ items = items.filter((item) => !isItemDraftForLocale(item, ctx.locale!));
754
882
  }
755
883
  } else {
756
884
  // Build query from node props (resolve filter templates against parent loop variables
757
885
  // overlaid on host-component resolved props, with parent loop variables winning).
758
886
  const query = {
759
887
  collection: source,
760
- filter: resolveFilterTemplates(node.filter as CMSFilterCondition | CMSFilterCondition[] | Record<string, unknown> | undefined, buildListResolutionScope(ctx)),
888
+ filter: resolveFilterTemplates(
889
+ node.filter as CMSFilterCondition | CMSFilterCondition[] | Record<string, unknown> | undefined,
890
+ buildListResolutionScope(ctx),
891
+ source,
892
+ ),
761
893
  sort: node.sort,
762
894
  limit: node.limit,
763
895
  offset: node.offset,
@@ -769,7 +901,7 @@ async function getCollectionItems(node: ListNode, source: string, ctx: SSRContex
769
901
  // Exclude current item if option is set and we have a current CMS context
770
902
  if (node.excludeCurrentItem && ctx.cmsContext?.cms?._id) {
771
903
  const currentId = ctx.cmsContext.cms._id as string;
772
- items = items.filter(item => item._id !== currentId);
904
+ items = items.filter((item) => item._id !== currentId);
773
905
  }
774
906
 
775
907
  return items;
@@ -830,25 +962,21 @@ async function renderChildrenAsync(children: unknown[], ctx: SSRContext): Promis
830
962
  * Build element class name for a node based on its SSR context.
831
963
  * Used by embed, link, locale-list, and regular HTML nodes.
832
964
  */
833
- function buildNodeElementClass(
834
- ctx: SSRContext,
835
- label: string | undefined,
836
- isSlotContent?: boolean
837
- ): string {
965
+ function buildNodeElementClass(ctx: SSRContext, label: string | undefined, isSlotContent?: boolean): string {
838
966
  const { pagePath } = ctx;
839
967
  // Slot content uses page context (defined in page, not component)
840
968
  const useComponentContext = !isSlotContent && Boolean(ctx.componentContext);
841
969
  const effectiveFileType = useComponentContext ? 'component' : 'page';
842
- const effectiveFileName = useComponentContext ? ctx.componentContext : (pagePath
843
- ? pagePath.replace(/^\//, '').replace(/\//g, '_') || 'index'
844
- : 'page');
970
+ const effectiveFileName = useComponentContext
971
+ ? ctx.componentContext
972
+ : pagePath
973
+ ? pagePath.replace(/^\//, '').replace(/\//g, '_') || 'index'
974
+ : 'page';
845
975
 
846
976
  // Slice off the component-instance prefix so class hashes stay stable across
847
977
  // instances of the same component (mirrors ComponentBuilder.ts:678-680).
848
978
  const rawPath = ctx.elementPath || [];
849
- const path = useComponentContext && ctx.componentRootPath
850
- ? rawPath.slice(ctx.componentRootPath.length)
851
- : rawPath;
979
+ const path = useComponentContext && ctx.componentRootPath ? rawPath.slice(ctx.componentRootPath.length) : rawPath;
852
980
 
853
981
  const elementClassCtx: ElementClassContext = {
854
982
  fileType: effectiveFileType,
@@ -866,7 +994,7 @@ function buildNodeElementClass(
866
994
  function registerInteractiveStyles(
867
995
  ctx: SSRContext,
868
996
  elementClass: string,
869
- interactiveStyles: InteractiveStyles
997
+ interactiveStyles: InteractiveStyles,
870
998
  ): Record<string, string> {
871
999
  if (!ctx.interactiveStylesMap) return {};
872
1000
 
@@ -887,7 +1015,8 @@ function registerInteractiveStyles(
887
1015
  function buildCssVariableStyleAttr(cssVariables: Record<string, string>): string {
888
1016
  if (Object.keys(cssVariables).length === 0) return '';
889
1017
  const styleString = Object.entries(cssVariables)
890
- .map(([k, v]) => `${k}: ${v}`)
1018
+ // Route values through the viewport-unit rewriter — see viewportUnits.ts.
1019
+ .map(([k, v]) => `${k}: ${rewriteViewportUnits(v)}`)
891
1020
  .join('; ');
892
1021
  return ` style="${escapeHtml(styleString)}"`;
893
1022
  }
@@ -902,20 +1031,18 @@ function arraysEqual(a: number[] | undefined, b: number[] | undefined): boolean
902
1031
  * Compute editor-only attributes for the current context.
903
1032
  * Returns '' when injectEditorAttrs is off — preview-only feature.
904
1033
  */
905
- function editorAttrs(
906
- ctx: SSRContext,
907
- opts: { isSlotContent?: boolean; isCMSListContainer?: boolean } = {}
908
- ): string {
1034
+ function editorAttrs(ctx: SSRContext, opts: { isSlotContent?: boolean; isCMSListContainer?: boolean } = {}): string {
909
1035
  if (!ctx.injectEditorAttrs) return '';
910
1036
  // Component root: inside a component instance and at the path the instance was reset to.
911
- const isComponentRoot = !!ctx.componentContext
912
- && !opts.isSlotContent
913
- && arraysEqual(ctx.elementPath, ctx.componentRootPath);
1037
+ const isComponentRoot =
1038
+ !!ctx.componentContext && !opts.isSlotContent && arraysEqual(ctx.elementPath, ctx.componentRootPath);
914
1039
  // Mirror client semantics: at component root, parent is the outer component;
915
1040
  // for descendants of a component, parent is the current component itself.
916
1041
  const effectiveParent = opts.isSlotContent
917
1042
  ? ctx.parentComponentName
918
- : (isComponentRoot ? ctx.parentComponentName : (ctx.componentContext ?? ctx.parentComponentName));
1043
+ : isComponentRoot
1044
+ ? ctx.parentComponentName
1045
+ : (ctx.componentContext ?? ctx.parentComponentName);
919
1046
  return buildEditorAttrs({
920
1047
  elementPath: ctx.elementPath,
921
1048
  cmsItemIndexPath: ctx.cmsItemIndexPath,
@@ -933,12 +1060,19 @@ function editorAttrs(
933
1060
  */
934
1061
  async function renderNode(
935
1062
  node: ComponentNode | ComponentNode[] | string | number | null | undefined,
936
- ctx: SSRContext
1063
+ ctx: SSRContext,
937
1064
  ): Promise<string> {
938
1065
  const { breakpoints, viewportWidth, locale, i18nConfig, slugMappings, pagePath } = ctx;
939
1066
 
940
1067
  if (node === null || node === undefined) return '';
941
1068
 
1069
+ // Verbatim-code markers ({ _code, expr }) from the meno-astro dialect carry arbitrary
1070
+ // JS the model can't represent. They render natively in the Astro build; meno-core SSR
1071
+ // has nothing to evaluate, so emit nothing rather than treating the marker as an element.
1072
+ if (typeof node === 'object' && !Array.isArray(node) && (node as { _code?: unknown })._code === true) {
1073
+ return '';
1074
+ }
1075
+
942
1076
  if (typeof node === 'string' || typeof node === 'number') {
943
1077
  let text = String(node);
944
1078
 
@@ -984,10 +1118,14 @@ async function renderNode(
984
1118
  }
985
1119
 
986
1120
  if (Array.isArray(node)) {
987
- return (await Promise.all(node.map((child, index) => {
988
- const childPath = ctx.elementPath ? [...ctx.elementPath.slice(0, -1), index] : [index];
989
- return renderNode(child, { ...ctx, elementPath: childPath });
990
- }))).join('');
1121
+ return (
1122
+ await Promise.all(
1123
+ node.map((child, index) => {
1124
+ const childPath = ctx.elementPath ? [...ctx.elementPath.slice(0, -1), index] : [index];
1125
+ return renderNode(child, { ...ctx, elementPath: childPath });
1126
+ }),
1127
+ )
1128
+ ).join('');
991
1129
  }
992
1130
 
993
1131
  if (typeof node !== 'object') return '';
@@ -1001,10 +1139,7 @@ async function renderNode(
1001
1139
  const i18nResolveConfig = i18nConfig ?? DEFAULT_I18N_CONFIG;
1002
1140
  const i18nEffectiveLocale = locale || i18nResolveConfig.defaultLocale;
1003
1141
  const resolved = resolveI18nValue(node, i18nEffectiveLocale, i18nResolveConfig);
1004
- return renderNode(
1005
- resolved as ComponentNode | string | number | null | undefined,
1006
- ctx
1007
- );
1142
+ return renderNode(resolved as ComponentNode | string | number | null | undefined, ctx);
1008
1143
  }
1009
1144
 
1010
1145
  // Check if condition - skip rendering if false
@@ -1015,12 +1150,18 @@ async function renderNode(
1015
1150
  // Handle List nodes (async operation - handles both prop and collection source types)
1016
1151
  // isListNode() also matches legacy 'cms-list' type for migration
1017
1152
  if (isListNode(node)) {
1018
- return await processList(node, ctx);
1153
+ try {
1154
+ return await processList(node, ctx);
1155
+ } catch (error) {
1156
+ // A list at page-root level isn't wrapped by renderComponent's catch, so
1157
+ // guard it here too — one broken list shouldn't take down the whole page.
1158
+ return renderNodeFallback(ctx, error, 'list');
1159
+ }
1019
1160
  }
1020
1161
 
1021
1162
  const nodeType = 'type' in node ? node.type : undefined;
1022
- const nodeStyle = ('style' in node) ? node.style as StyleObject | ResponsiveStyleObject | undefined : undefined;
1023
- const children = ('children' in node) ? (node.children || []) : [];
1163
+ const nodeStyle = 'style' in node ? (node.style as StyleObject | ResponsiveStyleObject | undefined) : undefined;
1164
+ const children = 'children' in node ? node.children || [] : [];
1024
1165
 
1025
1166
  // Handle embed nodes - render custom HTML content
1026
1167
  if (isEmbedNode(node)) {
@@ -1056,11 +1197,202 @@ async function renderNode(
1056
1197
 
1057
1198
  // Sanitize HTML with allowlist for SVG, rich-text formatting, and common elements (same as client)
1058
1199
  const purify = getDOMPurify();
1059
- const sanitizedHtml = purify ? purify.sanitize(htmlContent, {
1060
- ALLOWED_TAGS: ['svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'text', 'tspan', 'image', 'defs', 'use', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'mask', 'pattern', 'marker', 'symbol', 'a', 'div', 'span', 'p', 'br', 'button', 'img', 'iframe', 'video', 'audio', 'source', 'canvas', 'b', 'i', 'u', 'strong', 'em', 'sub', 'sup', 'mark', 's', 'small', 'del', 'ins', 'q', 'abbr', 'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'style', 'animate', 'animateTransform', 'animateMotion', 'set'],
1061
- ALLOWED_ATTR: ['class', 'id', 'style', 'width', 'height', 'viewBox', 'xmlns', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'd', 'cx', 'cy', 'r', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'points', 'href', 'src', 'alt', 'target', 'rel', 'data-*', 'aria-*', 'transform', 'opacity', 'fill-opacity', 'stroke-opacity', 'font-size', 'font-family', 'text-anchor', 'dominant-baseline', 'offset', 'stop-color', 'stop-opacity', 'frameborder', 'allowfullscreen', 'allow', 'title', 'attributeName', 'values', 'dur', 'begin', 'end', 'repeatCount', 'repeatDur', 'keyTimes', 'keySplines', 'calcMode', 'from', 'to', 'by', 'additive', 'accumulate', 'type', 'rotate', 'keyPoints', 'path'],
1062
- KEEP_CONTENT: true
1063
- }) : htmlContent;
1200
+ const purifiedHtml = purify
1201
+ ? purify.sanitize(htmlContent, {
1202
+ ALLOWED_TAGS: [
1203
+ 'svg',
1204
+ 'path',
1205
+ 'circle',
1206
+ 'rect',
1207
+ 'line',
1208
+ 'polyline',
1209
+ 'polygon',
1210
+ 'g',
1211
+ 'text',
1212
+ 'tspan',
1213
+ 'image',
1214
+ 'defs',
1215
+ 'use',
1216
+ 'linearGradient',
1217
+ 'radialGradient',
1218
+ 'stop',
1219
+ 'clipPath',
1220
+ 'mask',
1221
+ 'pattern',
1222
+ 'marker',
1223
+ 'symbol',
1224
+ 'a',
1225
+ 'div',
1226
+ 'span',
1227
+ 'p',
1228
+ 'br',
1229
+ 'button',
1230
+ 'img',
1231
+ 'iframe',
1232
+ 'video',
1233
+ 'audio',
1234
+ 'source',
1235
+ 'canvas',
1236
+ 'b',
1237
+ 'i',
1238
+ 'u',
1239
+ 'strong',
1240
+ 'em',
1241
+ 'sub',
1242
+ 'sup',
1243
+ 'mark',
1244
+ 's',
1245
+ 'small',
1246
+ 'del',
1247
+ 'ins',
1248
+ 'q',
1249
+ 'abbr',
1250
+ 'code',
1251
+ 'pre',
1252
+ 'blockquote',
1253
+ 'ul',
1254
+ 'ol',
1255
+ 'li',
1256
+ 'h1',
1257
+ 'h2',
1258
+ 'h3',
1259
+ 'h4',
1260
+ 'h5',
1261
+ 'h6',
1262
+ 'style',
1263
+ 'animate',
1264
+ 'animateTransform',
1265
+ 'animateMotion',
1266
+ 'set',
1267
+ 'filter',
1268
+ 'feGaussianBlur',
1269
+ 'feOffset',
1270
+ 'feMerge',
1271
+ 'feMergeNode',
1272
+ 'feColorMatrix',
1273
+ 'feComposite',
1274
+ 'feFlood',
1275
+ 'feMorphology',
1276
+ 'feBlend',
1277
+ 'feDropShadow',
1278
+ 'feTurbulence',
1279
+ 'feDisplacementMap',
1280
+ 'foreignObject',
1281
+ ],
1282
+ ALLOWED_ATTR: [
1283
+ 'class',
1284
+ 'id',
1285
+ 'style',
1286
+ 'width',
1287
+ 'height',
1288
+ 'viewBox',
1289
+ 'xmlns',
1290
+ 'xmlns:xlink',
1291
+ 'xlink:href',
1292
+ 'fill',
1293
+ 'stroke',
1294
+ 'stroke-width',
1295
+ 'stroke-linecap',
1296
+ 'stroke-linejoin',
1297
+ 'stroke-dasharray',
1298
+ 'stroke-dashoffset',
1299
+ 'd',
1300
+ 'cx',
1301
+ 'cy',
1302
+ 'r',
1303
+ 'x',
1304
+ 'y',
1305
+ 'x1',
1306
+ 'y1',
1307
+ 'x2',
1308
+ 'y2',
1309
+ 'points',
1310
+ 'href',
1311
+ 'src',
1312
+ 'alt',
1313
+ 'target',
1314
+ 'rel',
1315
+ 'data-*',
1316
+ 'aria-*',
1317
+ 'transform',
1318
+ 'opacity',
1319
+ 'fill-opacity',
1320
+ 'fill-rule',
1321
+ 'clip-rule',
1322
+ 'clip-path',
1323
+ 'clipPathUnits',
1324
+ 'mask',
1325
+ 'mask-type',
1326
+ 'maskUnits',
1327
+ 'maskContentUnits',
1328
+ 'patternUnits',
1329
+ 'patternContentUnits',
1330
+ 'patternTransform',
1331
+ 'gradientUnits',
1332
+ 'gradientTransform',
1333
+ 'spreadMethod',
1334
+ 'preserveAspectRatio',
1335
+ 'marker-start',
1336
+ 'marker-mid',
1337
+ 'marker-end',
1338
+ 'markerUnits',
1339
+ 'markerWidth',
1340
+ 'markerHeight',
1341
+ 'refX',
1342
+ 'refY',
1343
+ 'orient',
1344
+ 'paint-order',
1345
+ 'vector-effect',
1346
+ 'filter',
1347
+ 'filterUnits',
1348
+ 'primitiveUnits',
1349
+ 'in',
1350
+ 'in2',
1351
+ 'result',
1352
+ 'stdDeviation',
1353
+ 'flood-color',
1354
+ 'flood-opacity',
1355
+ 'stroke-opacity',
1356
+ 'font-size',
1357
+ 'font-family',
1358
+ 'font-weight',
1359
+ 'font-style',
1360
+ 'text-anchor',
1361
+ 'dominant-baseline',
1362
+ 'offset',
1363
+ 'stop-color',
1364
+ 'stop-opacity',
1365
+ 'frameborder',
1366
+ 'allowfullscreen',
1367
+ 'allow',
1368
+ 'title',
1369
+ 'attributeName',
1370
+ 'values',
1371
+ 'dur',
1372
+ 'begin',
1373
+ 'end',
1374
+ 'repeatCount',
1375
+ 'repeatDur',
1376
+ 'keyTimes',
1377
+ 'keySplines',
1378
+ 'calcMode',
1379
+ 'from',
1380
+ 'to',
1381
+ 'by',
1382
+ 'additive',
1383
+ 'accumulate',
1384
+ 'type',
1385
+ 'rotate',
1386
+ 'keyPoints',
1387
+ 'path',
1388
+ ],
1389
+ KEEP_CONTENT: true,
1390
+ })
1391
+ : htmlContent;
1392
+ // Inline simple SVG <style> rules as style="" attributes — class-scoped
1393
+ // declarations don't survive the editor preview's HMR `innerHTML` reparse
1394
+ // path (`htmlGenerator.ts:574`), but inline attributes always render.
1395
+ const sanitizedHtml = inlineSvgStyleRules(purifiedHtml);
1064
1396
  const optimizedHtml = ctx.imageMetadataMap
1065
1397
  ? rewriteRichTextImages(sanitizedHtml, ctx.imageMetadataMap, ctx.imageFormat)
1066
1398
  : sanitizedHtml;
@@ -1090,7 +1422,7 @@ async function renderNode(
1090
1422
  embedCssVariables = registerInteractiveStyles(ctx, elementClass, embedInteractiveStyles);
1091
1423
  }
1092
1424
 
1093
- let embedStyleAttr = buildCssVariableStyleAttr(embedCssVariables);
1425
+ const embedStyleAttr = buildCssVariableStyleAttr(embedCssVariables);
1094
1426
 
1095
1427
  // Add attribute className if present
1096
1428
  const attrClassName = (nodeAttributes.className || nodeAttributes.class || '') as string;
@@ -1137,7 +1469,11 @@ async function renderNode(
1137
1469
  // Link object - extract href and target
1138
1470
  // Unwrap nested link objects (e.g., {href: {href: "/path"}} from double-wrapped list item templates)
1139
1471
  let linkObj = rawValue as { href: unknown; target?: string };
1140
- while (typeof linkObj.href === 'object' && linkObj.href !== null && 'href' in (linkObj.href as Record<string, unknown>)) {
1472
+ while (
1473
+ typeof linkObj.href === 'object' &&
1474
+ linkObj.href !== null &&
1475
+ 'href' in (linkObj.href as Record<string, unknown>)
1476
+ ) {
1141
1477
  const nested = linkObj.href as { href: unknown; target?: string };
1142
1478
  if (!linkObj.target && nested.target) linkObj = { ...linkObj, target: nested.target };
1143
1479
  linkObj = { ...linkObj, href: nested.href };
@@ -1208,11 +1544,18 @@ async function renderNode(
1208
1544
  const classAttr = ` class="${escapeHtml(classNames.filter(Boolean).join(' '))}"`;
1209
1545
 
1210
1546
  const childrenHTML = Array.isArray(children)
1211
- ? (await Promise.all(children.map((child, index) => {
1212
- const childPath = ctx.elementPath ? [...ctx.elementPath, index] : [index];
1213
- return renderNode(child, { ...ctx, elementPath: childPath });
1214
- }))).join('')
1215
- : await renderNode(children as ComponentNode | string | number | null | undefined, { ...ctx, elementPath: ctx.elementPath ? [...ctx.elementPath, 0] : [0] });
1547
+ ? (
1548
+ await Promise.all(
1549
+ children.map((child, index) => {
1550
+ const childPath = ctx.elementPath ? [...ctx.elementPath, index] : [index];
1551
+ return renderNode(child, { ...ctx, elementPath: childPath });
1552
+ }),
1553
+ )
1554
+ ).join('')
1555
+ : await renderNode(children as ComponentNode | string | number | null | undefined, {
1556
+ ...ctx,
1557
+ elementPath: ctx.elementPath ? [...ctx.elementPath, 0] : [0],
1558
+ });
1216
1559
 
1217
1560
  return `<a href="${escapeHtml(String(href))}"${classAttr}${olinkStyleAttr}${attrs}${editorAttrs(ctx, { isSlotContent: isSlotContent(node) })}>${childrenHTML}</a>`;
1218
1561
  }
@@ -1223,9 +1566,9 @@ async function renderNode(
1223
1566
  }
1224
1567
 
1225
1568
  // Extract tag or component name based on node type
1226
- let tag = isHtmlNode(node) ? node.tag : undefined;
1569
+ const tag = isHtmlNode(node) ? node.tag : undefined;
1227
1570
  const componentName = isComponentNode(node) ? node.component : undefined;
1228
- let nodeProps = isComponentNode(node) ? (node.props || {}) : {};
1571
+ let nodeProps = isComponentNode(node) ? node.props || {} : {};
1229
1572
 
1230
1573
  // Process CMS templates in props
1231
1574
  if (ctx.cmsContext?.cms && Object.keys(nodeProps).length > 0) {
@@ -1248,18 +1591,29 @@ async function renderNode(
1248
1591
 
1249
1592
  // Process CMS templates in attributes (e.g., href="{{cms.link}}")
1250
1593
  if (ctx.cmsContext?.cms && Object.keys(nodeAttributes).length > 0) {
1251
- nodeAttributes = processCMSPropsTemplate(nodeAttributes as Record<string, unknown>, ctx.cmsContext.cms, locale, i18nConfig) as Record<string, string | number | boolean>;
1594
+ nodeAttributes = processCMSPropsTemplate(
1595
+ nodeAttributes as Record<string, unknown>,
1596
+ ctx.cmsContext.cms,
1597
+ locale,
1598
+ i18nConfig,
1599
+ ) as Record<string, string | number | boolean>;
1252
1600
  }
1253
1601
 
1254
1602
  // Process item templates in attributes (for CMSList context)
1255
1603
  // Skip when in templateMode to preserve {{item.field}} placeholders
1256
1604
  if (!ctx.templateMode && templateCtx && Object.keys(nodeAttributes).length > 0) {
1257
- nodeAttributes = processItemPropsTemplate(nodeAttributes, templateCtx, i18nResolver) as Record<string, string | number | boolean>;
1605
+ nodeAttributes = processItemPropsTemplate(nodeAttributes, templateCtx, i18nResolver) as Record<
1606
+ string,
1607
+ string | number | boolean
1608
+ >;
1258
1609
  }
1259
1610
 
1260
1611
  // Auto-skip attributes that were entirely template expressions and resolved to ""
1261
1612
  if (Object.keys(nodeAttributes).length > 0) {
1262
- nodeAttributes = skipEmptyTemplateAttributes(originalAttributes, nodeAttributes) as Record<string, string | number | boolean>;
1613
+ nodeAttributes = skipEmptyTemplateAttributes(originalAttributes, nodeAttributes) as Record<
1614
+ string,
1615
+ string | number | boolean
1616
+ >;
1263
1617
  }
1264
1618
 
1265
1619
  if (!tag && !componentName) return '';
@@ -1273,7 +1627,8 @@ async function renderNode(
1273
1627
  // result. This matches the client (ComponentBuilder.mergeNodeStyles) so that
1274
1628
  // an instance override of e.g. marginBottom actually replaces the structure's
1275
1629
  // marginBottom instead of fighting it as a same-specificity utility class.
1276
- const isCustomComponentNode = nodeType === NODE_TYPE.COMPONENT && isComponentNode(node) && ssrComponentRegistry.has(node.component);
1630
+ const isCustomComponentNode =
1631
+ nodeType === NODE_TYPE.COMPONENT && isComponentNode(node) && ssrComponentRegistry.has(node.component);
1277
1632
  let deferredComponentStyle: StyleObject | ResponsiveStyleObject | undefined;
1278
1633
 
1279
1634
  if (nodeStyle) {
@@ -1289,16 +1644,23 @@ async function renderNode(
1289
1644
  } else if (nodeProps.style) {
1290
1645
  // If no node.style but props have style, keep it for backward compatibility
1291
1646
  if (isResponsiveStyle(nodeProps.style) && breakpoints && viewportWidth) {
1292
- resolvedStyle = mergeResponsiveStyles(nodeProps.style as ResponsiveStyleObject, 'viewport', viewportWidth, breakpoints);
1647
+ resolvedStyle = mergeResponsiveStyles(
1648
+ nodeProps.style as ResponsiveStyleObject,
1649
+ 'viewport',
1650
+ viewportWidth,
1651
+ breakpoints,
1652
+ );
1293
1653
  } else {
1294
1654
  resolvedStyle = nodeProps.style as StyleObject;
1295
1655
  }
1296
1656
  }
1297
1657
 
1298
1658
  // Generate element-specific class if node has interactive styles or generateElementClass flag
1299
- const nodeInteractiveStyles = 'interactiveStyles' in node ? node.interactiveStyles as InteractiveStyles | undefined : undefined;
1300
- const nodeGenerateElementClass = 'generateElementClass' in node ? node.generateElementClass as boolean | undefined : undefined;
1301
- const nodeLabel = 'label' in node ? node.label as string | undefined : undefined;
1659
+ const nodeInteractiveStyles =
1660
+ 'interactiveStyles' in node ? (node.interactiveStyles as InteractiveStyles | undefined) : undefined;
1661
+ const nodeGenerateElementClass =
1662
+ 'generateElementClass' in node ? (node.generateElementClass as boolean | undefined) : undefined;
1663
+ const nodeLabel = 'label' in node ? (node.label as string | undefined) : undefined;
1302
1664
  let elementClass = '';
1303
1665
 
1304
1666
  if ((nodeInteractiveStyles && nodeInteractiveStyles.length > 0) || nodeGenerateElementClass) {
@@ -1333,7 +1695,9 @@ async function renderNode(
1333
1695
  ...(mergedClassName ? { className: mergedClassName } : {}),
1334
1696
  ...(deferredComponentStyle
1335
1697
  ? { style: deferredComponentStyle }
1336
- : Object.keys(resolvedStyle).length > 0 ? { style: resolvedStyle } : {})
1698
+ : Object.keys(resolvedStyle).length > 0
1699
+ ? { style: resolvedStyle }
1700
+ : {}),
1337
1701
  };
1338
1702
 
1339
1703
  // Check if this is a custom component
@@ -1352,13 +1716,11 @@ async function renderNode(
1352
1716
  return renderLinkNode(propsWithStyleAndAttrs, children, ctx);
1353
1717
  }
1354
1718
 
1355
- // Regular HTML element (must have tag)
1356
- if (!tag) {
1357
- console.error('Missing tag for HTML element in SSR');
1358
- return '';
1359
- }
1360
-
1361
- return renderHtmlElement(tag, propsWithStyleAndAttrs, children, ctx);
1719
+ // Regular HTML element. `tag` is necessarily set here: the `!tag &&
1720
+ // !componentName` case returned at the top of this section, and every
1721
+ // `componentName` path has already returned (isComponentNode ⇒ type ===
1722
+ // 'component', which the two component branches above handle exhaustively).
1723
+ return renderHtmlElement(tag as string, propsWithStyleAndAttrs, children, ctx);
1362
1724
  }
1363
1725
 
1364
1726
  /**
@@ -1369,7 +1731,7 @@ async function renderComponent(
1369
1731
  propsWithStyleAndAttrs: Record<string, unknown>,
1370
1732
  children: unknown,
1371
1733
  nodeAttributes: Record<string, unknown>,
1372
- ctx: SSRContext
1734
+ ctx: SSRContext,
1373
1735
  ): Promise<string> {
1374
1736
  const { locale, i18nConfig, pagePath } = ctx;
1375
1737
  const componentDef = ssrComponentRegistry.get(componentName);
@@ -1385,7 +1747,7 @@ async function renderComponent(
1385
1747
  propsWithStyleAndAttrs,
1386
1748
  children as Array<ComponentNode | string> | string | ComponentNode | null | undefined,
1387
1749
  locale,
1388
- i18nConfig
1750
+ i18nConfig,
1389
1751
  );
1390
1752
 
1391
1753
  // Process the structure with resolved props
@@ -1393,27 +1755,47 @@ async function renderComponent(
1393
1755
  // Use preserveResponsiveStyles: true to keep responsive styles intact for SSR rendering
1394
1756
  // Mark children as slot content so they use page context for element class generation
1395
1757
  // Pass parent template context (itemContext) for nested cms-list template resolution
1396
- const markedChildren = children ? markAsSlotContent(children as ComponentNode | ComponentNode[] | string | number | null | undefined) : undefined;
1758
+ const markedChildren = children
1759
+ ? markAsSlotContent(children as ComponentNode | ComponentNode[] | string | number | null | undefined)
1760
+ : undefined;
1397
1761
 
1398
1762
  const processedStructure = processStructure(
1399
1763
  structuredComponentDef.structure,
1400
- { props: resolvedProps, componentDef: structuredComponentDef, itemContext: ctx.templateContext },
1764
+ {
1765
+ props: resolvedProps,
1766
+ componentDef: structuredComponentDef,
1767
+ itemContext: ctx.templateContext,
1768
+ // Host (outer) component's resolved props — used as fallback scope
1769
+ // for `{{x}}` templates the child's interface doesn't declare. At
1770
+ // this point `ctx.componentResolvedProps` still belongs to the
1771
+ // outer component; it's only swapped to `resolvedProps` below when
1772
+ // we recurse via `renderNode`.
1773
+ parentProps: ctx.componentResolvedProps,
1774
+ },
1401
1775
  undefined,
1402
1776
  markedChildren,
1403
- true
1777
+ true,
1404
1778
  );
1405
1779
 
1406
1780
  if (!processedStructure) return '';
1407
1781
 
1408
1782
  // Type guard: ensure processedStructure is a ComponentNode
1409
- if (typeof processedStructure === 'string' || typeof processedStructure === 'number' || Array.isArray(processedStructure)) {
1783
+ if (
1784
+ typeof processedStructure === 'string' ||
1785
+ typeof processedStructure === 'number' ||
1786
+ Array.isArray(processedStructure)
1787
+ ) {
1410
1788
  return await renderNode(processedStructure as string | number | ComponentNode[], ctx);
1411
1789
  }
1412
1790
 
1413
1791
  // Merge instance style overrides, className, and attributes
1414
1792
  // processedStructure is typically an HTML node (the component's root element)
1415
1793
  // Handle component nodes, HTML nodes, and Link nodes (Link and HTML share top-level style/attributes)
1416
- const rootNode = processedStructure as ComponentNode & { props?: Record<string, unknown>; style?: any; attributes?: Record<string, string | number | boolean> };
1794
+ const rootNode = processedStructure as ComponentNode & {
1795
+ props?: Record<string, unknown>;
1796
+ style?: any;
1797
+ attributes?: Record<string, string | number | boolean>;
1798
+ };
1417
1799
  if (isComponentNode(rootNode) || isHtmlNode(rootNode) || isLinkNode(rootNode)) {
1418
1800
  if (!rootNode.props) {
1419
1801
  rootNode.props = {};
@@ -1435,7 +1817,7 @@ async function renderComponent(
1435
1817
  const existingClass = (rootNode.attributes.class || '') as string;
1436
1818
  rootNode.attributes.class = existingClass
1437
1819
  ? `${existingClass} ${propsWithStyleAndAttrs.className}`
1438
- : propsWithStyleAndAttrs.className as string;
1820
+ : (propsWithStyleAndAttrs.className as string);
1439
1821
  } else {
1440
1822
  const existingClassName = rootNode.props.className || '';
1441
1823
  rootNode.props.className = existingClassName
@@ -1463,9 +1845,7 @@ async function renderComponent(
1463
1845
  // Add defineVars data attributes for JS prop injection
1464
1846
  const defineVars = structuredComponentDef.defineVars;
1465
1847
  if (defineVars && componentName) {
1466
- const varsToExpose = defineVars === true
1467
- ? Object.keys(structuredComponentDef.interface || {})
1468
- : defineVars;
1848
+ const varsToExpose = defineVars === true ? Object.keys(structuredComponentDef.interface || {}) : defineVars;
1469
1849
 
1470
1850
  const propsForJS: Record<string, unknown> = {};
1471
1851
  for (const varName of varsToExpose) {
@@ -1479,22 +1859,40 @@ async function renderComponent(
1479
1859
  if (!processedRoot.attributes) {
1480
1860
  processedRoot.attributes = {};
1481
1861
  }
1482
- const existingComponent = (processedRoot.attributes?.['data-component'] as string)
1483
- || (processedRoot.props?.['data-component'] as string)
1484
- || '';
1862
+ const existingComponent =
1863
+ (processedRoot.attributes?.['data-component'] as string) ||
1864
+ (processedRoot.props?.['data-component'] as string) ||
1865
+ '';
1485
1866
  processedRoot.attributes['data-component'] = existingComponent
1486
1867
  ? `${existingComponent} ${componentName}`
1487
1868
  : componentName;
1488
1869
 
1489
1870
  let existingPropsMap: Record<string, unknown> = {};
1490
- const existingPropsStr = (processedRoot.attributes?.['data-props'] as string)
1491
- || (processedRoot.props?.['data-props'] as string);
1871
+ const existingPropsStr =
1872
+ (processedRoot.attributes?.['data-props'] as string) || (processedRoot.props?.['data-props'] as string);
1492
1873
  if (existingPropsStr) {
1493
- try { existingPropsMap = JSON.parse(existingPropsStr); } catch {}
1874
+ try {
1875
+ existingPropsMap = JSON.parse(existingPropsStr);
1876
+ } catch {}
1494
1877
  }
1495
1878
  existingPropsMap[componentName] = propsForJS;
1496
1879
  processedRoot.attributes['data-props'] = JSON.stringify(existingPropsMap);
1497
1880
  }
1881
+
1882
+ // Stamp every component instance with data-meno-component(+ -instance) so
1883
+ // tooling can address it by name (e.g. /screenshot pricing PricingSection).
1884
+ // Only applies to HTML / Link roots — nested-component roots inherit via
1885
+ // their own outermost element when this recurses.
1886
+ if (componentName && (isHtmlNode(rootNode) || isLinkNode(rootNode))) {
1887
+ if (!rootNode.attributes) rootNode.attributes = {};
1888
+ if (!ctx.componentInstanceCounter) {
1889
+ ctx.componentInstanceCounter = new Map<string, number>();
1890
+ }
1891
+ const instanceIndex = ctx.componentInstanceCounter.get(componentName) ?? 0;
1892
+ ctx.componentInstanceCounter.set(componentName, instanceIndex + 1);
1893
+ rootNode.attributes['data-meno-component'] = componentName;
1894
+ rootNode.attributes['data-meno-component-instance'] = String(instanceIndex);
1895
+ }
1498
1896
  }
1499
1897
 
1500
1898
  // Render the processed component structure with component context.
@@ -1502,7 +1900,11 @@ async function renderComponent(
1502
1900
  // data-element-path values match XRay's tree paths. componentRootPath captures
1503
1901
  // the page-level path of this instance so buildNodeElementClass can slice it
1504
1902
  // off to keep class hashes component-relative across instances.
1505
- return await renderNode(processedStructure, {
1903
+ // At this point the string/number/array branches have already returned
1904
+ // (see the type guard above) and processedStructure has been handled as a
1905
+ // ComponentNode (rootNode). Cast to narrow the residual
1906
+ // `Record<string, unknown>` union member — purely type-level, no runtime change.
1907
+ return await renderNode(processedStructure as ComponentNode, {
1506
1908
  ...ctx,
1507
1909
  // The previously-active component (if any) becomes the parent for editor attrs
1508
1910
  parentComponentName: ctx.componentContext,
@@ -1510,10 +1912,8 @@ async function renderComponent(
1510
1912
  componentRootPath: ctx.elementPath,
1511
1913
  componentResolvedProps: resolvedProps,
1512
1914
  });
1513
-
1514
1915
  } catch (error) {
1515
- console.error(`❌ SSR Error rendering component ${componentName}:`, error);
1516
- return '';
1916
+ return renderNodeFallback(ctx, error, `component ${componentName}`);
1517
1917
  }
1518
1918
  }
1519
1919
 
@@ -1523,7 +1923,7 @@ async function renderComponent(
1523
1923
  async function renderLinkNode(
1524
1924
  propsWithStyleAndAttrs: Record<string, unknown>,
1525
1925
  children: unknown,
1526
- ctx: SSRContext
1926
+ ctx: SSRContext,
1527
1927
  ): Promise<string> {
1528
1928
  const to = 'to' in propsWithStyleAndAttrs ? propsWithStyleAndAttrs.to : undefined;
1529
1929
  const restProps: Record<string, unknown> = { ...propsWithStyleAndAttrs };
@@ -1532,17 +1932,22 @@ async function renderLinkNode(
1532
1932
  const href = localizeHref(rawHref, ctx);
1533
1933
 
1534
1934
  // Build class attribute from utility classes
1535
- const linkClassAttr = restProps.className
1536
- ? ` class="${escapeHtml(String(restProps.className))}"`
1537
- : '';
1935
+ const linkClassAttr = restProps.className ? ` class="${escapeHtml(String(restProps.className))}"` : '';
1538
1936
 
1539
1937
  const attrs = buildAttributes(restProps, ['style', 'className', 'to']);
1540
1938
  const childrenHTML = Array.isArray(children)
1541
- ? (await Promise.all((children as (ComponentNode | string)[]).map((child, index) => {
1542
- const childPath = ctx.elementPath ? [...ctx.elementPath, index] : [index];
1543
- return renderNode(child, { ...ctx, elementPath: childPath });
1544
- }))).join('')
1545
- : await renderNode(children as ComponentNode, { ...ctx, elementPath: ctx.elementPath ? [...ctx.elementPath, 0] : [0] });
1939
+ ? (
1940
+ await Promise.all(
1941
+ (children as (ComponentNode | string)[]).map((child, index) => {
1942
+ const childPath = ctx.elementPath ? [...ctx.elementPath, index] : [index];
1943
+ return renderNode(child, { ...ctx, elementPath: childPath });
1944
+ }),
1945
+ )
1946
+ ).join('')
1947
+ : await renderNode(children as ComponentNode, {
1948
+ ...ctx,
1949
+ elementPath: ctx.elementPath ? [...ctx.elementPath, 0] : [0],
1950
+ });
1546
1951
 
1547
1952
  return `<a href="${escapeHtml(String(href))}"${linkClassAttr}${attrs}${editorAttrs(ctx)}>${childrenHTML}</a>`;
1548
1953
  }
@@ -1554,7 +1959,7 @@ async function renderHtmlElement(
1554
1959
  tag: string,
1555
1960
  propsWithStyleAndAttrs: Record<string, unknown>,
1556
1961
  children: unknown,
1557
- ctx: SSRContext
1962
+ ctx: SSRContext,
1558
1963
  ): Promise<string> {
1559
1964
  // Build class attribute from utility classes
1560
1965
  let classValue = propsWithStyleAndAttrs.className ? String(propsWithStyleAndAttrs.className) : '';
@@ -1581,9 +1986,7 @@ async function renderHtmlElement(
1581
1986
 
1582
1987
  // For img tags, exclude image-specific props (handled specially in renderImageElement)
1583
1988
  const imageProps = ['src', 'alt', 'loading', 'width', 'height', 'sizes', 'srcset', 'fetchpriority'];
1584
- const excludeProps = tag.toLowerCase() === 'img'
1585
- ? ['style', 'className', ...imageProps]
1586
- : ['style', 'className'];
1989
+ const excludeProps = tag.toLowerCase() === 'img' ? ['style', 'className', ...imageProps] : ['style', 'className'];
1587
1990
  const attrs = buildAttributes(propsWithStyleAndAttrs, excludeProps);
1588
1991
  // `<style>` and `<script>` are HTML "raw text" elements: their content is
1589
1992
  // not parsed for entities or child elements. Running text children through
@@ -1600,22 +2003,55 @@ async function renderHtmlElement(
1600
2003
  if (typeof node === 'string') return node;
1601
2004
  if (typeof node === 'number') return String(node);
1602
2005
  if (Array.isArray(node)) return node.map(flatten).join('');
2006
+ // Verbatim-code markers ({ _code, expr }) are silent by design — they
2007
+ // render natively in the Astro build and have nothing to emit in
2008
+ // meno-core SSR (mirrors the renderNode top-level handling).
2009
+ if ((node as { _code?: unknown })._code === true) return '';
2010
+ // Raw-text elements can't contain child elements, so any other object
2011
+ // child is dropped from the output — correct per HTML semantics, but
2012
+ // authored content vanishing should be visible, so surface the drop.
2013
+ logRuntimeError(
2014
+ 'ssrRenderer.renderHtmlElement',
2015
+ new Error(`Element child dropped inside <${tag}> — raw-text elements render only string content`),
2016
+ { tag, droppedNodeType: String((node as { type?: unknown }).type ?? typeof node) },
2017
+ );
1603
2018
  return '';
1604
2019
  };
1605
2020
  childrenHTML = flatten(children as any);
1606
2021
  } else {
1607
2022
  childrenHTML = Array.isArray(children)
1608
- ? (await Promise.all((children as (ComponentNode | string)[]).map((child, index) => {
1609
- const childPath = ctx.elementPath ? [...ctx.elementPath, index] : [index];
1610
- return renderNode(child, { ...ctx, elementPath: childPath });
1611
- }))).join('')
1612
- : await renderNode(children as ComponentNode, { ...ctx, elementPath: ctx.elementPath ? [...ctx.elementPath, 0] : [0] });
2023
+ ? (
2024
+ await Promise.all(
2025
+ (children as (ComponentNode | string)[]).map((child, index) => {
2026
+ const childPath = ctx.elementPath ? [...ctx.elementPath, index] : [index];
2027
+ return renderNode(child, { ...ctx, elementPath: childPath });
2028
+ }),
2029
+ )
2030
+ ).join('')
2031
+ : await renderNode(children as ComponentNode, {
2032
+ ...ctx,
2033
+ elementPath: ctx.elementPath ? [...ctx.elementPath, 0] : [0],
2034
+ });
1613
2035
  }
1614
2036
 
1615
2037
  const ea = editorAttrs(ctx);
1616
2038
 
1617
2039
  // Self-closing tags
1618
- const voidElements = ['img', 'input', 'br', 'hr', 'meta', 'link', 'area', 'base', 'col', 'embed', 'source', 'track', 'wbr'];
2040
+ const voidElements = [
2041
+ 'img',
2042
+ 'input',
2043
+ 'br',
2044
+ 'hr',
2045
+ 'meta',
2046
+ 'link',
2047
+ 'area',
2048
+ 'base',
2049
+ 'col',
2050
+ 'embed',
2051
+ 'source',
2052
+ 'track',
2053
+ 'wbr',
2054
+ ];
1619
2055
  if (voidElements.includes(tag.toLowerCase())) {
1620
2056
  // Special handling for img tags - inject srcset and render as <picture> when AVIF available
1621
2057
  if (tag.toLowerCase() === 'img') {
@@ -1636,7 +2072,7 @@ function renderImageElement(
1636
2072
  classAttr: string,
1637
2073
  styleAttr: string,
1638
2074
  attrs: string,
1639
- ctx: SSRContext
2075
+ ctx: SSRContext,
1640
2076
  ): string {
1641
2077
  const imgProps = propsWithStyleAndAttrs;
1642
2078
  const src = imgProps.src as string | undefined;
@@ -1644,8 +2080,8 @@ function renderImageElement(
1644
2080
  const loading = imgProps.loading as string | undefined;
1645
2081
  const sizes = imgProps.sizes as string | undefined;
1646
2082
  const fetchpriority = imgProps.fetchpriority as string | undefined;
1647
- let width = imgProps.width as string | number | undefined;
1648
- let height = imgProps.height as string | number | undefined;
2083
+ const width = imgProps.width as string | number | undefined;
2084
+ const height = imgProps.height as string | number | undefined;
1649
2085
 
1650
2086
  // Get image metadata from manifest
1651
2087
  const metadata = src ? ctx.imageMetadataMap?.get(src) : undefined;
@@ -1684,14 +2120,23 @@ function renderImageElement(
1684
2120
  if (width !== undefined) imgAttrs += ` width="${escapeHtml(String(width))}"`;
1685
2121
  if (height !== undefined) imgAttrs += ` height="${escapeHtml(String(height))}"`;
1686
2122
 
1687
-
1688
2123
  // Render as <picture> element if AVIF is available and imageFormat is 'avif'
1689
2124
  // Layout classes go on <picture> (block container), image-specific classes on <img>
1690
2125
  if (metadata?.avifSrcset && ctx.imageFormat !== 'webp') {
1691
2126
  // Image-specific class prefixes that should stay on <img>
1692
2127
  const imgClassPrefixes = [
1693
- 'objf-', 'objp-', 'flt-', 'tm-', 'bs-',
1694
- 'br-', 'bt-', 'bb-', 'bl-', 'border-r-', 'bc-', 'b-'
2128
+ 'objf-',
2129
+ 'objp-',
2130
+ 'flt-',
2131
+ 'tm-',
2132
+ 'bs-',
2133
+ 'br-',
2134
+ 'bt-',
2135
+ 'bb-',
2136
+ 'bl-',
2137
+ 'border-r-',
2138
+ 'bc-',
2139
+ 'b-',
1695
2140
  ];
1696
2141
  // Opacity classes (o-NUMBER) go on img, but overflow (o-h, o-a, o-s, o-v) stays on picture
1697
2142
  const opacityPattern = /^o-\d/;
@@ -1703,25 +2148,23 @@ function renderImageElement(
1703
2148
  const pictureClasses: string[] = [];
1704
2149
 
1705
2150
  for (const cls of allClasses) {
1706
- if (imgClassPrefixes.some(prefix => cls.startsWith(prefix)) || opacityPattern.test(cls)) {
2151
+ if (imgClassPrefixes.some((prefix) => cls.startsWith(prefix)) || opacityPattern.test(cls)) {
1707
2152
  imgClasses.push(cls);
1708
2153
  } else {
1709
2154
  pictureClasses.push(cls);
1710
2155
  }
1711
2156
  }
1712
2157
 
1713
- const pictureClassAttr = pictureClasses.length > 0
1714
- ? ` class="${escapeHtml(pictureClasses.join(' '))}"`
1715
- : '';
1716
- const imgClassAttr = imgClasses.length > 0
1717
- ? ` class="${escapeHtml(imgClasses.join(' '))}"`
1718
- : '';
2158
+ const pictureClassAttr = pictureClasses.length > 0 ? ` class="${escapeHtml(pictureClasses.join(' '))}"` : '';
2159
+ const imgClassAttr = imgClasses.length > 0 ? ` class="${escapeHtml(imgClasses.join(' '))}"` : '';
1719
2160
 
1720
- return `<picture${pictureClassAttr}${styleAttr}>` +
2161
+ return (
2162
+ `<picture${pictureClassAttr}${styleAttr}>` +
1721
2163
  `<source type="image/avif" srcset="${escapeHtml(metadata.avifSrcset)}" sizes="${escapeHtml(sizesAttr)}" />` +
1722
2164
  `<source type="image/webp" srcset="${escapeHtml(metadata.srcset)}" sizes="${escapeHtml(sizesAttr)}" />` +
1723
2165
  `<img${imgClassAttr}${imgAttrs}${attrs} />` +
1724
- `</picture>`;
2166
+ `</picture>`
2167
+ );
1725
2168
  }
1726
2169
 
1727
2170
  // Fallback: regular img with WebP srcset
@@ -1757,8 +2200,7 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
1757
2200
  }
1758
2201
 
1759
2202
  const localeStyleOpts = {
1760
- fluidActive:
1761
- ctx.responsiveScales?.enabled === true && ctx.responsiveScales?.mode === 'fluid',
2203
+ fluidActive: ctx.responsiveScales?.enabled === true && ctx.responsiveScales?.mode === 'fluid',
1762
2204
  responsiveScales: ctx.responsiveScales,
1763
2205
  };
1764
2206
 
@@ -1784,18 +2226,14 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
1784
2226
  localeListStyleAttr = buildCssVariableStyleAttr(localeListCssVariables);
1785
2227
  }
1786
2228
 
1787
- const containerClassAttr = containerClasses.length > 0
1788
- ? ` class="${escapeHtml(containerClasses.join(' '))}"`
1789
- : '';
2229
+ const containerClassAttr = containerClasses.length > 0 ? ` class="${escapeHtml(containerClasses.join(' '))}"` : '';
1790
2230
 
1791
2231
  // Convert item styles to utility classes
1792
2232
  let itemClasses: string[] = [];
1793
2233
  if (node.itemStyle) {
1794
2234
  itemClasses = responsiveStylesToClasses(node.itemStyle as ResponsiveStyleObject, localeStyleOpts);
1795
2235
  }
1796
- const itemClassAttr = itemClasses.length > 0
1797
- ? ` class="${escapeHtml(itemClasses.join(' '))}"`
1798
- : '';
2236
+ const itemClassAttr = itemClasses.length > 0 ? ` class="${escapeHtml(itemClasses.join(' '))}"` : '';
1799
2237
 
1800
2238
  // Convert active item styles to utility classes
1801
2239
  let activeItemClasses: string[] = [];
@@ -1808,24 +2246,19 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
1808
2246
  if (node.separatorStyle) {
1809
2247
  separatorClasses = responsiveStylesToClasses(node.separatorStyle as ResponsiveStyleObject, localeStyleOpts);
1810
2248
  }
1811
- const separatorClassAttr = separatorClasses.length > 0
1812
- ? ` class="${escapeHtml(separatorClasses.join(' '))}"`
1813
- : '';
2249
+ const separatorClassAttr = separatorClasses.length > 0 ? ` class="${escapeHtml(separatorClasses.join(' '))}"` : '';
1814
2250
 
1815
2251
  // Convert flag styles to utility classes
1816
2252
  let flagClasses: string[] = [];
1817
2253
  if (node.flagStyle) {
1818
2254
  flagClasses = responsiveStylesToClasses(node.flagStyle as ResponsiveStyleObject, localeStyleOpts);
1819
2255
  }
1820
- const flagClassAttr = flagClasses.length > 0
1821
- ? ` class="${escapeHtml(flagClasses.join(' '))}"`
1822
- : '';
2256
+ const flagClassAttr = flagClasses.length > 0 ? ` class="${escapeHtml(flagClasses.join(' '))}"` : '';
1823
2257
 
1824
2258
  // Current item gets both itemStyle + activeItemStyle (additive/override)
1825
2259
  const currentItemClasses = [...itemClasses, ...activeItemClasses];
1826
- const currentItemClassAttr = currentItemClasses.length > 0
1827
- ? ` class="${escapeHtml(currentItemClasses.join(' '))}"`
1828
- : '';
2260
+ const currentItemClassAttr =
2261
+ currentItemClasses.length > 0 ? ` class="${escapeHtml(currentItemClasses.join(' '))}"` : '';
1829
2262
 
1830
2263
  // Build links HTML
1831
2264
  const links: string[] = [];
@@ -1858,13 +2291,13 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
1858
2291
  }
1859
2292
  linkContent += `<div>${escapeHtml(displayText)}</div>`;
1860
2293
 
1861
- links.push(`<a href="${escapeHtml(link.path)}"${hreflangAttr}${currentAttr} data-locale="${escapeHtml(link.locale)}"${classAttrForLink}>${linkContent}</a>`);
2294
+ links.push(
2295
+ `<a href="${escapeHtml(link.path)}"${hreflangAttr}${currentAttr} data-locale="${escapeHtml(link.locale)}"${classAttrForLink}>${linkContent}</a>`,
2296
+ );
1862
2297
  }
1863
2298
 
1864
2299
  // Join links with separator (empty span styled via separatorStyle) or concatenate directly
1865
- const linksHTML = showSeparator
1866
- ? links.join(`<span${separatorClassAttr}></span>`)
1867
- : links.join('');
2300
+ const linksHTML = showSeparator ? links.join(`<span${separatorClassAttr}></span>`) : links.join('');
1868
2301
 
1869
2302
  // Extract attributes from node
1870
2303
  const nodeAttributes = resolveI18nAttrs(extractAttributesFromNode(node), locale, i18nConfig);
@@ -1897,8 +2330,20 @@ export async function renderPageSSR(
1897
2330
  cmsContext?: CMSContext,
1898
2331
  cmsService?: CMSService,
1899
2332
  isProductionBuild?: boolean,
1900
- injectEditorAttrs?: boolean
1901
- ): Promise<{ html: string; meta: string; title: string; javascript: string; componentCSS?: string; locale: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string>; ssrFallbackCollector: Map<string, string>; processedRawHtmlCollector: Map<string, string> }> {
2333
+ injectEditorAttrs?: boolean,
2334
+ ): Promise<{
2335
+ html: string;
2336
+ meta: string;
2337
+ title: string;
2338
+ javascript: string;
2339
+ componentCSS?: string;
2340
+ locale: string;
2341
+ interactiveStylesMap: Map<string, InteractiveStyles>;
2342
+ preloadImages: PreloadImage[];
2343
+ neededCollections: Set<string>;
2344
+ ssrFallbackCollector: Map<string, string>;
2345
+ processedRawHtmlCollector: Map<string, string>;
2346
+ }> {
1902
2347
  // Extract page content
1903
2348
  const rootNode = pageData?.root || undefined;
1904
2349
  if (!rootNode) {
@@ -1906,7 +2351,7 @@ export async function renderPageSSR(
1906
2351
  }
1907
2352
 
1908
2353
  // Load i18n config if not provided
1909
- const config = i18nConfig || await loadI18nConfig();
2354
+ const config = i18nConfig || (await loadI18nConfig());
1910
2355
 
1911
2356
  // Extract locale from path if not explicitly provided
1912
2357
  const { locale: pathLocale, pathWithoutLocale } = extractLocaleFromPath(pagePath, config);
@@ -1926,7 +2371,13 @@ export async function renderPageSSR(
1926
2371
  meta = { ...meta, ogTitle: processCMSTemplate(meta.ogTitle, cmsContext.cms, effectiveLocale, config) };
1927
2372
  }
1928
2373
  if (typeof meta.ogDescription === 'string') {
1929
- meta = { ...meta, ogDescription: processCMSTemplate(meta.ogDescription, cmsContext.cms, effectiveLocale, config) };
2374
+ meta = {
2375
+ ...meta,
2376
+ ogDescription: processCMSTemplate(meta.ogDescription, cmsContext.cms, effectiveLocale, config),
2377
+ };
2378
+ }
2379
+ if (typeof meta.ogImage === 'string') {
2380
+ meta = { ...meta, ogImage: processCMSTemplate(meta.ogImage, cmsContext.cms, effectiveLocale, config) };
1930
2381
  }
1931
2382
  }
1932
2383
 
@@ -1934,9 +2385,27 @@ export async function renderPageSSR(
1934
2385
 
1935
2386
  // Render the component tree to HTML with i18n and CMS support
1936
2387
  // Also collect interactive styles, preload images, and needed collections during render
1937
- const { html: contentHTML, interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector, processedRawHtmlCollector } = rootNode
1938
- ? await buildComponentHTML(rootNode, globalComponents, pageComponents, effectiveLocale, config, slugMappings, pagePath, cmsContext, cmsService, isProductionBuild, injectEditorAttrs)
1939
- : { html: '', interactiveStylesMap: new Map<string, InteractiveStyles>(), preloadImages: [], neededCollections: new Set<string>(), ssrFallbackCollector: new Map<string, string>(), processedRawHtmlCollector: new Map<string, string>() };
2388
+ // (rootNode is guaranteed non-null the guard at the top of this function throws otherwise)
2389
+ const {
2390
+ html: contentHTML,
2391
+ interactiveStylesMap,
2392
+ preloadImages,
2393
+ neededCollections,
2394
+ ssrFallbackCollector,
2395
+ processedRawHtmlCollector,
2396
+ } = await buildComponentHTML(
2397
+ rootNode,
2398
+ globalComponents,
2399
+ pageComponents,
2400
+ effectiveLocale,
2401
+ config,
2402
+ slugMappings,
2403
+ pagePath,
2404
+ cmsContext,
2405
+ cmsService,
2406
+ isProductionBuild,
2407
+ injectEditorAttrs,
2408
+ );
1940
2409
 
1941
2410
  // Collect JavaScript and CSS from all components
1942
2411
  const javascript = await collectComponentJavaScript(globalComponents, pageComponents);