meno-core 1.0.53 → 1.1.0

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 (415) hide show
  1. package/.claude/settings.local.json +1 -3
  2. package/bin/cli.ts +66 -261
  3. package/build-astro.ts +150 -128
  4. package/dist/bin/cli.js +42 -185
  5. package/dist/bin/cli.js.map +2 -2
  6. package/dist/chunks/chunk-2AR55GYH.js +42 -0
  7. package/dist/chunks/chunk-2AR55GYH.js.map +7 -0
  8. package/dist/chunks/{chunk-JGWFTO6P.js → chunk-2IIQK7T3.js} +2624 -1389
  9. package/dist/chunks/chunk-2IIQK7T3.js.map +7 -0
  10. package/dist/chunks/chunk-4ZRU52J2.js +169 -0
  11. package/dist/chunks/chunk-4ZRU52J2.js.map +7 -0
  12. package/dist/chunks/{chunk-UB44F4Z2.js → chunk-5X4HCN7J.js} +5 -7
  13. package/dist/chunks/chunk-5X4HCN7J.js.map +7 -0
  14. package/dist/chunks/{chunk-WQFG7PAH.js → chunk-7CCVOL5M.js} +15 -19
  15. package/dist/chunks/chunk-7CCVOL5M.js.map +7 -0
  16. package/dist/chunks/{chunk-YBLHKYFF.js → chunk-AR7LHIFY.js} +8 -2
  17. package/dist/chunks/chunk-AR7LHIFY.js.map +7 -0
  18. package/dist/chunks/{chunk-O3NAGJP4.js → chunk-NUP7H7D3.js} +135 -41
  19. package/dist/chunks/chunk-NUP7H7D3.js.map +7 -0
  20. package/dist/chunks/{chunk-X754AHS5.js → chunk-QWTQZHG3.js} +194 -90
  21. package/dist/chunks/chunk-QWTQZHG3.js.map +7 -0
  22. package/dist/chunks/{chunk-H3GJ4H2U.js → chunk-XTKNX4FW.js} +216 -87
  23. package/dist/chunks/chunk-XTKNX4FW.js.map +7 -0
  24. package/dist/chunks/{fs-JGINUXGL.js → fs-G3ANWEFP.js} +2 -2
  25. package/dist/lib/client/index.js +1003 -475
  26. package/dist/lib/client/index.js.map +3 -3
  27. package/dist/lib/server/index.js +14393 -6371
  28. package/dist/lib/server/index.js.map +4 -4
  29. package/dist/lib/shared/index.js +342 -101
  30. package/dist/lib/shared/index.js.map +4 -4
  31. package/dist/lib/shared/richtext/index.js +1 -1
  32. package/dist/lib/test-utils/index.js +64 -80
  33. package/dist/lib/test-utils/index.js.map +2 -2
  34. package/entries/client-router.tsx +10 -8
  35. package/lib/client/ClientInitializer.ts +8 -8
  36. package/lib/client/ErrorBoundary.test.tsx +246 -186
  37. package/lib/client/ErrorBoundary.tsx +183 -132
  38. package/lib/client/componentRegistry.test.ts +96 -108
  39. package/lib/client/componentRegistry.ts +1 -3
  40. package/lib/client/contexts/ThemeContext.tsx +3 -2
  41. package/lib/client/core/ComponentBuilder.test.ts +663 -560
  42. package/lib/client/core/ComponentBuilder.ts +427 -155
  43. package/lib/client/core/ComponentRenderer.test.tsx +2 -4
  44. package/lib/client/core/ComponentRenderer.tsx +46 -33
  45. package/lib/client/core/builders/embedBuilder.ts +242 -54
  46. package/lib/client/core/builders/linkBuilder.ts +72 -45
  47. package/lib/client/core/builders/linkNodeBuilder.ts +79 -54
  48. package/lib/client/core/builders/listBuilder.ts +155 -89
  49. package/lib/client/core/builders/localeListBuilder.ts +97 -62
  50. package/lib/client/core/builders/types.ts +5 -5
  51. package/lib/client/core/cmsTemplateProcessor.ts +8 -9
  52. package/lib/client/elementRegistry.ts +3 -3
  53. package/lib/client/fontFamiliesService.test.ts +2 -10
  54. package/lib/client/fontFamiliesService.ts +3 -3
  55. package/lib/client/hmr/HMRManager.tsx +47 -30
  56. package/lib/client/hmrCssReload.ts +49 -14
  57. package/lib/client/hmrWebSocket.ts +14 -19
  58. package/lib/client/hooks/useColorVariables.test.ts +21 -21
  59. package/lib/client/hooks/useColorVariables.ts +23 -18
  60. package/lib/client/hooks/usePropertyAutocomplete.ts +4 -6
  61. package/lib/client/hooks/useVariables.ts +6 -4
  62. package/lib/client/hydration/HydrationUtils.test.ts +24 -25
  63. package/lib/client/hydration/HydrationUtils.ts +3 -4
  64. package/lib/client/i18nConfigService.test.ts +2 -7
  65. package/lib/client/i18nConfigService.ts +2 -2
  66. package/lib/client/meno-filter/MenoFilter.test.ts +47 -49
  67. package/lib/client/meno-filter/MenoFilter.ts +14 -15
  68. package/lib/client/meno-filter/bindings.ts +18 -43
  69. package/lib/client/meno-filter/init.ts +4 -4
  70. package/lib/client/meno-filter/renderer.ts +30 -32
  71. package/lib/client/meno-filter/script.generated.ts +1 -3
  72. package/lib/client/meno-filter/ui.ts +4 -6
  73. package/lib/client/meno-filter/updates.ts +18 -23
  74. package/lib/client/meno-filter/utils.ts +1 -1
  75. package/lib/client/navigation.test.ts +174 -178
  76. package/lib/client/navigation.ts +0 -1
  77. package/lib/client/responsiveStyleResolver.knownTokens.test.ts +71 -0
  78. package/lib/client/responsiveStyleResolver.test.ts +234 -232
  79. package/lib/client/responsiveStyleResolver.ts +74 -20
  80. package/lib/client/routing/RouteLoader.test.ts +24 -25
  81. package/lib/client/routing/RouteLoader.ts +27 -35
  82. package/lib/client/routing/Router.tsx +210 -153
  83. package/lib/client/scripts/ScriptExecutor.test.ts +157 -158
  84. package/lib/client/scripts/ScriptExecutor.ts +18 -36
  85. package/lib/client/services/PrefetchService.test.ts +3 -3
  86. package/lib/client/services/PrefetchService.ts +23 -29
  87. package/lib/client/styleProcessor.test.ts +9 -9
  88. package/lib/client/styleProcessor.ts +24 -15
  89. package/lib/client/styles/StyleInjector.test.ts +122 -115
  90. package/lib/client/styles/StyleInjector.ts +10 -7
  91. package/lib/client/styles/UtilityClassCollector.ts +62 -28
  92. package/lib/client/styles/cspNonce.test.ts +2 -5
  93. package/lib/client/templateEngine.test.ts +554 -488
  94. package/lib/client/templateEngine.ts +203 -73
  95. package/lib/client/theme.test.ts +1 -1
  96. package/lib/client/theme.ts +0 -1
  97. package/lib/client/utils/toast.ts +0 -1
  98. package/lib/server/__integration__/api-routes.test.ts +8 -4
  99. package/lib/server/__integration__/cms-integration.test.ts +1 -4
  100. package/lib/server/__integration__/server-lifecycle.test.ts +4 -5
  101. package/lib/server/__integration__/ssr-rendering.test.ts +47 -37
  102. package/lib/server/__integration__/static-assets.test.ts +3 -3
  103. package/lib/server/__integration__/test-helpers.ts +87 -74
  104. package/lib/server/ab/generateFunctions.ts +12 -10
  105. package/lib/server/astro/cmsPageEmitter.ts +26 -42
  106. package/lib/server/astro/componentEmitter.ts +35 -43
  107. package/lib/server/astro/cssCollector.ts +10 -26
  108. package/lib/server/astro/nodeToAstro.test.ts +1754 -34
  109. package/lib/server/astro/nodeToAstro.ts +232 -216
  110. package/lib/server/astro/normalizeOrphanTemplateProps.test.ts +115 -119
  111. package/lib/server/astro/normalizeOrphanTemplateProps.ts +11 -18
  112. package/lib/server/astro/pageEmitter.ts +9 -13
  113. package/lib/server/astro/tailwindMapper.test.ts +10 -37
  114. package/lib/server/astro/tailwindMapper.ts +33 -40
  115. package/lib/server/astro/templateTransformer.ts +17 -20
  116. package/lib/server/createServer.ts +7 -19
  117. package/lib/server/cssGenerator.test.ts +35 -44
  118. package/lib/server/cssGenerator.ts +8 -19
  119. package/lib/server/fileWatcher.test.ts +164 -13
  120. package/lib/server/fileWatcher.ts +209 -124
  121. package/lib/server/index.ts +14 -17
  122. package/lib/server/jsonLoader.test.ts +39 -2
  123. package/lib/server/jsonLoader.ts +39 -34
  124. package/lib/server/middleware/cors.test.ts +21 -21
  125. package/lib/server/middleware/cors.ts +28 -4
  126. package/lib/server/middleware/errorHandler.test.ts +7 -5
  127. package/lib/server/middleware/errorHandler.ts +3 -8
  128. package/lib/server/middleware/index.ts +0 -1
  129. package/lib/server/middleware/logger.test.ts +10 -8
  130. package/lib/server/middleware/logger.ts +17 -29
  131. package/lib/server/migrateTemplates.ts +3 -3
  132. package/lib/server/pageCache.test.ts +76 -77
  133. package/lib/server/pageCache.ts +0 -1
  134. package/lib/server/projectContext.ts +5 -4
  135. package/lib/server/providers/fileSystemCMSProvider.test.ts +142 -99
  136. package/lib/server/providers/fileSystemCMSProvider.ts +62 -38
  137. package/lib/server/routes/api/cms.test.ts +26 -14
  138. package/lib/server/routes/api/cms.ts +9 -14
  139. package/lib/server/routes/api/colors.test.ts +2 -2
  140. package/lib/server/routes/api/components.ts +31 -35
  141. package/lib/server/routes/api/config.ts +3 -4
  142. package/lib/server/routes/api/core-routes.ts +49 -76
  143. package/lib/server/routes/api/enums.test.ts +2 -2
  144. package/lib/server/routes/api/functions.ts +9 -17
  145. package/lib/server/routes/api/index.ts +3 -6
  146. package/lib/server/routes/api/pages.ts +8 -11
  147. package/lib/server/routes/api/shared.test.ts +1 -1
  148. package/lib/server/routes/api/shared.ts +12 -9
  149. package/lib/server/routes/api/variables.test.ts +3 -5
  150. package/lib/server/routes/api/variables.ts +1 -1
  151. package/lib/server/routes/index.ts +25 -28
  152. package/lib/server/routes/pages.ts +35 -31
  153. package/lib/server/routes/static.ts +17 -6
  154. package/lib/server/runtime/bundler.ts +98 -61
  155. package/lib/server/runtime/fs.ts +16 -26
  156. package/lib/server/runtime/httpServer.ts +8 -12
  157. package/lib/server/services/ColorService.test.ts +3 -3
  158. package/lib/server/services/ColorService.ts +37 -29
  159. package/lib/server/services/EnumService.test.ts +4 -8
  160. package/lib/server/services/EnumService.ts +11 -3
  161. package/lib/server/services/VariableService.test.ts +8 -12
  162. package/lib/server/services/VariableService.ts +11 -3
  163. package/lib/server/services/cmsService.test.ts +158 -120
  164. package/lib/server/services/cmsService.ts +36 -146
  165. package/lib/server/services/componentService.test.ts +125 -45
  166. package/lib/server/services/componentService.ts +70 -46
  167. package/lib/server/services/configService.test.ts +22 -45
  168. package/lib/server/services/configService.ts +50 -27
  169. package/lib/server/services/fileWatcherService.ts +42 -8
  170. package/lib/server/services/index.ts +0 -1
  171. package/lib/server/services/pageService.test.ts +8 -10
  172. package/lib/server/services/pageService.ts +32 -18
  173. package/lib/server/ssr/attributeBuilder.ts +18 -8
  174. package/lib/server/ssr/buildErrorOverlay.ts +11 -12
  175. package/lib/server/ssr/clientDataInjector.ts +7 -21
  176. package/lib/server/ssr/cmsSSRProcessor.ts +4 -8
  177. package/lib/server/ssr/cssCollector.ts +1 -1
  178. package/lib/server/ssr/errorOverlay.test.ts +1 -1
  179. package/lib/server/ssr/errorOverlay.ts +3 -9
  180. package/lib/server/ssr/htmlGenerator.nonce.test.ts +9 -14
  181. package/lib/server/ssr/htmlGenerator.test.ts +120 -44
  182. package/lib/server/ssr/htmlGenerator.ts +126 -91
  183. package/lib/server/ssr/imageMetadata.test.ts +3 -1
  184. package/lib/server/ssr/imageMetadata.ts +27 -21
  185. package/lib/server/ssr/jsCollector.test.ts +8 -18
  186. package/lib/server/ssr/jsCollector.ts +3 -8
  187. package/lib/server/ssr/liveReloadIntegration.test.ts +76 -36
  188. package/lib/server/ssr/metaTagGenerator.ts +2 -2
  189. package/lib/server/ssr/ssrRenderer.branches.test.ts +1103 -0
  190. package/lib/server/ssr/ssrRenderer.test.ts +316 -256
  191. package/lib/server/ssr/ssrRenderer.ts +795 -243
  192. package/lib/server/ssrRenderer.test.ts +1048 -954
  193. package/lib/server/utils/jsonLineMapper.test.ts +28 -28
  194. package/lib/server/utils/jsonLineMapper.ts +7 -7
  195. package/lib/server/validateStyleCoverage.ts +18 -21
  196. package/lib/server/websocketManager.ts +28 -19
  197. package/lib/shared/attributeNodeUtils.test.ts +15 -15
  198. package/lib/shared/attributeNodeUtils.ts +5 -12
  199. package/lib/shared/breakpoints.test.ts +5 -5
  200. package/lib/shared/breakpoints.ts +4 -11
  201. package/lib/shared/cmsQuery.test.ts +81 -0
  202. package/lib/shared/cmsQuery.ts +92 -0
  203. package/lib/shared/cmsQueryParser.test.ts +50 -42
  204. package/lib/shared/cmsQueryParser.ts +60 -38
  205. package/lib/shared/colorConversions.ts +9 -9
  206. package/lib/shared/colorVariableUtils.test.ts +27 -5
  207. package/lib/shared/colorVariableUtils.ts +21 -12
  208. package/lib/shared/componentRefs.ts +1 -5
  209. package/lib/shared/constants.test.ts +3 -3
  210. package/lib/shared/constants.ts +8 -8
  211. package/lib/shared/cssGeneration.test.ts +285 -144
  212. package/lib/shared/cssGeneration.ts +217 -531
  213. package/lib/shared/cssNamedColors.ts +152 -30
  214. package/lib/shared/cssProperties.test.ts +69 -70
  215. package/lib/shared/cssProperties.ts +298 -114
  216. package/lib/shared/elementClassName.test.ts +109 -109
  217. package/lib/shared/elementClassName.ts +3 -3
  218. package/lib/shared/elementUtils.ts +12 -16
  219. package/lib/shared/errorLogger.test.ts +12 -12
  220. package/lib/shared/errorLogger.ts +2 -10
  221. package/lib/shared/errors.test.ts +2 -13
  222. package/lib/shared/errors.ts +2 -8
  223. package/lib/shared/expressionEvaluator.test.ts +145 -0
  224. package/lib/shared/expressionEvaluator.ts +52 -24
  225. package/lib/shared/fontLoader.test.ts +32 -18
  226. package/lib/shared/fontLoader.ts +2 -2
  227. package/lib/shared/friendlyError.ts +2 -3
  228. package/lib/shared/gradientUtils.test.ts +3 -7
  229. package/lib/shared/gradientUtils.ts +17 -14
  230. package/lib/shared/hrefRefs.test.ts +2 -2
  231. package/lib/shared/hrefRefs.ts +2 -10
  232. package/lib/shared/i18n.test.ts +4 -4
  233. package/lib/shared/i18n.ts +18 -38
  234. package/lib/shared/index.ts +27 -0
  235. package/lib/shared/inlineSvgStyleRules.ts +14 -13
  236. package/lib/shared/interactiveStyleMappings.test.ts +32 -54
  237. package/lib/shared/interactiveStyleMappings.ts +11 -24
  238. package/lib/shared/interactiveStyles.test.ts +165 -188
  239. package/lib/shared/interfaces/contentProvider.ts +1 -1
  240. package/lib/shared/itemTemplateUtils.test.ts +11 -17
  241. package/lib/shared/itemTemplateUtils.ts +27 -39
  242. package/lib/shared/jsonRepair.ts +16 -10
  243. package/lib/shared/libraryLoader.test.ts +21 -55
  244. package/lib/shared/libraryLoader.ts +7 -22
  245. package/lib/shared/linkUtils.ts +1 -1
  246. package/lib/shared/logger.test.ts +66 -0
  247. package/lib/shared/logger.ts +94 -0
  248. package/lib/shared/markdown.ts +25 -0
  249. package/lib/shared/netlifyLocale404.test.ts +179 -0
  250. package/lib/shared/netlifyLocale404.ts +110 -0
  251. package/lib/shared/nodeUtils.test.ts +28 -16
  252. package/lib/shared/nodeUtils.ts +76 -23
  253. package/lib/shared/pathArrayUtils.test.ts +1 -2
  254. package/lib/shared/pathArrayUtils.ts +1 -1
  255. package/lib/shared/pathSecurity.ts +2 -2
  256. package/lib/shared/pathUtils.test.ts +4 -6
  257. package/lib/shared/pathUtils.ts +42 -49
  258. package/lib/shared/paths/Path.test.ts +2 -2
  259. package/lib/shared/paths/Path.ts +0 -1
  260. package/lib/shared/paths/PathConverter.test.ts +1 -1
  261. package/lib/shared/paths/PathConverter.ts +17 -20
  262. package/lib/shared/paths/PathUtils.ts +13 -13
  263. package/lib/shared/paths/PathValidator.test.ts +2 -15
  264. package/lib/shared/paths/PathValidator.ts +12 -10
  265. package/lib/shared/paths/index.ts +1 -2
  266. package/lib/shared/permissions.test.ts +168 -0
  267. package/lib/shared/permissions.ts +162 -0
  268. package/lib/shared/propResolver.test.ts +240 -244
  269. package/lib/shared/propResolver.ts +15 -26
  270. package/lib/shared/pxToRem.test.ts +7 -6
  271. package/lib/shared/pxToRem.ts +2 -5
  272. package/lib/shared/registry/BaseNodeTypeRegistry.test.ts +12 -6
  273. package/lib/shared/registry/ClientRegistry.ts +0 -2
  274. package/lib/shared/registry/ComponentRegistry.test.ts +46 -32
  275. package/lib/shared/registry/ComponentRegistry.ts +12 -9
  276. package/lib/shared/registry/NodeTypeDefinition.ts +16 -9
  277. package/lib/shared/registry/RegistryManager.ts +1 -2
  278. package/lib/shared/registry/SSRRegistry.ts +0 -2
  279. package/lib/shared/registry/createNodeType.ts +7 -9
  280. package/lib/shared/registry/defineNodeType.ts +2 -6
  281. package/lib/shared/registry/fieldPresets.ts +3 -1
  282. package/lib/shared/registry/index.ts +0 -1
  283. package/lib/shared/registry/nodeTypes/ComponentInstanceNodeType.ts +14 -15
  284. package/lib/shared/registry/nodeTypes/CustomNodeType.ts +103 -0
  285. package/lib/shared/registry/nodeTypes/EmbedNodeType.ts +18 -11
  286. package/lib/shared/registry/nodeTypes/HtmlNodeType.ts +48 -19
  287. package/lib/shared/registry/nodeTypes/IslandNodeType.ts +129 -0
  288. package/lib/shared/registry/nodeTypes/LinkNodeType.ts +21 -19
  289. package/lib/shared/registry/nodeTypes/ListNodeType.ts +117 -77
  290. package/lib/shared/registry/nodeTypes/LocaleListNodeType.ts +27 -21
  291. package/lib/shared/registry/nodeTypes/MarkdownNodeType.ts +78 -0
  292. package/lib/shared/registry/nodeTypes/SlotMarkerType.ts +12 -8
  293. package/lib/shared/registry/nodeTypes/index.ts +26 -3
  294. package/lib/shared/responsiveScaling.test.ts +16 -34
  295. package/lib/shared/responsiveScaling.ts +61 -42
  296. package/lib/shared/responsiveStyleUtils.test.ts +0 -1
  297. package/lib/shared/responsiveStyleUtils.ts +11 -13
  298. package/lib/shared/richtext/htmlToTiptap.test.ts +139 -130
  299. package/lib/shared/richtext/htmlToTiptap.ts +2 -4
  300. package/lib/shared/richtext/tiptapToHtml.test.ts +5 -6
  301. package/lib/shared/richtext/types.ts +1 -8
  302. package/lib/shared/slugTranslator.test.ts +13 -13
  303. package/lib/shared/slugTranslator.ts +12 -16
  304. package/lib/shared/slugify.ts +9 -15
  305. package/lib/shared/styleNodeUtils.test.ts +8 -8
  306. package/lib/shared/styleNodeUtils.ts +18 -20
  307. package/lib/shared/styleUtils.test.ts +87 -61
  308. package/lib/shared/styleUtils.ts +5 -6
  309. package/lib/shared/themeDefaults.test.ts +11 -14
  310. package/lib/shared/themeDefaults.ts +3 -4
  311. package/lib/shared/tree/PathBuilder.test.ts +63 -145
  312. package/lib/shared/tree/PathBuilder.ts +59 -47
  313. package/lib/shared/treePathUtils.test.ts +51 -12
  314. package/lib/shared/treePathUtils.ts +91 -60
  315. package/lib/shared/types/api.ts +105 -8
  316. package/lib/shared/types/cms.test.ts +34 -0
  317. package/lib/shared/types/cms.ts +90 -34
  318. package/lib/shared/types/comment.ts +49 -16
  319. package/lib/shared/types/components.ts +51 -25
  320. package/lib/shared/types/errors.test.ts +1 -6
  321. package/lib/shared/types/errors.ts +3 -7
  322. package/lib/shared/types/experiments.ts +28 -28
  323. package/lib/shared/types/index.ts +25 -2
  324. package/lib/shared/types/permissions.ts +139 -0
  325. package/lib/shared/types/styles.ts +0 -1
  326. package/lib/shared/types/variables.test.ts +4 -13
  327. package/lib/shared/types/variables.ts +51 -30
  328. package/lib/shared/types.ts +1 -2
  329. package/lib/shared/utilityClassConfig.ts +751 -321
  330. package/lib/shared/utilityClassMapper.durability.test.ts +117 -0
  331. package/lib/shared/utilityClassMapper.test.ts +433 -95
  332. package/lib/shared/utilityClassMapper.ts +322 -249
  333. package/lib/shared/utilityClassNames.test.ts +38 -0
  334. package/lib/shared/utilityClassNames.ts +458 -0
  335. package/lib/shared/utils.test.ts +2 -10
  336. package/lib/shared/utils.ts +19 -10
  337. package/lib/shared/validation/cmsValidators.ts +2 -1
  338. package/lib/shared/validation/commentValidators.test.ts +53 -0
  339. package/lib/shared/validation/commentValidators.ts +12 -1
  340. package/lib/shared/validation/index.ts +1 -0
  341. package/lib/shared/validation/permissionsValidators.test.ts +49 -0
  342. package/lib/shared/validation/permissionsValidators.ts +49 -0
  343. package/lib/shared/validation/propValidator.test.ts +19 -21
  344. package/lib/shared/validation/propValidator.ts +13 -18
  345. package/lib/shared/validation/schemas.test.ts +64 -32
  346. package/lib/shared/validation/schemas.ts +616 -345
  347. package/lib/shared/validation/validators.test.ts +3 -8
  348. package/lib/shared/validation/validators.ts +89 -68
  349. package/lib/shared/viewportUnits.integration.test.ts +14 -10
  350. package/lib/shared/viewportUnits.test.ts +24 -23
  351. package/lib/shared/viewportUnits.ts +12 -5
  352. package/lib/test-utils/dom-setup.ts +1 -1
  353. package/lib/test-utils/factories/ConsoleMockFactory.ts +10 -14
  354. package/lib/test-utils/factories/DomMockFactory.ts +17 -23
  355. package/lib/test-utils/factories/EventMockFactory.ts +7 -13
  356. package/lib/test-utils/factories/FetchMockFactory.ts +39 -57
  357. package/lib/test-utils/factories/ServerMockFactory.ts +6 -10
  358. package/lib/test-utils/factories/StoreMockFactory.ts +18 -29
  359. package/lib/test-utils/fixtures.ts +45 -45
  360. package/lib/test-utils/helpers/asyncHelpers.test.ts +15 -18
  361. package/lib/test-utils/helpers/asyncHelpers.ts +11 -20
  362. package/lib/test-utils/helpers.ts +1 -5
  363. package/lib/test-utils/index.ts +0 -4
  364. package/lib/test-utils/mockFactories.ts +13 -19
  365. package/lib/test-utils/mocks.ts +6 -4
  366. package/package.json +3 -5
  367. package/scripts/build-for-publish.mjs +2 -4
  368. package/scripts/build-meno-filter.ts +4 -7
  369. package/tsconfig.json +1 -0
  370. package/vite.config.ts +4 -4
  371. package/build-next.ts +0 -1361
  372. package/build-static.test.ts +0 -424
  373. package/build-static.ts +0 -1074
  374. package/dist/build-static.js +0 -36
  375. package/dist/chunks/chunk-2MHDV5BF.js +0 -251
  376. package/dist/chunks/chunk-2MHDV5BF.js.map +0 -7
  377. package/dist/chunks/chunk-AZQYF6KE.js +0 -559
  378. package/dist/chunks/chunk-AZQYF6KE.js.map +0 -7
  379. package/dist/chunks/chunk-GZHGVVW3.js +0 -322
  380. package/dist/chunks/chunk-GZHGVVW3.js.map +0 -7
  381. package/dist/chunks/chunk-H3GJ4H2U.js.map +0 -7
  382. package/dist/chunks/chunk-I7YIGZXT.js +0 -467
  383. package/dist/chunks/chunk-I7YIGZXT.js.map +0 -7
  384. package/dist/chunks/chunk-IGYR22T6.js +0 -6192
  385. package/dist/chunks/chunk-IGYR22T6.js.map +0 -7
  386. package/dist/chunks/chunk-JGP5A3Y5.js +0 -1136
  387. package/dist/chunks/chunk-JGP5A3Y5.js.map +0 -7
  388. package/dist/chunks/chunk-JGWFTO6P.js.map +0 -7
  389. package/dist/chunks/chunk-O3NAGJP4.js.map +0 -7
  390. package/dist/chunks/chunk-QB2LNO4W.js +0 -77
  391. package/dist/chunks/chunk-QB2LNO4W.js.map +0 -7
  392. package/dist/chunks/chunk-R6XHAFBF.js +0 -3821
  393. package/dist/chunks/chunk-R6XHAFBF.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/chunk-X754AHS5.js.map +0 -7
  397. package/dist/chunks/chunk-YBLHKYFF.js.map +0 -7
  398. package/dist/chunks/configService-R3OGU2UD.js +0 -13
  399. package/dist/chunks/configService-R3OGU2UD.js.map +0 -7
  400. package/dist/chunks/constants-STK2YBIW.js +0 -46
  401. package/dist/chunks/constants-STK2YBIW.js.map +0 -7
  402. package/dist/chunks/fs-JGINUXGL.js.map +0 -7
  403. package/dist/entries/server-router.js +0 -67
  404. package/dist/entries/server-router.js.map +0 -7
  405. package/entries/server-router.tsx +0 -81
  406. package/lib/server/providers/fileSystemPageProvider.ts +0 -160
  407. package/lib/server/webflow/buildWebflow.ts +0 -635
  408. package/lib/server/webflow/index.ts +0 -23
  409. package/lib/server/webflow/nodeToWebflow.test.ts +0 -3170
  410. package/lib/server/webflow/nodeToWebflow.ts +0 -2435
  411. package/lib/server/webflow/styleMapper.test.ts +0 -389
  412. package/lib/server/webflow/styleMapper.ts +0 -695
  413. package/lib/server/webflow/templateWrapper.ts +0 -49
  414. package/lib/server/webflow/types.ts +0 -396
  415. /package/dist/{build-static.js.map → chunks/fs-G3ANWEFP.js.map} +0 -0
@@ -0,0 +1,1103 @@
1
+ /**
2
+ * Characterization tests for ssrRenderer.ts — uncovered branch coverage.
3
+ *
4
+ * Complements ssrRenderer.test.ts by pinning current behavior of the branches
5
+ * that the main suite does not reach: item-template `if` conditions, CMS filter
6
+ * template resolution, rich-text component expansion, nested cms-list
7
+ * placeholders, draft filtering on ID-based queries, prop-source list edge
8
+ * cases, CSS-variable interactive style mappings, template-mode raw HTML,
9
+ * embed html mappings, link-object hrefs, responsive props.style on component
10
+ * instances, raw-text elements (<style>/<script>), locale-list interactive
11
+ * styles, component render error fallbacks, and CMS templates in og:image.
12
+ *
13
+ * These are CHARACTERIZATION tests: they pin behavior as-is ahead of a planned
14
+ * refactor. Suspicious behaviors are flagged with `// NOTE: possible bug —`.
15
+ */
16
+
17
+ import { describe, test, expect, mock, beforeEach, afterAll } from 'bun:test';
18
+
19
+ // bun's mock.module() patches are PROCESS-GLOBAL and persist across test
20
+ // files in the same run. Snapshot the real module exports here (imports are
21
+ // evaluated before this module body runs, so these capture the originals)
22
+ // and restore them in afterAll so later test files see the real
23
+ // implementations. (Same pattern as ssrRenderer.test.ts.)
24
+ import * as realJsonLoader from '../jsonLoader';
25
+ import * as realImageMetadata from './imageMetadata';
26
+ import * as realConfigServiceModule from '../services/configService';
27
+ import * as realValidateStyleCoverage from '../validateStyleCoverage';
28
+
29
+ const REAL_JSON_LOADER = { ...realJsonLoader };
30
+ const REAL_IMAGE_METADATA = { ...realImageMetadata };
31
+ const REAL_CONFIG_SERVICE_MODULE = { ...realConfigServiceModule };
32
+ const REAL_VALIDATE_STYLE_COVERAGE = { ...realValidateStyleCoverage };
33
+
34
+ afterAll(() => {
35
+ mock.module('../jsonLoader', () => REAL_JSON_LOADER);
36
+ mock.module('./imageMetadata', () => REAL_IMAGE_METADATA);
37
+ mock.module('../services/configService', () => REAL_CONFIG_SERVICE_MODULE);
38
+ mock.module('../validateStyleCoverage', () => REAL_VALIDATE_STYLE_COVERAGE);
39
+ // errorLogger handler state is process-global too — don't leak it.
40
+ setErrorHandler(null);
41
+ });
42
+
43
+ let mockImageMetadataMap = new Map();
44
+ let mockImageFormat: 'webp' | 'avif' = 'webp';
45
+
46
+ mock.module('../jsonLoader', () => ({
47
+ loadBreakpointConfig: async () => ({
48
+ breakpoints: [
49
+ { name: 'mobile', maxWidth: 767 },
50
+ { name: 'tablet', minWidth: 768, maxWidth: 1023 },
51
+ { name: 'desktop', minWidth: 1024 },
52
+ ],
53
+ }),
54
+ loadI18nConfig: async () => ({
55
+ defaultLocale: 'en',
56
+ locales: [
57
+ { code: 'en', name: 'English', nativeName: 'English', langTag: 'en', icon: '/flags/en.svg' },
58
+ { code: 'fr', name: 'French', nativeName: 'Francais', langTag: 'fr', icon: '/flags/fr.svg' },
59
+ { code: 'de', name: 'German', nativeName: 'Deutsch', langTag: 'de' },
60
+ ],
61
+ }),
62
+ loadIconsConfig: async () => ({ favicon: '', appleTouchIcon: '' }),
63
+ loadPrefetchConfig: async () => ({ enabled: false }),
64
+ }));
65
+
66
+ mock.module('./imageMetadata', () => ({
67
+ buildImageMetadataMap: async () => mockImageMetadataMap,
68
+ DEFAULT_SIZES: '100vw',
69
+ ImageMetadataMap: Map,
70
+ rewriteRichTextImages: REAL_IMAGE_METADATA.rewriteRichTextImages,
71
+ }));
72
+
73
+ mock.module('../services/configService', () => ({
74
+ configService: {
75
+ getImageFormat: () => mockImageFormat,
76
+ getResponsiveScales: () => ({ enabled: false, baseReference: 16 }),
77
+ getSocial: () => ({}),
78
+ load: async () => {},
79
+ isLoaded: () => true,
80
+ reset: () => {},
81
+ },
82
+ ConfigService: class {},
83
+ }));
84
+
85
+ mock.module('../validateStyleCoverage', () => ({
86
+ validateStyleCoverage: () => {},
87
+ printMissingStyleWarnings: () => {},
88
+ }));
89
+
90
+ import { buildComponentHTML, renderPageSSR } from './ssrRenderer';
91
+ import { RAW_HTML_PREFIX } from '../../shared/constants';
92
+ import { setErrorHandler } from '../../shared/errorLogger';
93
+ import type { ComponentDefinition, JSONPage } from '../../shared/types';
94
+
95
+ /** Capture logRuntimeError entries via the errorLogger's telemetry hook. */
96
+ interface CapturedLogEntry {
97
+ location: string;
98
+ context?: Record<string, unknown>;
99
+ }
100
+ function captureErrorLog(): CapturedLogEntry[] {
101
+ const entries: CapturedLogEntry[] = [];
102
+ setErrorHandler((entry) => entries.push({ location: entry.location, context: entry.context }));
103
+ return entries;
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Helpers
108
+ // ---------------------------------------------------------------------------
109
+
110
+ interface RenderOpts {
111
+ globalComponents?: Record<string, ComponentDefinition>;
112
+ pageComponents?: Record<string, ComponentDefinition>;
113
+ locale?: string;
114
+ i18nConfig?: any;
115
+ slugMappings?: any[];
116
+ pagePath?: string;
117
+ cmsContext?: { cms?: Record<string, unknown> };
118
+ cmsService?: any;
119
+ isProductionBuild?: boolean;
120
+ injectEditorAttrs?: boolean;
121
+ }
122
+
123
+ async function renderFull(node: any, opts?: RenderOpts) {
124
+ return await buildComponentHTML(
125
+ node,
126
+ opts?.globalComponents ?? {},
127
+ opts?.pageComponents ?? {},
128
+ opts?.locale,
129
+ opts?.i18nConfig,
130
+ opts?.slugMappings,
131
+ opts?.pagePath,
132
+ opts?.cmsContext,
133
+ opts?.cmsService,
134
+ opts?.isProductionBuild,
135
+ opts?.injectEditorAttrs,
136
+ );
137
+ }
138
+
139
+ async function render(node: any, opts?: RenderOpts) {
140
+ return (await renderFull(node, opts)).html;
141
+ }
142
+
143
+ const I18N_EN_FR = {
144
+ defaultLocale: 'en',
145
+ locales: [
146
+ { code: 'en', name: 'English', nativeName: 'English', langTag: 'en' },
147
+ { code: 'fr', name: 'French', nativeName: 'Francais', langTag: 'fr' },
148
+ ],
149
+ };
150
+
151
+ // =========================================================================
152
+ // TEST SUITES
153
+ // =========================================================================
154
+
155
+ describe('ssrRenderer branches', () => {
156
+ beforeEach(() => {
157
+ mockImageMetadataMap = new Map();
158
+ mockImageFormat = 'webp';
159
+ setErrorHandler(null);
160
+ });
161
+
162
+ // -----------------------------------------------------------------------
163
+ // 1. evaluateIfCondition — item-template string `if` inside a list
164
+ // -----------------------------------------------------------------------
165
+ describe('if conditions with item templates', () => {
166
+ test('renders child when item-template if resolves truthy, skips when falsy', async () => {
167
+ const node = {
168
+ type: 'list',
169
+ sourceType: 'prop',
170
+ source: 'tags',
171
+ itemAs: 'tag',
172
+ children: [{ type: 'node', tag: 'span', if: '{{tag.visible}}', children: '{{tag.name}}' }],
173
+ };
174
+ // Prop-mode list at page level resolves its source from cmsContext.cms
175
+ const cmsContext = {
176
+ cms: {
177
+ tags: [
178
+ { name: 'Shown', visible: true },
179
+ { name: 'Hidden', visible: false },
180
+ ],
181
+ },
182
+ };
183
+ const html = await render(node, { cmsContext });
184
+ expect(html).toContain('Shown');
185
+ expect(html).not.toContain('Hidden');
186
+ });
187
+
188
+ test('item-template if resolving to 0 is falsy', async () => {
189
+ const node = {
190
+ type: 'list',
191
+ sourceType: 'prop',
192
+ source: 'tags',
193
+ itemAs: 'tag',
194
+ children: [{ type: 'node', tag: 'span', if: '{{tag.count}}', children: '{{tag.name}}' }],
195
+ };
196
+ const cmsContext = {
197
+ cms: {
198
+ tags: [
199
+ { name: 'HasCount', count: 3 },
200
+ { name: 'ZeroCount', count: 0 },
201
+ ],
202
+ },
203
+ };
204
+ const html = await render(node, { cmsContext });
205
+ expect(html).toContain('HasCount');
206
+ expect(html).not.toContain('ZeroCount');
207
+ });
208
+ });
209
+
210
+ // -----------------------------------------------------------------------
211
+ // 2. CMS filter template resolution (resolveFilterTemplates / resolveFilterValue)
212
+ // -----------------------------------------------------------------------
213
+ describe('collection list filter templates', () => {
214
+ function makeCapturingCmsService(items: any[] = [{ _id: 'p1', title: 'Post One' }]) {
215
+ const queries: any[] = [];
216
+ return {
217
+ queries,
218
+ service: {
219
+ queryItems: async (query: any) => {
220
+ queries.push(query);
221
+ return items;
222
+ },
223
+ getItemsByIds: async () => items,
224
+ getSchema: () => undefined,
225
+ },
226
+ };
227
+ }
228
+
229
+ test('array filter: template values resolve against parent list scope, literals pass through', async () => {
230
+ const { queries, service } = makeCapturingCmsService();
231
+ const node = {
232
+ type: 'list',
233
+ sourceType: 'prop',
234
+ source: 'categories',
235
+ itemAs: 'category',
236
+ children: [
237
+ {
238
+ type: 'list',
239
+ sourceType: 'collection',
240
+ source: 'posts',
241
+ filter: [
242
+ { field: 'category', value: '{{category.slug}}' },
243
+ { field: 'status', value: 'published' },
244
+ ],
245
+ children: [{ type: 'node', tag: 'span', children: '{{post.title}}' }],
246
+ },
247
+ ],
248
+ };
249
+ const cmsContext = { cms: { categories: [{ slug: 'tech' }] } };
250
+ const html = await render(node, { cmsContext, cmsService: service });
251
+
252
+ expect(html).toContain('Post One');
253
+ expect(queries.length).toBe(1);
254
+ expect(queries[0].filter).toEqual([
255
+ { field: 'category', value: 'tech' },
256
+ { field: 'status', value: 'published' },
257
+ ]);
258
+ });
259
+
260
+ test('single condition object filter resolves its template value', async () => {
261
+ const { queries, service } = makeCapturingCmsService();
262
+ const node = {
263
+ type: 'list',
264
+ sourceType: 'prop',
265
+ source: 'categories',
266
+ itemAs: 'category',
267
+ children: [
268
+ {
269
+ type: 'list',
270
+ sourceType: 'collection',
271
+ source: 'posts',
272
+ filter: { field: 'category', value: '{{category.slug}}' },
273
+ children: [{ type: 'node', tag: 'span', children: '{{post.title}}' }],
274
+ },
275
+ ],
276
+ };
277
+ const cmsContext = { cms: { categories: [{ slug: 'design' }] } };
278
+ await render(node, { cmsContext, cmsService: service });
279
+
280
+ expect(queries[0].filter).toEqual({ field: 'category', value: 'design' });
281
+ });
282
+
283
+ test('unresolvable template value drops the condition and logs a warning', async () => {
284
+ const { queries, service } = makeCapturingCmsService();
285
+ const logged = captureErrorLog();
286
+ const node = {
287
+ type: 'list',
288
+ sourceType: 'prop',
289
+ source: 'categories',
290
+ itemAs: 'category',
291
+ children: [
292
+ {
293
+ type: 'list',
294
+ sourceType: 'collection',
295
+ source: 'posts',
296
+ filter: [
297
+ { field: 'category', value: '{{category.missing}}' },
298
+ { field: 'status', value: 'published' },
299
+ ],
300
+ children: ['{{post.title}}'],
301
+ },
302
+ ],
303
+ };
304
+ const cmsContext = { cms: { categories: [{ slug: 'tech' }] } };
305
+ await render(node, { cmsContext, cmsService: service });
306
+
307
+ // An unresolved filter template would be sent to the CMS query verbatim
308
+ // ('{{category.missing}}') and silently match nothing. Instead the
309
+ // condition is dropped from the query (resolvable conditions survive)…
310
+ expect(queries[0].filter).toEqual([{ field: 'status', value: 'published' }]);
311
+ // …and the drop is surfaced through the runtime error logger so users
312
+ // can see why the list's results changed.
313
+ expect(logged.length).toBe(1);
314
+ expect(logged[0]!.location).toBe('ssrRenderer.resolveFilterTemplates');
315
+ expect(logged[0]!.context).toMatchObject({
316
+ collection: 'posts',
317
+ field: 'category',
318
+ template: '{{category.missing}}',
319
+ });
320
+ });
321
+ });
322
+
323
+ // -----------------------------------------------------------------------
324
+ // 3. expandRichTextComponents — component markers in raw HTML
325
+ // -----------------------------------------------------------------------
326
+ describe('rich-text component expansion', () => {
327
+ const Icon: ComponentDefinition = {
328
+ component: {
329
+ structure: { type: 'node', tag: 'i', children: '{{name}}' },
330
+ interface: { name: { type: 'string', default: 'default-icon' } },
331
+ },
332
+ };
333
+
334
+ test('expands a known component marker, preserving surrounding HTML', async () => {
335
+ const raw =
336
+ RAW_HTML_PREFIX +
337
+ '<p>before</p>' +
338
+ '<div data-meno-component="BrIcon" data-meno-props="{&quot;name&quot;:&quot;star&quot;}"></div>' +
339
+ '<p>after</p>';
340
+ const result = await renderFull(raw, { globalComponents: { BrIcon: Icon } });
341
+ expect(result.html).toContain('<p>before</p>');
342
+ expect(result.html).toContain('<p>after</p>');
343
+ // The marker div is replaced by the rendered component; the rendered
344
+ // root carries the regular component-instance stamp.
345
+ expect(result.html).toContain('<i data-meno-component="BrIcon" data-meno-component-instance="0">star</i>');
346
+ expect(result.html).not.toContain('data-meno-props');
347
+ // Raw→processed mapping collected for the Astro exporter
348
+ const rawSlice = raw.slice(RAW_HTML_PREFIX.length);
349
+ expect(result.processedRawHtmlCollector.get(rawSlice)).toBe(result.html);
350
+ });
351
+
352
+ test('keeps marker verbatim for unknown components', async () => {
353
+ const marker = '<div data-meno-component="BrNotRegistered" data-meno-props=""></div>';
354
+ const html = await render(RAW_HTML_PREFIX + marker);
355
+ expect(html).toContain(marker);
356
+ });
357
+
358
+ test('invalid props JSON falls back to default props and logs the parse failure', async () => {
359
+ const logged = captureErrorLog();
360
+ const raw = `${RAW_HTML_PREFIX}<div data-meno-component="BrIcon" data-meno-props="{not-json"></div>`;
361
+ const html = await render(raw, { globalComponents: { BrIcon: Icon } });
362
+ // Graceful fallback: the component still renders with interface defaults
363
+ // (a malformed marker must never crash the page)…
364
+ expect(html).toContain('default-icon');
365
+ // …but the parse failure is surfaced, including the component name.
366
+ expect(logged.length).toBe(1);
367
+ expect(logged[0]!.location).toBe('ssrRenderer.expandRichTextComponents');
368
+ expect(logged[0]!.context).toMatchObject({ component: 'BrIcon' });
369
+ });
370
+
371
+ test('expands multiple markers in one raw HTML string', async () => {
372
+ const raw =
373
+ RAW_HTML_PREFIX +
374
+ '<div data-meno-component="BrIcon" data-meno-props="{&quot;name&quot;:&quot;one&quot;}"></div>' +
375
+ '<span>mid</span>' +
376
+ '<div data-meno-component="BrIcon" data-meno-props="{&quot;name&quot;:&quot;two&quot;}"></div>';
377
+ const html = await render(raw, { globalComponents: { BrIcon: Icon } });
378
+ expect(html).toContain('one');
379
+ expect(html).toContain('<span>mid</span>');
380
+ expect(html).toContain('two');
381
+ });
382
+ });
383
+
384
+ // -----------------------------------------------------------------------
385
+ // 4. Nested cms-list placeholder (emitTemplate + nested collection list)
386
+ // -----------------------------------------------------------------------
387
+ describe('nested collection list placeholder', () => {
388
+ test('nested collection list inside emitTemplate emits a hydration placeholder', async () => {
389
+ const cmsService = {
390
+ queryItems: async (query: any) => {
391
+ if (query.collection === 'posts') return [{ _id: 'p1', title: 'Post One' }];
392
+ if (query.collection === 'tags') return [{ _id: 't1', name: 'TagOne' }];
393
+ return [];
394
+ },
395
+ getItemsByIds: async () => [],
396
+ getSchema: () => undefined,
397
+ };
398
+
399
+ const node = {
400
+ type: 'list',
401
+ sourceType: 'collection',
402
+ source: 'posts',
403
+ emitTemplate: true,
404
+ children: [
405
+ { type: 'node', tag: 'h2', children: '{{post.title}}' },
406
+ {
407
+ type: 'list',
408
+ sourceType: 'collection',
409
+ source: 'tags',
410
+ itemAs: 'tag',
411
+ limit: 5,
412
+ children: [{ type: 'node', tag: 'span', children: '{{tag.name}}' }],
413
+ },
414
+ ],
415
+ };
416
+
417
+ const result = await renderFull(node, { cmsService });
418
+
419
+ // Regular item render: both lists fully rendered
420
+ expect(result.html).toContain('Post One');
421
+ expect(result.html).toContain('TagOne');
422
+
423
+ // Template emission: nested list becomes a placeholder with config + inner template
424
+ expect(result.html).toContain('<template data-meno-item>');
425
+ expect(result.html).toContain('data-cms-list-nested="true"');
426
+ expect(result.html).toContain('data-collection="tags"');
427
+ expect(result.html).toContain('data-cms-config=');
428
+ expect(result.html).toContain('<template data-nested-template>');
429
+ // Inner template preserves {{tag.name}} placeholders for client hydration
430
+ expect(result.html).toContain('{{tag.name}}');
431
+ // Config JSON is escaped into the attribute
432
+ expect(result.html).toContain('&quot;collection&quot;:&quot;tags&quot;');
433
+ expect(result.html).toContain('&quot;limit&quot;:5');
434
+
435
+ // The nested collection is tracked for client-side data injection
436
+ expect(result.neededCollections.has('tags')).toBe(true);
437
+ });
438
+ });
439
+
440
+ // -----------------------------------------------------------------------
441
+ // 5. Draft filtering on ID-based collection queries
442
+ // -----------------------------------------------------------------------
443
+ describe('ID-based collection list draft filtering', () => {
444
+ test('filters out items that are draft for the active locale', async () => {
445
+ const cmsService = {
446
+ getItemsByIds: async () => [
447
+ { _id: '1', title: 'Published Item' },
448
+ { _id: '2', title: 'Draft Item', _draftLocales: ['en'] },
449
+ ],
450
+ queryItems: async () => [],
451
+ getSchema: () => undefined,
452
+ };
453
+ const node = {
454
+ type: 'list',
455
+ sourceType: 'collection',
456
+ source: 'posts',
457
+ items: ['1', '2'],
458
+ children: [{ type: 'node', tag: 'span', children: '{{post.title}}' }],
459
+ };
460
+ const html = await render(node, { cmsService, locale: 'en', i18nConfig: I18N_EN_FR });
461
+ expect(html).toContain('Published Item');
462
+ expect(html).not.toContain('Draft Item');
463
+ });
464
+
465
+ test('keeps items that are draft only for other locales', async () => {
466
+ const cmsService = {
467
+ getItemsByIds: async () => [{ _id: '2', title: 'FR Draft', _draftLocales: ['fr'] }],
468
+ queryItems: async () => [],
469
+ getSchema: () => undefined,
470
+ };
471
+ const node = {
472
+ type: 'list',
473
+ sourceType: 'collection',
474
+ source: 'posts',
475
+ items: ['2'],
476
+ children: [{ type: 'node', tag: 'span', children: '{{post.title}}' }],
477
+ };
478
+ const html = await render(node, { cmsService, locale: 'en', i18nConfig: I18N_EN_FR });
479
+ expect(html).toContain('FR Draft');
480
+ });
481
+ });
482
+
483
+ // -----------------------------------------------------------------------
484
+ // 6. getPropItems — template-expression and legacy-collection sources
485
+ // -----------------------------------------------------------------------
486
+ describe('prop-source list resolution', () => {
487
+ test('nested list with {{parent.field}} source resolves from parent template context', async () => {
488
+ const node = {
489
+ type: 'list',
490
+ sourceType: 'prop',
491
+ source: 'sections',
492
+ itemAs: 'section',
493
+ children: [
494
+ {
495
+ type: 'list',
496
+ sourceType: 'prop',
497
+ source: '{{section.items}}',
498
+ children: [{ type: 'node', tag: 'span', children: '{{item.label}}' }],
499
+ },
500
+ ],
501
+ };
502
+ const cmsContext = { cms: { sections: [{ items: [{ label: 'X' }, { label: 'Y' }] }] } };
503
+ const html = await render(node, { cmsContext });
504
+ expect(html).toContain('X');
505
+ expect(html).toContain('Y');
506
+ });
507
+
508
+ test('{{parent.field}} source resolving to a non-array renders nothing', async () => {
509
+ const node = {
510
+ type: 'list',
511
+ sourceType: 'prop',
512
+ source: 'sections',
513
+ itemAs: 'section',
514
+ children: [
515
+ {
516
+ type: 'list',
517
+ sourceType: 'prop',
518
+ source: '{{section.items}}',
519
+ children: [{ type: 'node', tag: 'span', children: '{{item.label}}' }],
520
+ },
521
+ ],
522
+ };
523
+ const cmsContext = { cms: { sections: [{ items: 'not-an-array' }] } };
524
+ const html = await render(node, { cmsContext });
525
+ expect(html).toBe('');
526
+ });
527
+
528
+ test('bare source naming a non-array CMS field renders nothing', async () => {
529
+ const node = {
530
+ type: 'list',
531
+ sourceType: 'prop',
532
+ source: 'title',
533
+ children: [{ type: 'node', tag: 'span', children: '{{item.label}}' }],
534
+ };
535
+ const cmsContext = { cms: { title: 'just a string' } };
536
+ const html = await render(node, { cmsContext });
537
+ expect(html).toBe('');
538
+ });
539
+
540
+ test('legacy `collection` key with sourceType prop resolves from component props', async () => {
541
+ // processStructure pre-resolves bare `source` prop names, but the legacy
542
+ // `collection` key is untouched — exercising the componentResolvedProps
543
+ // lookup branch in getPropItems.
544
+ const Direct: ComponentDefinition = {
545
+ component: {
546
+ interface: { features: { type: 'list' as const, itemSchema: {}, default: [] } },
547
+ structure: {
548
+ type: 'list',
549
+ sourceType: 'prop',
550
+ collection: 'features',
551
+ itemAs: 'feature',
552
+ children: [{ type: 'node', tag: 'li', children: '{{feature.label}}' }],
553
+ } as any,
554
+ },
555
+ };
556
+ const node = {
557
+ type: 'component',
558
+ component: 'BrLegacyList',
559
+ props: { features: [{ label: 'Fast' }, { label: 'Safe' }] },
560
+ };
561
+ const html = await render(node, { globalComponents: { BrLegacyList: Direct } });
562
+ expect(html).toContain('Fast');
563
+ expect(html).toContain('Safe');
564
+ });
565
+ });
566
+
567
+ // -----------------------------------------------------------------------
568
+ // 7. Interactive style mappings → CSS variable inline style
569
+ // -----------------------------------------------------------------------
570
+ describe('interactive style mappings (CSS variables)', () => {
571
+ test('link node mapping resolves to inline --is-N variable from component props', async () => {
572
+ const Btn: ComponentDefinition = {
573
+ component: {
574
+ interface: { variant: { type: 'string', default: 'primary' } },
575
+ structure: {
576
+ type: 'link',
577
+ href: '/go',
578
+ interactiveStyles: [
579
+ {
580
+ postfix: ':hover',
581
+ style: {
582
+ color: { _mapping: true, prop: 'variant', values: { primary: '#0070f3', secondary: '#999999' } },
583
+ },
584
+ },
585
+ ],
586
+ children: 'Go',
587
+ } as any,
588
+ },
589
+ };
590
+ const node = { type: 'component', component: 'BrMappedLink', props: { variant: 'primary' } };
591
+ const result = await renderFull(node, { globalComponents: { BrMappedLink: Btn }, pagePath: '/test' });
592
+
593
+ // Inline style carries the resolved CSS variable
594
+ expect(result.html).toContain('style="--is-0: #0070f3"');
595
+ // The registered interactive style references the variable, not the mapping
596
+ const [styles] = [...result.interactiveStylesMap.values()];
597
+ expect(JSON.stringify(styles)).toContain('var(--is-0)');
598
+ expect(JSON.stringify(styles)).not.toContain('_mapping');
599
+ });
600
+
601
+ test('mapping with prop value not in values map emits no CSS variable', async () => {
602
+ const Btn: ComponentDefinition = {
603
+ component: {
604
+ interface: { variant: { type: 'string', default: 'unknown-variant' } },
605
+ structure: {
606
+ type: 'link',
607
+ href: '/go',
608
+ interactiveStyles: [
609
+ {
610
+ postfix: ':hover',
611
+ style: { color: { _mapping: true, prop: 'variant', values: { primary: '#0070f3' } } },
612
+ },
613
+ ],
614
+ children: 'Go',
615
+ } as any,
616
+ },
617
+ };
618
+ const node = { type: 'component', component: 'BrMappedLinkMiss', props: {} };
619
+ const result = await renderFull(node, { globalComponents: { BrMappedLinkMiss: Btn }, pagePath: '/test' });
620
+ expect(result.html).not.toContain('--is-0:');
621
+ // Style still registered with the var() reference even though it is unset
622
+ const [styles] = [...result.interactiveStylesMap.values()];
623
+ expect(JSON.stringify(styles)).toContain('var(--is-0)');
624
+ });
625
+ });
626
+
627
+ // -----------------------------------------------------------------------
628
+ // 8. Verbatim-code markers
629
+ // -----------------------------------------------------------------------
630
+ describe('verbatim code markers', () => {
631
+ test('renders nothing for { _code: true } nodes', async () => {
632
+ const html = await render({ _code: true, expr: 'doSomething()' });
633
+ expect(html).toBe('');
634
+ });
635
+
636
+ test('renders siblings around a _code marker', async () => {
637
+ const html = await render([
638
+ { type: 'node', tag: 'span', children: 'before' },
639
+ { _code: true, expr: 'skipMe()' },
640
+ { type: 'node', tag: 'span', children: 'after' },
641
+ ]);
642
+ expect(html).toContain('before');
643
+ expect(html).toContain('after');
644
+ expect(html).not.toContain('skipMe');
645
+ });
646
+ });
647
+
648
+ // -----------------------------------------------------------------------
649
+ // 9. Template mode text: CMS substitution + raw HTML
650
+ // -----------------------------------------------------------------------
651
+ describe('template mode text processing', () => {
652
+ const cmsService = {
653
+ queryItems: async () => [{ _id: 'p1', title: 'Post One' }],
654
+ getItemsByIds: async () => [],
655
+ getSchema: () => undefined,
656
+ };
657
+
658
+ test('emitTemplate processes {{cms.*}} but preserves {{item.*}} placeholders', async () => {
659
+ const node = {
660
+ type: 'list',
661
+ sourceType: 'collection',
662
+ source: 'posts',
663
+ emitTemplate: true,
664
+ children: [{ type: 'node', tag: 'span', children: '{{cms.brand}}: {{post.title}}' }],
665
+ };
666
+ const cmsContext = { cms: { brand: 'Acme' } };
667
+ const html = await render(node, { cmsService, cmsContext });
668
+ // Template part keeps the item placeholder but substitutes the CMS value
669
+ expect(html).toContain('<template data-meno-item>');
670
+ expect(html).toContain('Acme: {{post.title}}');
671
+ });
672
+
673
+ test('emitTemplate emits rich-text CMS fields as raw HTML inside the template', async () => {
674
+ const node = {
675
+ type: 'list',
676
+ sourceType: 'collection',
677
+ source: 'posts',
678
+ emitTemplate: true,
679
+ children: [{ type: 'node', tag: 'div', children: '{{cms.body}}' }],
680
+ };
681
+ const cmsContext = { cms: { body: { __richtext__: true, html: '<p>Rich <b>content</b></p>' } } };
682
+ const result = await renderFull(node, { cmsService, cmsContext });
683
+ expect(result.html).toContain('<p>Rich <b>content</b></p>');
684
+ expect(result.html).not.toContain('&lt;p&gt;');
685
+ // Raw→processed mapping collected
686
+ expect(result.processedRawHtmlCollector.get('<p>Rich <b>content</b></p>')).toBe('<p>Rich <b>content</b></p>');
687
+ });
688
+ });
689
+
690
+ // -----------------------------------------------------------------------
691
+ // 10. Embed nodes — html mappings, template mode, item templates, classes
692
+ // -----------------------------------------------------------------------
693
+ describe('embed node branches', () => {
694
+ test('html mapping (passthrough) resolves from component props', async () => {
695
+ const Logo: ComponentDefinition = {
696
+ component: {
697
+ interface: { svg: { type: 'string', default: '<svg viewBox="0 0 10 10"></svg>' } },
698
+ structure: { type: 'embed', html: { _mapping: true, prop: 'svg' } } as any,
699
+ },
700
+ };
701
+ const node = { type: 'component', component: 'BrEmbedMap', props: {} };
702
+ const html = await render(node, { globalComponents: { BrEmbedMap: Logo } });
703
+ expect(html).toContain('<span class="oem"');
704
+ expect(html).toContain('<svg');
705
+ });
706
+
707
+ test('html mapping (value map) resolves the variant HTML', async () => {
708
+ const IconSet: ComponentDefinition = {
709
+ component: {
710
+ interface: { icon: { type: 'string', default: 'arrow' } },
711
+ structure: {
712
+ type: 'embed',
713
+ html: { _mapping: true, prop: 'icon', values: { arrow: '<b>arrow-svg</b>', check: '<b>check-svg</b>' } },
714
+ } as any,
715
+ },
716
+ };
717
+ const node = { type: 'component', component: 'BrEmbedValues', props: { icon: 'check' } };
718
+ const html = await render(node, { globalComponents: { BrEmbedValues: IconSet } });
719
+ expect(html).toContain('check-svg');
720
+ expect(html).not.toContain('arrow-svg');
721
+ });
722
+
723
+ test('html mapping that cannot resolve renders an empty embed span', async () => {
724
+ const Broken: ComponentDefinition = {
725
+ component: {
726
+ interface: {},
727
+ structure: { type: 'embed', html: { _mapping: true, prop: 'missing' } } as any,
728
+ },
729
+ };
730
+ const node = { type: 'component', component: 'BrEmbedMiss', props: {} };
731
+ const html = await render(node, { globalComponents: { BrEmbedMiss: Broken } });
732
+ expect(html).toContain('<span class="oem"');
733
+ expect(html).toContain('></span>');
734
+ });
735
+
736
+ test('html mapping on a page-level embed (no component props in scope) renders empty', async () => {
737
+ // Inside a component, processStructure resolves the mapping before the
738
+ // renderer sees it; at page level the renderer resolves it itself and
739
+ // has no componentResolvedProps → empty content.
740
+ const html = await render({ type: 'embed', html: { _mapping: true, prop: 'svg' } });
741
+ expect(html).toContain('<span class="oem"');
742
+ expect(html).toContain('></span>');
743
+ });
744
+
745
+ test('non-string non-mapping html value renders an empty embed span', async () => {
746
+ const html = await render({ type: 'embed', html: 12345 });
747
+ expect(html).toContain('<span class="oem"');
748
+ expect(html).toContain('></span>');
749
+ expect(html).not.toContain('12345');
750
+ });
751
+
752
+ test('embed in emitTemplate processes {{cms.*}} in html', async () => {
753
+ const cmsService = {
754
+ queryItems: async () => [{ _id: 'p1', title: 'Post One' }],
755
+ getItemsByIds: async () => [],
756
+ getSchema: () => undefined,
757
+ };
758
+ const node = {
759
+ type: 'list',
760
+ sourceType: 'collection',
761
+ source: 'posts',
762
+ emitTemplate: true,
763
+ children: [{ type: 'embed', html: '<b>{{cms.brand}}</b>' }],
764
+ };
765
+ const cmsContext = { cms: { brand: 'Acme' } };
766
+ const html = await render(node, { cmsService, cmsContext });
767
+ expect(html).toContain('<b>Acme</b>');
768
+ });
769
+
770
+ test('embed inside a list processes {{item.*}} templates in html', async () => {
771
+ const node = {
772
+ type: 'list',
773
+ sourceType: 'prop',
774
+ source: 'icons',
775
+ children: [{ type: 'embed', html: '<b>{{item.glyph}}</b>' }],
776
+ };
777
+ const cmsContext = { cms: { icons: [{ glyph: 'STAR' }] } };
778
+ const html = await render(node, { cmsContext });
779
+ expect(html).toContain('<b>STAR</b>');
780
+ });
781
+
782
+ test('embed with interactive styles merges attribute class into class list', async () => {
783
+ const node = {
784
+ type: 'embed',
785
+ html: '<b>x</b>',
786
+ interactiveStyles: [{ postfix: ':hover', style: { opacity: '0.5' } }],
787
+ attributes: { class: 'embed-extra' },
788
+ };
789
+ const result = await renderFull(node, { pagePath: '/test' });
790
+ const classMatch = result.html.match(/class="([^"]*)"/);
791
+ expect(classMatch).not.toBeNull();
792
+ expect(classMatch![1]).toContain('oem');
793
+ expect(classMatch![1]).toContain('embed-extra');
794
+ // Element class registered for interactive CSS generation
795
+ expect(result.interactiveStylesMap.size).toBe(1);
796
+ const [elementClass] = [...result.interactiveStylesMap.keys()];
797
+ expect(classMatch![1]).toContain(elementClass);
798
+ });
799
+ });
800
+
801
+ // -----------------------------------------------------------------------
802
+ // 11. Link nodes — link-object hrefs from item templates
803
+ // -----------------------------------------------------------------------
804
+ describe('link node item-template hrefs', () => {
805
+ test('href template resolving to a link object extracts href and target', async () => {
806
+ const node = {
807
+ type: 'list',
808
+ sourceType: 'prop',
809
+ source: 'links',
810
+ children: [{ type: 'link', href: '{{item.link}}', children: '{{item.label}}' }],
811
+ };
812
+ const cmsContext = { cms: { links: [{ label: 'About', link: { href: '/about', target: '_blank' } }] } };
813
+ const html = await render(node, { cmsContext });
814
+ expect(html).toContain('href="/about"');
815
+ expect(html).toContain('target="_blank"');
816
+ expect(html).toContain('About');
817
+ });
818
+
819
+ test('double-wrapped link objects are unwrapped (target hoisted from inner)', async () => {
820
+ const node = {
821
+ type: 'list',
822
+ sourceType: 'prop',
823
+ source: 'links',
824
+ children: [{ type: 'link', href: '{{item.link}}', children: 'Inner' }],
825
+ };
826
+ const cmsContext = {
827
+ cms: { links: [{ link: { href: { href: '/inner', target: '_blank' } } }] },
828
+ };
829
+ const html = await render(node, { cmsContext });
830
+ expect(html).toContain('href="/inner"');
831
+ expect(html).toContain('target="_blank"');
832
+ });
833
+
834
+ test('explicit target attribute wins over link-object target', async () => {
835
+ const node = {
836
+ type: 'list',
837
+ sourceType: 'prop',
838
+ source: 'links',
839
+ children: [{ type: 'link', href: '{{item.link}}', attributes: { target: '_self' }, children: 'Self' }],
840
+ };
841
+ const cmsContext = { cms: { links: [{ link: { href: '/x', target: '_blank' } }] } };
842
+ const html = await render(node, { cmsContext });
843
+ expect(html).toContain('target="_self"');
844
+ expect(html).not.toContain('target="_blank"');
845
+ });
846
+
847
+ test('href template resolving to a plain string is interpolated', async () => {
848
+ const node = {
849
+ type: 'list',
850
+ sourceType: 'prop',
851
+ source: 'links',
852
+ children: [{ type: 'link', href: '{{item.url}}', children: 'Plain' }],
853
+ };
854
+ const cmsContext = { cms: { links: [{ url: '/plain-target' }] } };
855
+ const html = await render(node, { cmsContext });
856
+ expect(html).toContain('href="/plain-target"');
857
+ });
858
+ });
859
+
860
+ // -----------------------------------------------------------------------
861
+ // 12. Component instance with responsive props.style (no node.style)
862
+ // -----------------------------------------------------------------------
863
+ describe('component instance with responsive props.style', () => {
864
+ test('responsive props.style is merged at desktop viewport and applied to root', async () => {
865
+ const Card: ComponentDefinition = {
866
+ component: {
867
+ interface: {},
868
+ structure: { type: 'node', tag: 'div', children: 'Card content' },
869
+ },
870
+ };
871
+ const node = {
872
+ type: 'component',
873
+ component: 'BrRespCard',
874
+ props: { style: { base: { padding: '40px' }, mobile: { padding: '10px' } } },
875
+ };
876
+ const html = await render(node, { globalComponents: { BrRespCard: Card } });
877
+ expect(html).toContain('Card content');
878
+ // SSR renders at the desktop viewport (1920px) → base tier wins
879
+ expect(html).toContain('class="');
880
+ expect(html).toContain('p-10');
881
+ expect(html).not.toContain('p-2.5');
882
+ });
883
+ });
884
+
885
+ // -----------------------------------------------------------------------
886
+ // 13. <a> tag is-current appended to existing classes
887
+ // -----------------------------------------------------------------------
888
+ describe('anchor is-current class merging', () => {
889
+ test('is-current is appended after existing classes for the current page', async () => {
890
+ const node = {
891
+ type: 'node',
892
+ tag: 'a',
893
+ attributes: { href: '/about', class: 'nav-link' },
894
+ children: 'About',
895
+ };
896
+ const html = await render(node, { pagePath: '/about' });
897
+ expect(html).toContain('class="nav-link is-current"');
898
+ });
899
+
900
+ test('non-matching href keeps original classes only', async () => {
901
+ const node = {
902
+ type: 'node',
903
+ tag: 'a',
904
+ attributes: { href: '/contact', class: 'nav-link' },
905
+ children: 'Contact',
906
+ };
907
+ const html = await render(node, { pagePath: '/about' });
908
+ expect(html).toContain('class="nav-link"');
909
+ expect(html).not.toContain('is-current');
910
+ });
911
+ });
912
+
913
+ // -----------------------------------------------------------------------
914
+ // 14. Raw text elements (<style> / <script>) — children emitted verbatim
915
+ // -----------------------------------------------------------------------
916
+ describe('raw text elements', () => {
917
+ test('<style> children are not HTML-escaped', async () => {
918
+ const css = '.a > .b { background: url("x.png?a=1&b=2"); }';
919
+ const html = await render({ type: 'node', tag: 'style', children: [css] });
920
+ expect(html).toBe(`<style>${css}</style>`);
921
+ });
922
+
923
+ test('<script> string child is emitted verbatim', async () => {
924
+ const js = 'if (a < b && c > d) { run("x"); }';
925
+ const html = await render({ type: 'node', tag: 'script', children: js });
926
+ expect(html).toBe(`<script>${js}</script>`);
927
+ });
928
+
929
+ test('flatten handles numbers, nested arrays, null, and drops objects with a logged warning', async () => {
930
+ const logged = captureErrorLog();
931
+ const html = await render({
932
+ type: 'node',
933
+ tag: 'script',
934
+ children: ['var n = ', 42, [';', null, [' // tail']], { type: 'node', tag: 'span', children: 'dropped' }],
935
+ });
936
+ // Object children inside <script>/<style> are dropped — raw-text elements
937
+ // cannot contain child elements, so this is correct output behavior…
938
+ expect(html).toBe('<script>var n = 42; // tail</script>');
939
+ // …but the authored content silently vanishing is surfaced via the logger.
940
+ expect(logged.length).toBe(1);
941
+ expect(logged[0]!.location).toBe('ssrRenderer.renderHtmlElement');
942
+ expect(logged[0]!.context).toMatchObject({ tag: 'script' });
943
+ });
944
+
945
+ test('verbatim _code markers inside <script> are dropped silently (by design)', async () => {
946
+ const logged = captureErrorLog();
947
+ const html = await render({
948
+ type: 'node',
949
+ tag: 'script',
950
+ children: ['var a = 1;', { _code: true, expr: 'nativeOnly()' }],
951
+ });
952
+ // _code markers render natively in the Astro build and have nothing to
953
+ // emit in meno-core SSR — mirroring renderNode, they drop without noise.
954
+ expect(html).toBe('<script>var a = 1;</script>');
955
+ expect(logged.length).toBe(0);
956
+ });
957
+ });
958
+
959
+ // -----------------------------------------------------------------------
960
+ // 15. Locale-list interactive styles
961
+ // -----------------------------------------------------------------------
962
+ describe('locale-list interactive styles', () => {
963
+ const slugMappings = [{ pageId: 'about', slugs: { en: 'about', fr: 'a-propos' } }];
964
+
965
+ test('locale-list with interactiveStyles registers element class on container', async () => {
966
+ const node = {
967
+ type: 'locale-list',
968
+ interactiveStyles: [{ postfix: ':hover', style: { opacity: '0.7' } }],
969
+ };
970
+ const result = await renderFull(node, {
971
+ locale: 'en',
972
+ i18nConfig: I18N_EN_FR,
973
+ slugMappings,
974
+ pagePath: '/about',
975
+ });
976
+ expect(result.html).toContain('data-locale-list="true"');
977
+ expect(result.interactiveStylesMap.size).toBe(1);
978
+ const [elementClass] = [...result.interactiveStylesMap.keys()];
979
+ expect(result.html).toContain(`class="${elementClass}"`);
980
+ });
981
+
982
+ test('locale-list with generateElementClass gets element class without styles', async () => {
983
+ const node = { type: 'locale-list', generateElementClass: true };
984
+ const result = await renderFull(node, {
985
+ locale: 'en',
986
+ i18nConfig: I18N_EN_FR,
987
+ slugMappings,
988
+ pagePath: '/about',
989
+ });
990
+ expect(result.html).toMatch(/data-locale-list="true" class="[^"]+"/);
991
+ expect(result.interactiveStylesMap.size).toBe(0);
992
+ });
993
+ });
994
+
995
+ // -----------------------------------------------------------------------
996
+ // 16. renderComponent error fallback
997
+ // -----------------------------------------------------------------------
998
+ describe('component render error fallback', () => {
999
+ function makeThrowingDef(): ComponentDefinition {
1000
+ return {
1001
+ component: {
1002
+ interface: {},
1003
+ get structure() {
1004
+ throw new Error('structure exploded');
1005
+ },
1006
+ },
1007
+ } as unknown as ComponentDefinition;
1008
+ }
1009
+
1010
+ test('component that throws renders nothing outside the editor', async () => {
1011
+ const node = { type: 'component', component: 'BrThrows', props: {} };
1012
+ const html = await render(node, { globalComponents: { BrThrows: makeThrowingDef() } });
1013
+ expect(html).toBe('');
1014
+ });
1015
+
1016
+ test('component that throws renders a visible error placeholder in editor preview', async () => {
1017
+ const node = { type: 'component', component: 'BrThrowsEditor', props: {} };
1018
+ const html = await render(node, {
1019
+ globalComponents: { BrThrowsEditor: makeThrowingDef() },
1020
+ injectEditorAttrs: true,
1021
+ });
1022
+ // Errors surface in the editor instead of vanishing (see commit de167014)
1023
+ expect(html).toContain('data-meno-render-error');
1024
+ expect(html).toContain('⚠️');
1025
+ });
1026
+
1027
+ test('a broken component does not take down sibling nodes', async () => {
1028
+ const nodes = [
1029
+ { type: 'node', tag: 'p', children: 'before' },
1030
+ { type: 'component', component: 'BrThrowsSibling', props: {} },
1031
+ { type: 'node', tag: 'p', children: 'after' },
1032
+ ];
1033
+ const html = await render(nodes, { globalComponents: { BrThrowsSibling: makeThrowingDef() } });
1034
+ expect(html).toContain('before');
1035
+ expect(html).toContain('after');
1036
+ });
1037
+ });
1038
+
1039
+ // -----------------------------------------------------------------------
1040
+ // 17. renderComponent className merging onto structure roots
1041
+ // -----------------------------------------------------------------------
1042
+ describe('component instance class merging', () => {
1043
+ test('instance class is appended to an HTML root with an existing class', async () => {
1044
+ const Box: ComponentDefinition = {
1045
+ component: {
1046
+ interface: {},
1047
+ structure: { type: 'node', tag: 'div', attributes: { class: 'root-cls' }, children: 'Box' } as any,
1048
+ },
1049
+ };
1050
+ const node = { type: 'component', component: 'BrBoxA', attributes: { class: 'inst-cls' }, props: {} };
1051
+ const html = await render(node, { globalComponents: { BrBoxA: Box } });
1052
+ expect(html).toMatch(/class="[^"]*root-cls[^"]*inst-cls[^"]*"/);
1053
+ });
1054
+
1055
+ test('instance class lands on an HTML root without an existing class', async () => {
1056
+ const Box: ComponentDefinition = {
1057
+ component: {
1058
+ interface: {},
1059
+ structure: { type: 'node', tag: 'div', children: 'Box' },
1060
+ },
1061
+ };
1062
+ const node = { type: 'component', component: 'BrBoxB', attributes: { class: 'only-inst' }, props: {} };
1063
+ const html = await render(node, { globalComponents: { BrBoxB: Box } });
1064
+ expect(html).toContain('only-inst');
1065
+ });
1066
+
1067
+ test('instance class flows through a nested-component root to the final element', async () => {
1068
+ const Inner: ComponentDefinition = {
1069
+ component: {
1070
+ interface: {},
1071
+ structure: { type: 'node', tag: 'div', children: 'Inner' },
1072
+ },
1073
+ };
1074
+ const Outer: ComponentDefinition = {
1075
+ component: {
1076
+ interface: {},
1077
+ structure: { type: 'component', component: 'BrNestedInner', props: {} } as any,
1078
+ },
1079
+ };
1080
+ const node = { type: 'component', component: 'BrNestedOuter', attributes: { class: 'outer-cls' }, props: {} };
1081
+ const html = await render(node, {
1082
+ globalComponents: { BrNestedOuter: Outer, BrNestedInner: Inner },
1083
+ });
1084
+ expect(html).toContain('Inner');
1085
+ expect(html).toContain('outer-cls');
1086
+ });
1087
+ });
1088
+
1089
+ // -----------------------------------------------------------------------
1090
+ // 18. renderPageSSR — CMS template in og:image
1091
+ // -----------------------------------------------------------------------
1092
+ describe('renderPageSSR og:image CMS template', () => {
1093
+ test('processes {{cms.*}} in meta.ogImage', async () => {
1094
+ const pageData: JSONPage = {
1095
+ root: { type: 'node', tag: 'div', children: 'Body' } as any,
1096
+ meta: { title: 'Page', ogImage: '{{cms.image}}' },
1097
+ } as JSONPage;
1098
+ const cmsContext = { cms: { image: '/img/hero.png' } };
1099
+ const result = await renderPageSSR(pageData, {}, '/', '', undefined, undefined, undefined, cmsContext);
1100
+ expect(result.meta).toContain('/img/hero.png');
1101
+ });
1102
+ });
1103
+ });