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
@@ -3,7 +3,7 @@
3
3
  * Tests the core ComponentNode -> Astro template markup converter
4
4
  */
5
5
 
6
- import { describe, test, expect, beforeEach } from 'bun:test';
6
+ import { describe, test, expect } from 'bun:test';
7
7
  import { nodeToAstro, type AstroEmitContext } from './nodeToAstro';
8
8
  import { DEFAULT_BREAKPOINTS } from '../../shared/breakpoints';
9
9
  import type { ComponentNode } from '../../shared/types';
@@ -29,7 +29,7 @@ function createContext(overrides?: Partial<AstroEmitContext>): AstroEmitContext
29
29
  }
30
30
 
31
31
  /** Strip leading/trailing whitespace from each line for easier comparison */
32
- function trimLines(s: string): string {
32
+ function _trimLines(s: string): string {
33
33
  return s
34
34
  .split('\n')
35
35
  .map((l) => l.trimEnd())
@@ -206,7 +206,7 @@ describe('nodeToAstro', () => {
206
206
  test('should handle indentation via context', () => {
207
207
  const ctx = createContext({ indent: 2 });
208
208
  const result = nodeToAstro('text', ctx);
209
- expect(result).toMatch(/^ text/); // 2 * 2 spaces = 4 spaces
209
+ expect(result).toMatch(/^ {4}text/); // 2 * 2 spaces = 4 spaces
210
210
  });
211
211
  });
212
212
 
@@ -954,12 +954,15 @@ describe('nodeToAstro', () => {
954
954
  test('should emit <Picture> for static image type node with metadata', () => {
955
955
  const ctx = createContext({
956
956
  imageMetadataMap: new Map([
957
- ['/images/photo.jpg', {
958
- width: 800,
959
- height: 600,
960
- srcset: '/images/photo-400.webp 400w',
961
- avifSrcset: '/images/photo-400.avif 400w',
962
- }],
957
+ [
958
+ '/images/photo.jpg',
959
+ {
960
+ width: 800,
961
+ height: 600,
962
+ srcset: '/images/photo-400.webp 400w',
963
+ avifSrcset: '/images/photo-400.avif 400w',
964
+ },
965
+ ],
963
966
  ]) as any,
964
967
  });
965
968
  const node = {
@@ -983,12 +986,15 @@ describe('nodeToAstro', () => {
983
986
 
984
987
  describe('static image <Picture> emission', () => {
985
988
  const staticMetadata = new Map([
986
- ['/images/hero.jpg', {
987
- width: 1920,
988
- height: 1080,
989
- srcset: '/images/hero-800.webp 800w',
990
- avifSrcset: '/images/hero-800.avif 800w',
991
- }],
989
+ [
990
+ '/images/hero.jpg',
991
+ {
992
+ width: 1920,
993
+ height: 1080,
994
+ srcset: '/images/hero-800.webp 800w',
995
+ avifSrcset: '/images/hero-800.avif 800w',
996
+ },
997
+ ],
992
998
  ]);
993
999
 
994
1000
  test('static <img> emits <Picture> and records import', () => {
@@ -1090,13 +1096,16 @@ describe('nodeToAstro', () => {
1090
1096
  test('blur hash puts blur on <picture> via pictureAttributes, no wrapper div', () => {
1091
1097
  const ctx = createContext({
1092
1098
  imageMetadataMap: new Map([
1093
- ['/images/hero.jpg', {
1094
- width: 1920,
1095
- height: 1080,
1096
- srcset: '/images/hero-800.webp 800w',
1097
- avifSrcset: '/images/hero-800.avif 800w',
1098
- blurHash: 'data:image/png;base64,blur',
1099
- }],
1099
+ [
1100
+ '/images/hero.jpg',
1101
+ {
1102
+ width: 1920,
1103
+ height: 1080,
1104
+ srcset: '/images/hero-800.webp 800w',
1105
+ avifSrcset: '/images/hero-800.avif 800w',
1106
+ blurHash: 'data:image/png;base64,blur',
1107
+ },
1108
+ ],
1100
1109
  ]) as any,
1101
1110
  });
1102
1111
  const node: ComponentNode = {
@@ -1140,9 +1149,7 @@ describe('nodeToAstro', () => {
1140
1149
  {
1141
1150
  type: 'node',
1142
1151
  tag: 'div',
1143
- children: [
1144
- { type: 'component', component: 'Button', children: [] },
1145
- ],
1152
+ children: [{ type: 'component', component: 'Button', children: [] }],
1146
1153
  } as any,
1147
1154
  ];
1148
1155
  nodeToAstro(nodes, ctx);
@@ -1164,11 +1171,11 @@ describe('nodeToAstro', () => {
1164
1171
  tag: '{{headingTag}}',
1165
1172
  children: ['Title'],
1166
1173
  } as any;
1167
- const result = nodeToAstro(node, ctx);
1174
+ const _result = nodeToAstro(node, ctx);
1168
1175
  expect(ctx.dynamicTags).toBeDefined();
1169
1176
  expect(ctx.dynamicTags!.size).toBe(1);
1170
1177
  // Should use Tag_<path> variable
1171
- const entry = [...ctx.dynamicTags!.entries()][0];
1178
+ const entry = [...ctx.dynamicTags!.entries()][0]!;
1172
1179
  expect(entry[0]).toMatch(/^Tag_/);
1173
1180
  });
1174
1181
  });
@@ -1265,9 +1272,7 @@ describe('nodeToAstro', () => {
1265
1272
  children: [],
1266
1273
  } as any;
1267
1274
  const result = nodeToAstro(node, ctx);
1268
- expect(result).toContain(
1269
- 'sizes="(max-width: 540px) 340px, (max-width: 1024px) 500px, 700px"'
1270
- );
1275
+ expect(result).toContain('sizes="(max-width: 540px) 340px, (max-width: 1024px) 500px, 700px"');
1271
1276
  });
1272
1277
 
1273
1278
  test('should fall back to 100vw when width is not a px value', () => {
@@ -1332,9 +1337,9 @@ describe('nodeToAstro', () => {
1332
1337
  expect(result).toContain('</picture>');
1333
1338
  expect(result).toContain('background-image:url(data:image/webp;base64,AAA)');
1334
1339
  // The <picture>'s style must carry the background, and the <img> must not.
1335
- const pictureLine = result.split('\n').find(l => l.includes('<picture'));
1340
+ const pictureLine = result.split('\n').find((l) => l.includes('<picture'));
1336
1341
  expect(pictureLine).toContain('background-image:url');
1337
- const imgLine = result.split('\n').find(l => l.includes('<img'));
1342
+ const imgLine = result.split('\n').find((l) => l.includes('<img'));
1338
1343
  expect(imgLine).not.toContain('background-image');
1339
1344
  // The onload handler should clear the placeholder from the parent.
1340
1345
  expect(result).toContain("this.parentElement.style.backgroundImage=''");
@@ -1363,10 +1368,10 @@ describe('nodeToAstro', () => {
1363
1368
  expect(result).toContain('<picture');
1364
1369
  expect(result).toContain('type="image/avif"');
1365
1370
  expect(result).toContain('type="image/webp"');
1366
- const pictureLine = result.split('\n').find(l => l.includes('<picture'));
1371
+ const pictureLine = result.split('\n').find((l) => l.includes('<picture'));
1367
1372
  expect(pictureLine).toContain('background-image:url(data:image/webp;base64,BBB)');
1368
1373
  // Inner img should not carry an inline background-image anymore.
1369
- const imgLine = result.split('\n').find(l => l.includes('<img'));
1374
+ const imgLine = result.split('\n').find((l) => l.includes('<img'));
1370
1375
  expect(imgLine).not.toContain('background-image');
1371
1376
  // Fill classes on inner img so it covers the picture's declared size.
1372
1377
  expect(imgLine).toContain('block');
@@ -1490,4 +1495,1719 @@ describe('nodeToAstro', () => {
1490
1495
  expect(result).toContain('id={item.id');
1491
1496
  });
1492
1497
  });
1498
+
1499
+ // =========================================================================
1500
+ // Style mappings -> class:list conditionals
1501
+ // =========================================================================
1502
+
1503
+ describe('style mappings to class:list', () => {
1504
+ const alignMapping = {
1505
+ _mapping: true,
1506
+ prop: 'align',
1507
+ values: { left: 'left', center: 'center' },
1508
+ };
1509
+
1510
+ test('two-value mapping emits ternary in class:list (component def)', () => {
1511
+ const ctx = createContext({ isComponentDef: true });
1512
+ const node: ComponentNode = {
1513
+ type: 'node',
1514
+ tag: 'div',
1515
+ style: { textAlign: alignMapping },
1516
+ children: ['x'],
1517
+ } as any;
1518
+ const result = nodeToAstro(node, ctx);
1519
+ expect(result).toContain(`class:list={[String(align) === "left" ? 'text-left' : 'text-center']}`);
1520
+ });
1521
+
1522
+ test('multi-value mapping emits one && entry per value', () => {
1523
+ const ctx = createContext({ isComponentDef: true });
1524
+ const node: ComponentNode = {
1525
+ type: 'node',
1526
+ tag: 'div',
1527
+ style: {
1528
+ textAlign: {
1529
+ _mapping: true,
1530
+ prop: 'align',
1531
+ values: { left: 'left', center: 'center', right: 'right' },
1532
+ },
1533
+ },
1534
+ children: ['x'],
1535
+ } as any;
1536
+ const result = nodeToAstro(node, ctx);
1537
+ expect(result).toContain(`String(align) === "left" && 'text-left'`);
1538
+ expect(result).toContain(`String(align) === "center" && 'text-center'`);
1539
+ expect(result).toContain(`String(align) === "right" && 'text-right'`);
1540
+ });
1541
+
1542
+ test('boolean mapping keys are coerced then re-stringified', () => {
1543
+ const ctx = createContext({ isComponentDef: true });
1544
+ const node: ComponentNode = {
1545
+ type: 'node',
1546
+ tag: 'div',
1547
+ style: {
1548
+ textAlign: { _mapping: true, prop: 'centered', values: { true: 'center', false: 'left' } },
1549
+ },
1550
+ children: ['x'],
1551
+ } as any;
1552
+ const result = nodeToAstro(node, ctx);
1553
+ expect(result).toContain(`String(centered) === "true" ? 'text-center' : 'text-left'`);
1554
+ });
1555
+
1556
+ test('responsive mapping under tablet gets max-[1024px]: prefix', () => {
1557
+ const ctx = createContext({ isComponentDef: true });
1558
+ const node: ComponentNode = {
1559
+ type: 'node',
1560
+ tag: 'div',
1561
+ style: { tablet: { textAlign: alignMapping } },
1562
+ children: ['x'],
1563
+ } as any;
1564
+ const result = nodeToAstro(node, ctx);
1565
+ expect(result).toContain(`'max-[1024px]:text-left'`);
1566
+ expect(result).toContain(`'max-[1024px]:text-center'`);
1567
+ });
1568
+
1569
+ test('responsive mapping under mobile gets max-[540px]: prefix', () => {
1570
+ const ctx = createContext({ isComponentDef: true });
1571
+ const node: ComponentNode = {
1572
+ type: 'node',
1573
+ tag: 'div',
1574
+ style: { mobile: { textAlign: alignMapping } },
1575
+ children: ['x'],
1576
+ } as any;
1577
+ const result = nodeToAstro(node, ctx);
1578
+ expect(result).toContain(`'max-[540px]:text-left'`);
1579
+ });
1580
+
1581
+ test('static classes and conditionals combine in class:list', () => {
1582
+ const ctx = createContext({ isComponentDef: true });
1583
+ const node: ComponentNode = {
1584
+ type: 'node',
1585
+ tag: 'div',
1586
+ style: { display: 'flex', textAlign: alignMapping },
1587
+ children: ['x'],
1588
+ } as any;
1589
+ const result = nodeToAstro(node, ctx);
1590
+ expect(result).toContain(`class:list={['flex', String(align) === "left"`);
1591
+ });
1592
+
1593
+ test('mapping values without a Tailwind class are dropped', () => {
1594
+ const ctx = createContext({ isComponentDef: true });
1595
+ const node: ComponentNode = {
1596
+ type: 'node',
1597
+ tag: 'div',
1598
+ style: {
1599
+ // gap '' produces no class; both must resolve or the ternary is skipped
1600
+ gap: { _mapping: true, prop: 'spacing', values: { a: '', b: '' } },
1601
+ },
1602
+ children: ['x'],
1603
+ } as any;
1604
+ const result = nodeToAstro(node, ctx);
1605
+ expect(result).not.toContain('class:list');
1606
+ expect(result).not.toContain('class=');
1607
+ });
1608
+
1609
+ // Mappings reference component props by bare identifier; at page scope those
1610
+ // identifiers don't exist (runtime resolveStyleMapping() receives no props
1611
+ // there and drops the property), so the emitter must not reference them —
1612
+ // doing so is an Astro build error, not a graceful degradation.
1613
+ test('page context drops style-mapping conditionals instead of referencing missing props', () => {
1614
+ const ctx = createContext({ isComponentDef: false });
1615
+ const node: ComponentNode = {
1616
+ type: 'node',
1617
+ tag: 'div',
1618
+ style: { textAlign: alignMapping },
1619
+ children: ['x'],
1620
+ } as any;
1621
+ const result = nodeToAstro(node, ctx);
1622
+ expect(result).not.toContain('class:list');
1623
+ expect(result).not.toContain('align');
1624
+ expect(result).toContain('<div>');
1625
+ });
1626
+
1627
+ test('page context keeps static classes while dropping mapping conditionals', () => {
1628
+ const ctx = createContext({ isComponentDef: false });
1629
+ const node: ComponentNode = {
1630
+ type: 'node',
1631
+ tag: 'div',
1632
+ style: { display: 'flex', textAlign: alignMapping },
1633
+ children: ['x'],
1634
+ } as any;
1635
+ const result = nodeToAstro(node, ctx);
1636
+ expect(result).toContain('class="flex"');
1637
+ expect(result).not.toContain('class:list');
1638
+ expect(result).not.toContain('align');
1639
+ });
1640
+ });
1641
+
1642
+ // =========================================================================
1643
+ // Dynamic (template-expression) styles -> inline style attribute
1644
+ // =========================================================================
1645
+
1646
+ describe('dynamic template styles', () => {
1647
+ test('template style value becomes template-literal style attr in component def', () => {
1648
+ const ctx = createContext({ isComponentDef: true });
1649
+ const node: ComponentNode = {
1650
+ type: 'node',
1651
+ tag: 'div',
1652
+ style: { width: '{{w}}' },
1653
+ children: ['x'],
1654
+ } as any;
1655
+ const result = nodeToAstro(node, ctx);
1656
+ expect(result).toContain('style={`width: ${w}`}');
1657
+ });
1658
+
1659
+ test('multiple dynamic styles join with semicolons', () => {
1660
+ const ctx = createContext({ isComponentDef: true });
1661
+ const node: ComponentNode = {
1662
+ type: 'node',
1663
+ tag: 'div',
1664
+ style: { width: '{{w}}', minHeight: '{{h}}' },
1665
+ children: ['x'],
1666
+ } as any;
1667
+ const result = nodeToAstro(node, ctx);
1668
+ expect(result).toContain('style={`width: ${w}; min-height: ${h}`}');
1669
+ });
1670
+
1671
+ test('dynamic styles rewrite item vars inside list context', () => {
1672
+ const ctx = createContext({
1673
+ isComponentDef: true,
1674
+ listItemBinding: 'card',
1675
+ listIndexVar: 'cardIndex',
1676
+ listSourceVar: 'cards',
1677
+ });
1678
+ const node: ComponentNode = {
1679
+ type: 'node',
1680
+ tag: 'div',
1681
+ style: { width: '{{item.width}}' },
1682
+ children: ['x'],
1683
+ } as any;
1684
+ const result = nodeToAstro(node, ctx);
1685
+ expect(result).toContain('style={`width: ${card.width}`}');
1686
+ });
1687
+
1688
+ test('dynamic styles are NOT emitted in page context', () => {
1689
+ const ctx = createContext({ isComponentDef: false });
1690
+ const node: ComponentNode = {
1691
+ type: 'node',
1692
+ tag: 'div',
1693
+ style: { width: '{{w}}' },
1694
+ children: ['x'],
1695
+ } as any;
1696
+ const result = nodeToAstro(node, ctx);
1697
+ expect(result).not.toContain('style=');
1698
+ });
1699
+ });
1700
+
1701
+ // =========================================================================
1702
+ // Interactive style mappings -> --is-N CSS variables
1703
+ // =========================================================================
1704
+
1705
+ describe('interactive style mappings', () => {
1706
+ test('two-value mapping emits ternary CSS variable', () => {
1707
+ const ctx = createContext({ isComponentDef: true });
1708
+ const node: ComponentNode = {
1709
+ type: 'node',
1710
+ tag: 'div',
1711
+ label: 'Btn',
1712
+ interactiveStyles: [
1713
+ {
1714
+ postfix: ':hover',
1715
+ style: {
1716
+ backgroundColor: { _mapping: true, prop: 'variant', values: { primary: '#00f', secondary: '#f00' } },
1717
+ },
1718
+ },
1719
+ ],
1720
+ children: ['x'],
1721
+ } as any;
1722
+ const result = nodeToAstro(node, ctx);
1723
+ expect(result).toContain(`'--is-0': variant === "primary" ? '#00f' : '#f00'`);
1724
+ expect(result).toContain('style={Object.entries({');
1725
+ // Element class is generated for interactive styles and prepended to classes
1726
+ expect(result).toContain('class="p_test-page_btn"');
1727
+ });
1728
+
1729
+ test('multi-value mapping emits inline lookup object, filtering empty values', () => {
1730
+ const ctx = createContext({ isComponentDef: true });
1731
+ const node: ComponentNode = {
1732
+ type: 'node',
1733
+ tag: 'div',
1734
+ interactiveStyles: [
1735
+ {
1736
+ postfix: ':hover',
1737
+ style: {
1738
+ borderWidth: { _mapping: true, prop: 'size', values: { a: '1px', b: '2px', c: '' } },
1739
+ },
1740
+ },
1741
+ ],
1742
+ children: ['x'],
1743
+ } as any;
1744
+ const result = nodeToAstro(node, ctx);
1745
+ expect(result).toContain(`'--is-0': ({"a": '1px', "b": '2px'})[size] || ''`);
1746
+ });
1747
+
1748
+ test('interactive mapping merges with existing dynamic style attr', () => {
1749
+ const ctx = createContext({ isComponentDef: true });
1750
+ const node: ComponentNode = {
1751
+ type: 'node',
1752
+ tag: 'div',
1753
+ style: { width: '{{w}}' },
1754
+ interactiveStyles: [
1755
+ {
1756
+ postfix: ':hover',
1757
+ style: {
1758
+ backgroundColor: { _mapping: true, prop: 'variant', values: { primary: '#00f', secondary: '#f00' } },
1759
+ },
1760
+ },
1761
+ ],
1762
+ children: ['x'],
1763
+ } as any;
1764
+ const result = nodeToAstro(node, ctx);
1765
+ expect(result).toContain('style={`width: ${w}; ${ Object.entries({');
1766
+ });
1767
+
1768
+ test('interactive styles register element class in collectedInteractiveStyles', () => {
1769
+ const collected = new Map();
1770
+ const ctx = createContext({
1771
+ collectedInteractiveStyles: collected as any,
1772
+ });
1773
+ const node: ComponentNode = {
1774
+ type: 'node',
1775
+ tag: 'div',
1776
+ label: 'Hero',
1777
+ interactiveStyles: [{ postfix: ':hover', style: { opacity: '0.5' } }],
1778
+ children: ['x'],
1779
+ } as any;
1780
+ const result = nodeToAstro(node, ctx);
1781
+ expect(result).toContain('class="p_test-page_hero"');
1782
+ expect(collected.get('p_test-page_hero')).toEqual([{ postfix: ':hover', style: { opacity: '0.5' } }]);
1783
+ });
1784
+
1785
+ test('generateElementClass flag alone produces hashed element class', () => {
1786
+ const ctx = createContext();
1787
+ const node: ComponentNode = {
1788
+ type: 'node',
1789
+ tag: 'div',
1790
+ generateElementClass: true,
1791
+ children: ['x'],
1792
+ } as any;
1793
+ const result = nodeToAstro(node, ctx);
1794
+ expect(result).toMatch(/class="p_test-page_[a-z0-9]+"/);
1795
+ });
1796
+ });
1797
+
1798
+ // =========================================================================
1799
+ // Attribute template branches (buildAttributesString)
1800
+ // =========================================================================
1801
+
1802
+ describe('attribute template branches', () => {
1803
+ test('link-type prop in full-match attribute uses href coercion expr', () => {
1804
+ const ctx = createContext({
1805
+ isComponentDef: true,
1806
+ componentProps: { cta: { type: 'link', label: 'CTA' } as any },
1807
+ });
1808
+ const node: ComponentNode = {
1809
+ type: 'node',
1810
+ tag: 'div',
1811
+ attributes: { 'data-href': '{{cta}}' },
1812
+ children: [],
1813
+ } as any;
1814
+ const result = nodeToAstro(node, ctx);
1815
+ expect(result).toContain(`data-href={(typeof cta === 'string' ? cta : cta?.href) ?? "#"}`);
1816
+ });
1817
+
1818
+ test('mixed-content attribute becomes template literal in component def', () => {
1819
+ const ctx = createContext({ isComponentDef: true });
1820
+ const node: ComponentNode = {
1821
+ type: 'node',
1822
+ tag: 'button',
1823
+ attributes: { 'aria-label': 'Open {{name}} menu' },
1824
+ children: ['x'],
1825
+ } as any;
1826
+ const result = nodeToAstro(node, ctx);
1827
+ expect(result).toContain('aria-label={`Open ${name} menu`}');
1828
+ });
1829
+
1830
+ test('mixed-content attribute with link-type prop coerces href inside literal', () => {
1831
+ const ctx = createContext({
1832
+ isComponentDef: true,
1833
+ componentProps: { cta: { type: 'link', label: 'CTA' } as any },
1834
+ });
1835
+ const node: ComponentNode = {
1836
+ type: 'node',
1837
+ tag: 'div',
1838
+ attributes: { 'data-url': 'go-{{cta}}' },
1839
+ children: [],
1840
+ } as any;
1841
+ const result = nodeToAstro(node, ctx);
1842
+ expect(result).toContain('data-url={`go-${(typeof cta === \'string\' ? cta : cta?.href) ?? "#"}`}');
1843
+ });
1844
+
1845
+ test('list-item full-match attribute outside component def', () => {
1846
+ const ctx = createContext({
1847
+ isComponentDef: false,
1848
+ listItemBinding: 'item',
1849
+ });
1850
+ const node: ComponentNode = {
1851
+ type: 'node',
1852
+ tag: 'div',
1853
+ attributes: { id: '{{item.id}}' },
1854
+ children: [],
1855
+ } as any;
1856
+ const result = nodeToAstro(node, ctx);
1857
+ expect(result).toContain('id={item.id || undefined}');
1858
+ });
1859
+
1860
+ test('list-item meta var in attribute resolves index expression', () => {
1861
+ const ctx = createContext({
1862
+ isComponentDef: false,
1863
+ listItemBinding: 'item',
1864
+ listIndexVar: 'i',
1865
+ listSourceVar: 'items',
1866
+ });
1867
+ const node: ComponentNode = {
1868
+ type: 'node',
1869
+ tag: 'div',
1870
+ attributes: { 'data-last': '{{itemLast}}' },
1871
+ children: [],
1872
+ } as any;
1873
+ const result = nodeToAstro(node, ctx);
1874
+ expect(result).toContain('data-last={(i === items.length - 1) || undefined}');
1875
+ });
1876
+
1877
+ test('list-item mixed-content attribute becomes template literal', () => {
1878
+ const ctx = createContext({
1879
+ isComponentDef: false,
1880
+ listItemBinding: 'item',
1881
+ listIndexVar: 'i',
1882
+ listSourceVar: 'items',
1883
+ });
1884
+ const node: ComponentNode = {
1885
+ type: 'node',
1886
+ tag: 'div',
1887
+ attributes: { id: 'row-{{item.id}}' },
1888
+ children: [],
1889
+ } as any;
1890
+ const result = nodeToAstro(node, ctx);
1891
+ expect(result).toContain('id={`row-${item.id}`}');
1892
+ });
1893
+
1894
+ test('CMS mixed-content attribute becomes template literal', () => {
1895
+ const ctx = createContext({ cmsMode: true, cmsEntryBinding: 'entry' });
1896
+ const node: ComponentNode = {
1897
+ type: 'node',
1898
+ tag: 'div',
1899
+ attributes: { alt: 'Photo of {{cms.name}}' },
1900
+ children: [],
1901
+ } as any;
1902
+ const result = nodeToAstro(node, ctx);
1903
+ expect(result).toContain('alt={`Photo of ${entry.data.name}`}');
1904
+ });
1905
+ });
1906
+
1907
+ // =========================================================================
1908
+ // emitAttrValue via image emitter (CMS / list-item src)
1909
+ // =========================================================================
1910
+
1911
+ describe('image attribute value transformation', () => {
1912
+ test('CMS mixed-content img src becomes template literal', () => {
1913
+ const ctx = createContext({
1914
+ imageMetadataMap: new Map() as any,
1915
+ cmsMode: true,
1916
+ cmsEntryBinding: 'entry',
1917
+ });
1918
+ const node: ComponentNode = {
1919
+ type: 'node',
1920
+ tag: 'img',
1921
+ attributes: { src: 'x-{{cms.img}}', alt: 'A' },
1922
+ children: [],
1923
+ } as any;
1924
+ const result = nodeToAstro(node, ctx);
1925
+ expect(result).toContain('src={`x-${entry.data.img}`}');
1926
+ });
1927
+
1928
+ test('list-item mixed-content img src becomes template literal', () => {
1929
+ const ctx = createContext({
1930
+ imageMetadataMap: new Map() as any,
1931
+ listItemBinding: 'post',
1932
+ listIndexVar: 'postIndex',
1933
+ listSourceVar: 'posts',
1934
+ });
1935
+ const node: ComponentNode = {
1936
+ type: 'node',
1937
+ tag: 'img',
1938
+ attributes: { src: '/i/{{post.slug}}.jpg', alt: 'A' },
1939
+ children: [],
1940
+ } as any;
1941
+ const result = nodeToAstro(node, ctx);
1942
+ expect(result).toContain('src={`/i/${post.slug}.jpg`}');
1943
+ });
1944
+
1945
+ test('list-item full-match img src with meta var resolves index', () => {
1946
+ const ctx = createContext({
1947
+ imageMetadataMap: new Map() as any,
1948
+ listItemBinding: 'item',
1949
+ listIndexVar: 'i',
1950
+ listSourceVar: 'items',
1951
+ });
1952
+ const node: ComponentNode = {
1953
+ type: 'node',
1954
+ tag: 'img',
1955
+ attributes: { src: '{{itemIndex}}', alt: 'A' },
1956
+ children: [],
1957
+ } as any;
1958
+ const result = nodeToAstro(node, ctx);
1959
+ expect(result).toContain('src={i}');
1960
+ });
1961
+ });
1962
+
1963
+ // =========================================================================
1964
+ // i18n value emission
1965
+ // =========================================================================
1966
+
1967
+ describe('i18n values', () => {
1968
+ test('component def without locale wraps i18n object with r() resolver', () => {
1969
+ const ctx = createContext({ isComponentDef: true });
1970
+ const node = { _i18n: true, en: 'Hello', fr: 'Bonjour' } as any;
1971
+ const result = nodeToAstro(node, ctx);
1972
+ expect(result).toContain('{r({"_i18n":true,"en":"Hello","fr":"Bonjour"})}');
1973
+ expect(ctx.needsI18nResolver).toBe(true);
1974
+ });
1975
+
1976
+ test('i18n value resolving to a non-string is stringified', () => {
1977
+ const ctx = createContext({ locale: 'en' });
1978
+ const node = { _i18n: true, en: 42 } as any;
1979
+ const result = nodeToAstro(node, ctx);
1980
+ expect(result.trim()).toBe('42');
1981
+ });
1982
+
1983
+ // An i18n object on a page emitted without a locale resolves through the
1984
+ // default locale (resolveTranslation falls back exact → default → first
1985
+ // available → empty) instead of stringifying to "[object Object]".
1986
+ test('page context without locale resolves i18n via default locale', () => {
1987
+ const ctx = createContext({ isComponentDef: false });
1988
+ const node = { _i18n: true, en: 'Hello' } as any;
1989
+ const result = nodeToAstro(node, ctx);
1990
+ expect(result.trim()).toBe('Hello');
1991
+ });
1992
+
1993
+ test('page context without locale honors i18nConfig default locale', () => {
1994
+ const ctx = createContext({
1995
+ isComponentDef: false,
1996
+ i18nConfig: {
1997
+ defaultLocale: 'fr',
1998
+ locales: [
1999
+ { code: 'en', name: 'English', nativeName: 'English', langTag: 'en-US' },
2000
+ { code: 'fr', name: 'French', nativeName: 'Français', langTag: 'fr-FR' },
2001
+ ],
2002
+ },
2003
+ });
2004
+ const node = { _i18n: true, en: 'Hello', fr: 'Bonjour' } as any;
2005
+ const result = nodeToAstro(node, ctx);
2006
+ expect(result.trim()).toBe('Bonjour');
2007
+ });
2008
+ });
2009
+
2010
+ // =========================================================================
2011
+ // Prop value formatting edge cases
2012
+ // =========================================================================
2013
+
2014
+ describe('prop value formatting', () => {
2015
+ test('null and undefined props emit {undefined}', () => {
2016
+ const ctx = createContext();
2017
+ const node: ComponentNode = {
2018
+ type: 'component',
2019
+ component: 'Widget',
2020
+ props: { a: null, b: undefined },
2021
+ children: [],
2022
+ } as any;
2023
+ const result = nodeToAstro(node, ctx);
2024
+ expect(result).toContain('a={undefined}');
2025
+ expect(result).toContain('b={undefined}');
2026
+ });
2027
+
2028
+ test('object and array props emit JSON literals', () => {
2029
+ const ctx = createContext();
2030
+ const node: ComponentNode = {
2031
+ type: 'component',
2032
+ component: 'Widget',
2033
+ props: { cfg: { a: 1 }, list: [1, 2] },
2034
+ children: [],
2035
+ } as any;
2036
+ const result = nodeToAstro(node, ctx);
2037
+ expect(result).toContain('cfg={{"a":1}}');
2038
+ expect(result).toContain('list={[1,2]}');
2039
+ });
2040
+ });
2041
+
2042
+ // =========================================================================
2043
+ // Slug-mapped link localization
2044
+ // =========================================================================
2045
+
2046
+ describe('slug-mapped link localization', () => {
2047
+ test('translates internal href through slug mappings', () => {
2048
+ const ctx = createContext({
2049
+ locale: 'fr',
2050
+ i18nDefaultLocale: 'en',
2051
+ slugMappings: [{ pageId: 'about', slugs: { en: 'about', fr: 'a-propos' } }],
2052
+ });
2053
+ const node: ComponentNode = {
2054
+ type: 'link',
2055
+ href: '/about',
2056
+ children: ['About'],
2057
+ } as any;
2058
+ const result = nodeToAstro(node, ctx);
2059
+ expect(result).toContain('href="/fr/a-propos"');
2060
+ });
2061
+ });
2062
+
2063
+ // =========================================================================
2064
+ // Embed node advanced branches
2065
+ // =========================================================================
2066
+
2067
+ describe('embed node advanced', () => {
2068
+ test('HTML mapping emits Fragment set:html with prop ref in component def', () => {
2069
+ const ctx = createContext({ isComponentDef: true });
2070
+ const node: ComponentNode = {
2071
+ type: 'embed',
2072
+ html: { _mapping: true, prop: 'content' },
2073
+ children: [],
2074
+ } as any;
2075
+ const result = nodeToAstro(node, ctx);
2076
+ expect(result).toContain('class="oem"');
2077
+ expect(result).toContain('Fragment set:html={content}');
2078
+ });
2079
+
2080
+ // NOTE: pinned degradation — an HTML mapping on a page (outside component
2081
+ // def) falls through to the literal path with html='' and emits an empty
2082
+ // embed. This matches SSR runtime, where resolveHtmlMapping() receives no
2083
+ // props at page scope and resolves to '' (ssrRenderer embed branch).
2084
+ test('HTML mapping in page context emits empty embed (pinned)', () => {
2085
+ const ctx = createContext({ isComponentDef: false });
2086
+ const node: ComponentNode = {
2087
+ type: 'embed',
2088
+ html: { _mapping: true, prop: 'content' },
2089
+ children: [],
2090
+ } as any;
2091
+ const result = nodeToAstro(node, ctx);
2092
+ expect(result).toContain('Fragment set:html={``}');
2093
+ expect(result).not.toContain('content');
2094
+ });
2095
+
2096
+ test('template html with static classes prefixes oem to class', () => {
2097
+ const ctx = createContext({ isComponentDef: true });
2098
+ const node: ComponentNode = {
2099
+ type: 'embed',
2100
+ html: '{{icon}}',
2101
+ style: { display: 'flex' },
2102
+ children: [],
2103
+ } as any;
2104
+ const result = nodeToAstro(node, ctx);
2105
+ expect(result).toContain('class="oem flex"');
2106
+ expect(result).toContain('Fragment set:html={icon}');
2107
+ });
2108
+
2109
+ test('literal embed with static classes prefixes oem to class', () => {
2110
+ const ctx = createContext();
2111
+ const node: ComponentNode = {
2112
+ type: 'embed',
2113
+ html: '<b>x</b>',
2114
+ style: { display: 'flex' },
2115
+ children: [],
2116
+ } as any;
2117
+ const result = nodeToAstro(node, ctx);
2118
+ expect(result).toContain('class="oem flex"');
2119
+ });
2120
+
2121
+ test('mixed-template embed html falls through to literal emission (pinned)', () => {
2122
+ // Only a full-match {{expr}} is resolved; mixed content keeps the raw
2123
+ // {{...}} inside the emitted template literal.
2124
+ const ctx = createContext({ isComponentDef: true });
2125
+ const node: ComponentNode = {
2126
+ type: 'embed',
2127
+ html: '<i>{{icon}}</i>',
2128
+ children: [],
2129
+ } as any;
2130
+ const result = nodeToAstro(node, ctx);
2131
+ expect(result).toContain('Fragment set:html={`<i>{{icon}}</i>`}');
2132
+ });
2133
+
2134
+ test('embed with interactive styles registers element class', () => {
2135
+ const collected = new Map();
2136
+ const ctx = createContext({ collectedInteractiveStyles: collected as any });
2137
+ const node: ComponentNode = {
2138
+ type: 'embed',
2139
+ html: '<b>x</b>',
2140
+ label: 'Embed1',
2141
+ interactiveStyles: [{ postfix: ':hover', style: { opacity: '0.5' } }],
2142
+ children: [],
2143
+ } as any;
2144
+ const result = nodeToAstro(node, ctx);
2145
+ expect(result).toContain('p_test-page_embed1');
2146
+ expect(collected.has('p_test-page_embed1')).toBe(true);
2147
+ });
2148
+ });
2149
+
2150
+ // =========================================================================
2151
+ // Link node advanced branches
2152
+ // =========================================================================
2153
+
2154
+ describe('link node advanced', () => {
2155
+ test('link mapping in component def emits href coercion over prop', () => {
2156
+ const ctx = createContext({ isComponentDef: true });
2157
+ const node: ComponentNode = {
2158
+ type: 'link',
2159
+ href: { _mapping: true, prop: 'link' },
2160
+ children: ['Go'],
2161
+ } as any;
2162
+ const result = nodeToAstro(node, ctx);
2163
+ expect(result).toContain(`href={(typeof link === 'string' ? link : link?.href) ?? "#"}`);
2164
+ });
2165
+
2166
+ // NOTE: pinned known broken-link mode — a link mapping on a page emits a
2167
+ // static href="#" because the prop does not exist at page scope. This
2168
+ // matches SSR runtime (non-string href falls back to '#'); the real fix
2169
+ // is upstream in the link binding, not in this emitter.
2170
+ test('link mapping in page context emits static href="#" (pinned)', () => {
2171
+ const ctx = createContext({ isComponentDef: false });
2172
+ const node: ComponentNode = {
2173
+ type: 'link',
2174
+ href: { _mapping: true, prop: 'link' },
2175
+ children: ['Go'],
2176
+ } as any;
2177
+ const result = nodeToAstro(node, ctx);
2178
+ expect(result).toContain('href="#"');
2179
+ });
2180
+
2181
+ test('mixed-template href becomes template literal in component def', () => {
2182
+ const ctx = createContext({ isComponentDef: true });
2183
+ const node: ComponentNode = {
2184
+ type: 'link',
2185
+ href: '/blog/{{slug}}',
2186
+ children: ['Read'],
2187
+ } as any;
2188
+ const result = nodeToAstro(node, ctx);
2189
+ expect(result).toContain('href={`/blog/${slug}`}');
2190
+ });
2191
+
2192
+ test('mixed-template href with link-type prop coerces inside literal', () => {
2193
+ const ctx = createContext({
2194
+ isComponentDef: true,
2195
+ componentProps: { cta: { type: 'link', label: 'CTA' } as any },
2196
+ });
2197
+ const node: ComponentNode = {
2198
+ type: 'link',
2199
+ href: '{{cta}}?utm=1',
2200
+ children: ['Go'],
2201
+ } as any;
2202
+ const result = nodeToAstro(node, ctx);
2203
+ expect(result).toContain('href={`${(typeof cta === \'string\' ? cta : cta?.href) ?? "#"}?utm=1`}');
2204
+ });
2205
+
2206
+ test('CMS mixed-template href becomes template literal', () => {
2207
+ const ctx = createContext({ cmsMode: true, cmsEntryBinding: 'entry' });
2208
+ const node: ComponentNode = {
2209
+ type: 'link',
2210
+ href: '/posts/{{cms.slug}}',
2211
+ children: ['Read'],
2212
+ } as any;
2213
+ const result = nodeToAstro(node, ctx);
2214
+ expect(result).toContain('href={`/posts/${entry.data.slug}`}');
2215
+ });
2216
+
2217
+ test('static classes get olink prefix', () => {
2218
+ const ctx = createContext();
2219
+ const node: ComponentNode = {
2220
+ type: 'link',
2221
+ href: '/x',
2222
+ style: { display: 'flex' },
2223
+ children: ['x'],
2224
+ } as any;
2225
+ const result = nodeToAstro(node, ctx);
2226
+ expect(result).toContain('class="olink flex"');
2227
+ });
2228
+
2229
+ test('link with interactive styles registers element class', () => {
2230
+ const collected = new Map();
2231
+ const ctx = createContext({ collectedInteractiveStyles: collected as any });
2232
+ const node: ComponentNode = {
2233
+ type: 'link',
2234
+ href: '/x',
2235
+ label: 'NavLink',
2236
+ interactiveStyles: [{ postfix: ':hover', style: { opacity: '0.5' } }],
2237
+ children: ['x'],
2238
+ } as any;
2239
+ const result = nodeToAstro(node, ctx);
2240
+ expect(result).toContain('p_test-page_navlink');
2241
+ expect(collected.has('p_test-page_navlink')).toBe(true);
2242
+ });
2243
+ });
2244
+
2245
+ // =========================================================================
2246
+ // Component instance advanced (props)
2247
+ // =========================================================================
2248
+
2249
+ describe('component instance advanced props', () => {
2250
+ test('mixed-template string prop becomes template literal', () => {
2251
+ const ctx = createContext({ isComponentDef: true });
2252
+ const node: ComponentNode = {
2253
+ type: 'component',
2254
+ component: 'Card',
2255
+ props: { title: 'Hello {{name}}!' },
2256
+ children: [],
2257
+ } as any;
2258
+ const result = nodeToAstro(node, ctx);
2259
+ expect(result).toContain('title={`Hello ${name}!`}');
2260
+ });
2261
+
2262
+ test('full-match prop rewrites item vars and meta vars in list context', () => {
2263
+ const ctx = createContext({
2264
+ isComponentDef: true,
2265
+ listItemBinding: 'card',
2266
+ listIndexVar: 'cardIndex',
2267
+ listSourceVar: 'cards',
2268
+ });
2269
+ const node: ComponentNode = {
2270
+ type: 'component',
2271
+ component: 'Badge',
2272
+ props: { text: '{{item.label}}', index: '{{itemIndex}}' },
2273
+ children: [],
2274
+ } as any;
2275
+ const result = nodeToAstro(node, ctx);
2276
+ expect(result).toContain('text={card.label}');
2277
+ expect(result).toContain('index={cardIndex}');
2278
+ });
2279
+
2280
+ test('CMS full-match and mixed props transform to entry data access', () => {
2281
+ const ctx = createContext({ cmsMode: true, cmsEntryBinding: 'entry' });
2282
+ const node: ComponentNode = {
2283
+ type: 'component',
2284
+ component: 'Card',
2285
+ props: { title: '{{cms.title}}', sub: 'By {{cms.author}}' },
2286
+ children: [],
2287
+ } as any;
2288
+ const result = nodeToAstro(node, ctx);
2289
+ expect(result).toContain('title={entry.data.title}');
2290
+ expect(result).toContain('sub={`By ${entry.data.author}`}');
2291
+ });
2292
+
2293
+ test('CMS props honor cmsWrapFn', () => {
2294
+ const ctx = createContext({ cmsMode: true, cmsEntryBinding: 'entry', cmsWrapFn: 'r' });
2295
+ const node: ComponentNode = {
2296
+ type: 'component',
2297
+ component: 'Card',
2298
+ props: { title: '{{cms.title}}' },
2299
+ children: [],
2300
+ } as any;
2301
+ const result = nodeToAstro(node, ctx);
2302
+ expect(result).toContain('title={r(entry.data.title)}');
2303
+ });
2304
+
2305
+ test('rich-text prop is passed as escaped template literal', () => {
2306
+ const ctx = createContext({
2307
+ globalComponents: {
2308
+ Card: {
2309
+ component: {
2310
+ interface: { content: { type: 'rich-text', label: 'Content' } },
2311
+ },
2312
+ },
2313
+ } as any,
2314
+ });
2315
+ const node: ComponentNode = {
2316
+ type: 'component',
2317
+ component: 'Card',
2318
+ props: { content: '<p>Hi `tick`</p>' },
2319
+ children: [],
2320
+ } as any;
2321
+ const result = nodeToAstro(node, ctx);
2322
+ expect(result).toContain('content={`<p>Hi \\`tick\\`</p>`}');
2323
+ });
2324
+
2325
+ test('CMS consumer component gets cms entry threaded as prop', () => {
2326
+ const ctx = createContext({
2327
+ cmsMode: true,
2328
+ cmsEntryBinding: 'entry',
2329
+ cmsConsumers: new Set(['Card']),
2330
+ });
2331
+ const node: ComponentNode = {
2332
+ type: 'component',
2333
+ component: 'Card',
2334
+ children: [],
2335
+ } as any;
2336
+ const result = nodeToAstro(node, ctx);
2337
+ expect(result).toContain('cms={entry}');
2338
+ });
2339
+
2340
+ test('CMS consumer with explicit cms prop is not overridden', () => {
2341
+ const ctx = createContext({
2342
+ cmsMode: true,
2343
+ cmsEntryBinding: 'entry',
2344
+ cmsConsumers: new Set(['Card']),
2345
+ });
2346
+ const node: ComponentNode = {
2347
+ type: 'component',
2348
+ component: 'Card',
2349
+ props: { cms: 'custom' },
2350
+ children: [],
2351
+ } as any;
2352
+ const result = nodeToAstro(node, ctx);
2353
+ expect(result).toContain('cms="custom"');
2354
+ expect(result).not.toContain('cms={entry}');
2355
+ });
2356
+ });
2357
+
2358
+ // =========================================================================
2359
+ // Component instance style overrides
2360
+ // =========================================================================
2361
+
2362
+ describe('component instance style overrides', () => {
2363
+ test('instance style emits element class and collects unlayered rule', () => {
2364
+ const collected = new Map();
2365
+ const ctx = createContext({ collectedInteractiveStyles: collected as any });
2366
+ const node: ComponentNode = {
2367
+ type: 'component',
2368
+ component: 'Card',
2369
+ label: 'MyCard',
2370
+ style: { base: { padding: '10px' } },
2371
+ children: [],
2372
+ } as any;
2373
+ const result = nodeToAstro(node, ctx);
2374
+ expect(result).toContain('class="p_test-page_mycard"');
2375
+ const rules = collected.get('p_test-page_mycard');
2376
+ expect(rules).toHaveLength(1);
2377
+ expect(rules[0].style).toEqual({ base: { padding: '10px' } });
2378
+ });
2379
+
2380
+ test('instance style merges under component root style per breakpoint', () => {
2381
+ const collected = new Map();
2382
+ const ctx = createContext({
2383
+ collectedInteractiveStyles: collected as any,
2384
+ globalComponents: {
2385
+ Card: {
2386
+ component: {
2387
+ structure: { type: 'node', tag: 'div', style: { base: { color: 'red', padding: '4px' } } },
2388
+ },
2389
+ },
2390
+ } as any,
2391
+ });
2392
+ const node: ComponentNode = {
2393
+ type: 'component',
2394
+ component: 'Card',
2395
+ label: 'MyCard',
2396
+ style: { base: { padding: '10px' } },
2397
+ children: [],
2398
+ } as any;
2399
+ nodeToAstro(node, ctx);
2400
+ const rules = collected.get('p_test-page_mycard');
2401
+ expect(rules[0].style.base).toEqual({ color: 'red', padding: '10px' });
2402
+ });
2403
+
2404
+ test('component root style is read from props.style for instance roots', () => {
2405
+ const collected = new Map();
2406
+ const ctx = createContext({
2407
+ collectedInteractiveStyles: collected as any,
2408
+ globalComponents: {
2409
+ Card: {
2410
+ component: {
2411
+ structure: {
2412
+ type: 'component',
2413
+ component: 'Inner',
2414
+ props: { style: { base: { color: 'blue' } } },
2415
+ },
2416
+ },
2417
+ },
2418
+ } as any,
2419
+ });
2420
+ const node: ComponentNode = {
2421
+ type: 'component',
2422
+ component: 'Card',
2423
+ label: 'MyCard',
2424
+ style: { base: { padding: '10px' } },
2425
+ children: [],
2426
+ } as any;
2427
+ nodeToAstro(node, ctx);
2428
+ const rules = collected.get('p_test-page_mycard');
2429
+ expect(rules[0].style.base).toEqual({ color: 'blue', padding: '10px' });
2430
+ });
2431
+
2432
+ test('template-expression values are stripped from override CSS', () => {
2433
+ const collected = new Map();
2434
+ const ctx = createContext({ collectedInteractiveStyles: collected as any });
2435
+ const node: ComponentNode = {
2436
+ type: 'component',
2437
+ component: 'Card',
2438
+ label: 'MyCard',
2439
+ style: { base: { padding: '{{pad}}', margin: '5px' } },
2440
+ children: [],
2441
+ } as any;
2442
+ nodeToAstro(node, ctx);
2443
+ const rules = collected.get('p_test-page_mycard');
2444
+ expect(rules[0].style.base).toEqual({ margin: '5px' });
2445
+ });
2446
+
2447
+ test('all-template override emits no class and no rule', () => {
2448
+ const collected = new Map();
2449
+ const ctx = createContext({ collectedInteractiveStyles: collected as any });
2450
+ const node: ComponentNode = {
2451
+ type: 'component',
2452
+ component: 'Card',
2453
+ style: { base: { padding: '{{pad}}' } },
2454
+ children: [],
2455
+ } as any;
2456
+ const result = nodeToAstro(node, ctx);
2457
+ expect(result).not.toContain('class=');
2458
+ expect(collected.size).toBe(0);
2459
+ });
2460
+
2461
+ test('empty responsive override shape is ignored', () => {
2462
+ const collected = new Map();
2463
+ const ctx = createContext({ collectedInteractiveStyles: collected as any });
2464
+ const node: ComponentNode = {
2465
+ type: 'component',
2466
+ component: 'Card',
2467
+ style: { base: {}, tablet: {}, mobile: {} },
2468
+ children: [],
2469
+ } as any;
2470
+ const result = nodeToAstro(node, ctx);
2471
+ expect(result).not.toContain('class=');
2472
+ expect(collected.size).toBe(0);
2473
+ });
2474
+
2475
+ test('instance interactive styles fold into the same element class', () => {
2476
+ const collected = new Map();
2477
+ const ctx = createContext({ collectedInteractiveStyles: collected as any });
2478
+ const node: ComponentNode = {
2479
+ type: 'component',
2480
+ component: 'Card',
2481
+ label: 'HoverCard',
2482
+ interactiveStyles: [{ postfix: ':hover', style: { opacity: '0.7' } }],
2483
+ children: [],
2484
+ } as any;
2485
+ const result = nodeToAstro(node, ctx);
2486
+ expect(result).toContain('class="p_test-page_hovercard"');
2487
+ const rules = collected.get('p_test-page_hovercard');
2488
+ expect(rules).toHaveLength(1);
2489
+ expect(rules[0].postfix).toBe(':hover');
2490
+ });
2491
+
2492
+ test('flat instance style (legacy) is collected as-is', () => {
2493
+ const collected = new Map();
2494
+ const ctx = createContext({ collectedInteractiveStyles: collected as any });
2495
+ const node: ComponentNode = {
2496
+ type: 'component',
2497
+ component: 'Card',
2498
+ label: 'FlatCard',
2499
+ style: { padding: '8px' },
2500
+ children: [],
2501
+ } as any;
2502
+ nodeToAstro(node, ctx);
2503
+ const rules = collected.get('p_test-page_flatcard');
2504
+ expect(rules[0].style).toEqual({ padding: '8px' });
2505
+ });
2506
+ });
2507
+
2508
+ // =========================================================================
2509
+ // Image type node with metadata (non-static src)
2510
+ // =========================================================================
2511
+
2512
+ describe('image type node with metadata', () => {
2513
+ test('metadata without avif/blur emits plain img with srcset and dimensions', () => {
2514
+ const ctx = createContext({
2515
+ imageMetadataMap: new Map([
2516
+ ['/photos/x.jpg', { width: 800, height: 600, srcset: '/photos/x-400.webp 400w' }],
2517
+ ]) as any,
2518
+ });
2519
+ const node = { type: 'image', src: '/photos/x.jpg', alt: 'X' } as any;
2520
+ const result = nodeToAstro(node, ctx);
2521
+ expect(result).toContain('<img');
2522
+ expect(result).not.toContain('<picture');
2523
+ expect(result).toContain('width="800"');
2524
+ expect(result).toContain('height="600"');
2525
+ expect(result).toContain('srcset="/photos/x-400.webp 400w"');
2526
+ expect(result).toContain('sizes="100vw"');
2527
+ });
2528
+
2529
+ test('metadata with avif emits picture with both sources', () => {
2530
+ const ctx = createContext({
2531
+ imageMetadataMap: new Map([
2532
+ [
2533
+ '/photos/x.jpg',
2534
+ { width: 800, height: 600, srcset: '/photos/x.webp 400w', avifSrcset: '/photos/x.avif 400w' },
2535
+ ],
2536
+ ]) as any,
2537
+ });
2538
+ const node = { type: 'image', src: '/photos/x.jpg', alt: 'X' } as any;
2539
+ const result = nodeToAstro(node, ctx);
2540
+ expect(result).toContain('<picture');
2541
+ expect(result).toContain('type="image/avif"');
2542
+ expect(result).toContain('type="image/webp"');
2543
+ expect(result).toContain('class="block w-full h-full"');
2544
+ });
2545
+
2546
+ test('metadata with blur but no avif wraps img in picture with blur background', () => {
2547
+ const ctx = createContext({
2548
+ imageMetadataMap: new Map([
2549
+ [
2550
+ '/photos/x.jpg',
2551
+ { width: 800, height: 600, srcset: '/photos/x.webp 400w', blurHash: 'data:image/png;base64,Z' },
2552
+ ],
2553
+ ]) as any,
2554
+ });
2555
+ const node = { type: 'image', src: '/photos/x.jpg', alt: 'X' } as any;
2556
+ const result = nodeToAstro(node, ctx);
2557
+ expect(result).toContain('<picture');
2558
+ expect(result).toContain('background-image:url(data:image/png;base64,Z)');
2559
+ expect(result).toContain('onload="this.parentElement.style.backgroundImage=\'\'"');
2560
+ const imgLine = result.split('\n').find((l) => l.includes('<img'));
2561
+ expect(imgLine).toContain('srcset=');
2562
+ });
2563
+
2564
+ test('img-specific classes split onto inner img in picture wrapper', () => {
2565
+ const ctx = createContext({
2566
+ imageMetadataMap: new Map([
2567
+ [
2568
+ '/photos/x.jpg',
2569
+ { width: 800, height: 600, srcset: '/photos/x.webp 400w', avifSrcset: '/photos/x.avif 400w' },
2570
+ ],
2571
+ ]) as any,
2572
+ });
2573
+ const node = {
2574
+ type: 'image',
2575
+ src: '/photos/x.jpg',
2576
+ alt: 'X',
2577
+ style: { display: 'flex', objectFit: 'cover' },
2578
+ } as any;
2579
+ const result = nodeToAstro(node, ctx);
2580
+ const pictureLine = result.split('\n').find((l) => l.includes('<picture'));
2581
+ expect(pictureLine).toContain('flex');
2582
+ expect(pictureLine).not.toContain('object-cover');
2583
+ const imgLine = result.split('\n').find((l) => l.includes('<img'));
2584
+ expect(imgLine).toContain('object-cover');
2585
+ });
2586
+
2587
+ test('image type node with interactive styles registers element class', () => {
2588
+ const collected = new Map();
2589
+ const ctx = createContext({ collectedInteractiveStyles: collected as any });
2590
+ const node = {
2591
+ type: 'image',
2592
+ src: '/photos/x.jpg',
2593
+ alt: 'X',
2594
+ label: 'Pic',
2595
+ interactiveStyles: [{ postfix: ':hover', style: { opacity: '0.5' } }],
2596
+ } as any;
2597
+ const result = nodeToAstro(node, ctx);
2598
+ expect(result).toContain('p_test-page_pic');
2599
+ expect(collected.has('p_test-page_pic')).toBe(true);
2600
+ });
2601
+ });
2602
+
2603
+ // =========================================================================
2604
+ // Static <Picture> with dynamic class:list fallbacks
2605
+ // =========================================================================
2606
+
2607
+ describe('static Picture with dynamic classes', () => {
2608
+ const alignMapping = {
2609
+ _mapping: true,
2610
+ prop: 'align',
2611
+ values: { left: 'left', center: 'center' },
2612
+ };
2613
+
2614
+ test('dynamic class:list without blur wraps Picture in div', () => {
2615
+ const ctx = createContext({
2616
+ isComponentDef: true,
2617
+ imageMetadataMap: new Map([
2618
+ ['/images/hero.jpg', { width: 100, height: 100, srcset: 'a.webp 100w', avifSrcset: 'a.avif 100w' }],
2619
+ ]) as any,
2620
+ });
2621
+ const node: ComponentNode = {
2622
+ type: 'node',
2623
+ tag: 'img',
2624
+ style: { textAlign: alignMapping },
2625
+ attributes: { src: '/images/hero.jpg', alt: 'Hero' },
2626
+ } as any;
2627
+ const result = nodeToAstro(node, ctx);
2628
+ expect(result).toContain('<div class:list={[');
2629
+ expect(result).toContain('<Picture src={imgHero}');
2630
+ expect(result).toContain('</div>');
2631
+ });
2632
+
2633
+ test('dynamic class:list with blur wraps Picture in blurred div', () => {
2634
+ const ctx = createContext({
2635
+ isComponentDef: true,
2636
+ imageMetadataMap: new Map([
2637
+ [
2638
+ '/images/hero.jpg',
2639
+ {
2640
+ width: 100,
2641
+ height: 100,
2642
+ srcset: 'a.webp 100w',
2643
+ avifSrcset: 'a.avif 100w',
2644
+ blurHash: 'data:image/png;base64,B',
2645
+ },
2646
+ ],
2647
+ ]) as any,
2648
+ });
2649
+ const node: ComponentNode = {
2650
+ type: 'node',
2651
+ tag: 'img',
2652
+ style: { textAlign: alignMapping },
2653
+ attributes: { src: '/images/hero.jpg', alt: 'Hero' },
2654
+ } as any;
2655
+ const result = nodeToAstro(node, ctx);
2656
+ expect(result).toContain('<div class:list={[');
2657
+ expect(result).toContain('background-image:url(data:image/png;base64,B)');
2658
+ expect(result).toContain("this.parentElement.parentElement.style.backgroundImage=''");
2659
+ });
2660
+
2661
+ test('img-specific classes land on inner Picture class, layout on pictureAttributes', () => {
2662
+ const ctx = createContext({
2663
+ imageMetadataMap: new Map([
2664
+ ['/images/hero.jpg', { width: 100, height: 100, srcset: 'a.webp 100w', avifSrcset: 'a.avif 100w' }],
2665
+ ]) as any,
2666
+ });
2667
+ const node: ComponentNode = {
2668
+ type: 'node',
2669
+ tag: 'img',
2670
+ style: { display: 'flex', objectFit: 'cover' },
2671
+ attributes: { src: '/images/hero.jpg', alt: 'Hero' },
2672
+ } as any;
2673
+ const result = nodeToAstro(node, ctx);
2674
+ expect(result).toContain('pictureAttributes={{class: "flex"}}');
2675
+ expect(result).toContain('class="object-cover"');
2676
+ });
2677
+ });
2678
+
2679
+ // =========================================================================
2680
+ // HTML img advanced (element class, dynamic class:list, dynamic styles)
2681
+ // =========================================================================
2682
+
2683
+ describe('HTML img advanced', () => {
2684
+ test('img with interactive styles registers element class on picture', () => {
2685
+ const collected = new Map();
2686
+ const ctx = createContext({
2687
+ collectedInteractiveStyles: collected as any,
2688
+ imageMetadataMap: new Map([
2689
+ ['/test.jpg', { width: 100, height: 100, srcset: 'a.webp 100w', avifSrcset: 'a.avif 100w' }],
2690
+ ]) as any,
2691
+ });
2692
+ const node: ComponentNode = {
2693
+ type: 'node',
2694
+ tag: 'img',
2695
+ label: 'HeroImg',
2696
+ interactiveStyles: [{ postfix: ':hover', style: { opacity: '0.5' } }],
2697
+ attributes: { src: '/test.jpg', alt: 'Hero' },
2698
+ } as any;
2699
+ const result = nodeToAstro(node, ctx);
2700
+ expect(result).toContain('p_test-page_heroimg');
2701
+ expect(collected.has('p_test-page_heroimg')).toBe(true);
2702
+ });
2703
+
2704
+ test('dynamic class:list goes on picture wrapper, img gets fill classes', () => {
2705
+ const ctx = createContext({
2706
+ isComponentDef: true,
2707
+ imageMetadataMap: new Map([
2708
+ ['/test.jpg', { width: 100, height: 100, srcset: 'a.webp 100w', avifSrcset: 'a.avif 100w' }],
2709
+ ]) as any,
2710
+ });
2711
+ const node: ComponentNode = {
2712
+ type: 'node',
2713
+ tag: 'img',
2714
+ style: { textAlign: { _mapping: true, prop: 'align', values: { left: 'left', center: 'center' } } },
2715
+ attributes: { src: '/test.jpg', alt: 'T' },
2716
+ } as any;
2717
+ const result = nodeToAstro(node, ctx);
2718
+ const pictureLine = result.split('\n').find((l) => l.includes('<picture'));
2719
+ expect(pictureLine).toContain('class:list={[');
2720
+ const imgLine = result.split('\n').find((l) => l.includes('<img'));
2721
+ expect(imgLine).toContain('class="block w-full h-full"');
2722
+ });
2723
+
2724
+ // The blur placeholder CSS must merge into a dynamic (template-literal)
2725
+ // style attr too — previously injectInlineStyle only handled a static
2726
+ // `style="..."` attribute and silently dropped the blur background while
2727
+ // the onload clear handler was still emitted.
2728
+ test('blur background merges into dynamic template-literal style attr', () => {
2729
+ const ctx = createContext({
2730
+ isComponentDef: true,
2731
+ imageMetadataMap: new Map([
2732
+ ['/test.jpg', { width: 100, height: 100, srcset: 'a.webp 100w', blurHash: 'data:image/png;base64,B' }],
2733
+ ]) as any,
2734
+ });
2735
+ const node: ComponentNode = {
2736
+ type: 'node',
2737
+ tag: 'img',
2738
+ style: { width: '{{w}}' },
2739
+ attributes: { src: '/test.jpg', alt: 'T' },
2740
+ } as any;
2741
+ const result = nodeToAstro(node, ctx);
2742
+ const pictureLine = result.split('\n').find((l) => l.includes('<picture'));
2743
+ expect(pictureLine).toContain(
2744
+ 'style={`width: ${w};background-image:url(data:image/png;base64,B);background-size:cover`}',
2745
+ );
2746
+ expect(result).toContain("this.parentElement.style.backgroundImage=''");
2747
+ // The merged attr stays balanced: one template literal, one JSX expression
2748
+ const styleAttr = pictureLine!.slice(pictureLine!.indexOf('style={'));
2749
+ const count = (s: string, re: RegExp) => (s.match(re) ?? []).length;
2750
+ expect(count(pictureLine!, /`/g)).toBe(2);
2751
+ expect(styleAttr.startsWith('style={`')).toBe(true);
2752
+ expect(styleAttr.trimEnd().endsWith('`}>')).toBe(true);
2753
+ });
2754
+ });
2755
+
2756
+ // =========================================================================
2757
+ // Static image import naming
2758
+ // =========================================================================
2759
+
2760
+ describe('static image import naming', () => {
2761
+ test('nested image path camel-cases into import variable', () => {
2762
+ const ctx = createContext({
2763
+ imageMetadataMap: new Map([
2764
+ ['/images/sub/hero-photo.jpg', { width: 100, height: 100, srcset: 'a.webp 100w' }],
2765
+ ]) as any,
2766
+ });
2767
+ const node: ComponentNode = {
2768
+ type: 'node',
2769
+ tag: 'img',
2770
+ attributes: { src: '/images/sub/hero-photo.jpg', alt: 'H' },
2771
+ } as any;
2772
+ const result = nodeToAstro(node, ctx);
2773
+ expect(result).toContain('src={imgSubHeroPhoto}');
2774
+ expect(ctx.imageImports?.get('imgSubHeroPhoto')).toBe('../assets/images/sub/hero-photo.jpg');
2775
+ });
2776
+
2777
+ test('var name collision appends numeric suffix', () => {
2778
+ const ctx = createContext({
2779
+ imageMetadataMap: new Map([
2780
+ ['/images/hero.jpg', { width: 100, height: 100, srcset: 'a.webp 100w' }],
2781
+ ['/images/hero.png', { width: 100, height: 100, srcset: 'b.webp 100w' }],
2782
+ ]) as any,
2783
+ });
2784
+ const nodes: ComponentNode[] = [
2785
+ { type: 'node', tag: 'img', attributes: { src: '/images/hero.jpg', alt: 'A' } },
2786
+ { type: 'node', tag: 'img', attributes: { src: '/images/hero.png', alt: 'B' } },
2787
+ ] as any;
2788
+ nodeToAstro(nodes, ctx);
2789
+ expect(ctx.imageImports?.get('imgHero')).toBe('../assets/images/hero.jpg');
2790
+ expect(ctx.imageImports?.get('imgHero2')).toBe('../assets/images/hero.png');
2791
+ });
2792
+ });
2793
+
2794
+ // =========================================================================
2795
+ // List node slicing variants
2796
+ // =========================================================================
2797
+
2798
+ describe('list node slicing', () => {
2799
+ test('offset only uses single-arg slice', () => {
2800
+ const ctx = createContext({ isComponentDef: true });
2801
+ const node: ComponentNode = {
2802
+ type: 'list',
2803
+ sourceType: 'prop',
2804
+ source: '{{items}}',
2805
+ offset: 2,
2806
+ children: [],
2807
+ } as any;
2808
+ const result = nodeToAstro(node, ctx);
2809
+ expect(result).toContain('items.slice(2).map(');
2810
+ });
2811
+
2812
+ test('limit only slices from zero', () => {
2813
+ const ctx = createContext({ isComponentDef: true });
2814
+ const node: ComponentNode = {
2815
+ type: 'list',
2816
+ sourceType: 'prop',
2817
+ source: '{{items}}',
2818
+ limit: 5,
2819
+ children: [],
2820
+ } as any;
2821
+ const result = nodeToAstro(node, ctx);
2822
+ expect(result).toContain('items.slice(0, 5).map(');
2823
+ });
2824
+
2825
+ test('custom itemAs renames map params and rewrites item vars', () => {
2826
+ const ctx = createContext({ isComponentDef: true });
2827
+ const node: ComponentNode = {
2828
+ type: 'list',
2829
+ sourceType: 'prop',
2830
+ source: '{{links}}',
2831
+ itemAs: 'link',
2832
+ children: ['{{item.text}}'],
2833
+ } as any;
2834
+ const result = nodeToAstro(node, ctx);
2835
+ expect(result).toContain('links.map((link, linkIndex) => (');
2836
+ expect(result).toContain('{link.text}');
2837
+ });
2838
+ });
2839
+
2840
+ // =========================================================================
2841
+ // Collection list query chain
2842
+ // =========================================================================
2843
+
2844
+ describe('collection list query chain', () => {
2845
+ test('filter, sort desc, offset and limit chain in order', () => {
2846
+ const ctx = createContext({ isComponentDef: false });
2847
+ const node: ComponentNode = {
2848
+ type: 'list',
2849
+ sourceType: 'collection',
2850
+ source: 'posts',
2851
+ filter: { field: 'category', operator: 'eq', value: 'news' },
2852
+ sort: { field: 'date', order: 'desc' },
2853
+ offset: 1,
2854
+ limit: 3,
2855
+ children: [],
2856
+ } as any;
2857
+ nodeToAstro(node, ctx);
2858
+ const line = ctx.frontmatterLines![0];
2859
+ expect(line).toContain('.filter(e => e.data.category === "news")');
2860
+ expect(line).toContain('.sort((a, b) => a.data.date > b.data.date ? -1 : 1)');
2861
+ expect(line).toContain('.slice(1, 4)');
2862
+ });
2863
+
2864
+ test('array-form sort ascending', () => {
2865
+ const ctx = createContext({ isComponentDef: false });
2866
+ const node: ComponentNode = {
2867
+ type: 'list',
2868
+ sourceType: 'collection',
2869
+ source: 'posts',
2870
+ sort: [{ field: 'title', order: 'asc' }],
2871
+ children: [],
2872
+ } as any;
2873
+ nodeToAstro(node, ctx);
2874
+ expect(ctx.frontmatterLines![0]).toContain('.sort((a, b) => a.data.title > b.data.title ? 1 : -1)');
2875
+ });
2876
+
2877
+ test('entries are flattened with default _url expression', () => {
2878
+ const ctx = createContext({ isComponentDef: false });
2879
+ const node: ComponentNode = {
2880
+ type: 'list',
2881
+ sourceType: 'collection',
2882
+ source: 'posts',
2883
+ children: [],
2884
+ } as any;
2885
+ nodeToAstro(node, ctx);
2886
+ expect(ctx.frontmatterLines![0]).toContain(
2887
+ '.then(items => items.map((e) => ({ ...e.data, _id: e.id, _url: `/posts/${e.data.slug ?? e.id}` })))',
2888
+ );
2889
+ });
2890
+
2891
+ test('collectionUrlExpr overrides the flattened _url expression', () => {
2892
+ const ctx = createContext({
2893
+ isComponentDef: false,
2894
+ collectionUrlExpr: new Map([['posts', '`/blog/${e.data.slug}`']]) as any,
2895
+ });
2896
+ const node: ComponentNode = {
2897
+ type: 'list',
2898
+ sourceType: 'collection',
2899
+ source: 'posts',
2900
+ children: [],
2901
+ } as any;
2902
+ nodeToAstro(node, ctx);
2903
+ expect(ctx.frontmatterLines![0]).toContain('_url: `/blog/${e.data.slug}`');
2904
+ });
2905
+
2906
+ test('default itemAs is singularized collection name and children bind to it', () => {
2907
+ const ctx = createContext({ isComponentDef: false });
2908
+ const node: ComponentNode = {
2909
+ type: 'list',
2910
+ sourceType: 'collection',
2911
+ source: 'posts',
2912
+ children: ['{{post.title}}'],
2913
+ } as any;
2914
+ const result = nodeToAstro(node, ctx);
2915
+ expect(result).toContain('postsList.map((post, postIndex) => (');
2916
+ expect(result).toContain('{post.title}');
2917
+ });
2918
+ });
2919
+
2920
+ // =========================================================================
2921
+ // Locale list advanced
2922
+ // =========================================================================
2923
+
2924
+ describe('locale list advanced', () => {
2925
+ const i18nConfig = {
2926
+ defaultLocale: 'en',
2927
+ locales: [
2928
+ { code: 'en', name: 'English', nativeName: 'English', langTag: 'en-US', icon: '/flags/en.svg' },
2929
+ { code: 'pl', name: 'Polish', nativeName: 'Polski', langTag: 'pl-PL', icon: '/flags/pl.svg' },
2930
+ ],
2931
+ } as any;
2932
+ const slugMap = { en: '/about', pl: '/pl/o-nas' };
2933
+
2934
+ test('displayType code renders uppercase locale codes', () => {
2935
+ const ctx = createContext({ locale: 'en', i18nConfig, currentPageSlugMap: slugMap });
2936
+ const node = { type: 'locale-list', displayType: 'code', showFlag: false, children: [] } as any;
2937
+ const result = nodeToAstro(node, ctx);
2938
+ expect(result).toContain('<div>EN</div>');
2939
+ expect(result).toContain('<div>PL</div>');
2940
+ });
2941
+
2942
+ test('displayType name renders English names', () => {
2943
+ const ctx = createContext({ locale: 'en', i18nConfig, currentPageSlugMap: slugMap });
2944
+ const node = { type: 'locale-list', displayType: 'name', showFlag: false, children: [] } as any;
2945
+ const result = nodeToAstro(node, ctx);
2946
+ expect(result).toContain('<div>English</div>');
2947
+ expect(result).toContain('<div>Polish</div>');
2948
+ });
2949
+
2950
+ test('flags render with flagStyle classes when icons configured', () => {
2951
+ const ctx = createContext({ locale: 'en', i18nConfig, currentPageSlugMap: slugMap });
2952
+ const node = { type: 'locale-list', flagStyle: { display: 'flex' }, children: [] } as any;
2953
+ const result = nodeToAstro(node, ctx);
2954
+ expect(result).toContain('<img src="/flags/en.svg" alt="English flag" class="flex">');
2955
+ expect(result).toContain('<img src="/flags/pl.svg" alt="Polski flag" class="flex">');
2956
+ });
2957
+
2958
+ test('separator span with separatorStyle classes between links', () => {
2959
+ const ctx = createContext({ locale: 'en', i18nConfig, currentPageSlugMap: slugMap });
2960
+ const node = { type: 'locale-list', separatorStyle: { display: 'flex' }, children: [] } as any;
2961
+ const result = nodeToAstro(node, ctx);
2962
+ expect(result).toContain('<span class="flex"></span>');
2963
+ });
2964
+
2965
+ test('showCurrent false hides the active locale link', () => {
2966
+ const ctx = createContext({ locale: 'en', i18nConfig, currentPageSlugMap: slugMap });
2967
+ const node = { type: 'locale-list', showCurrent: false, showFlag: false, children: [] } as any;
2968
+ const result = nodeToAstro(node, ctx);
2969
+ expect(result).not.toContain('data-locale="en"');
2970
+ expect(result).toContain('data-locale="pl"');
2971
+ });
2972
+
2973
+ test('itemStyle and activeItemStyle combine on the current link', () => {
2974
+ const ctx = createContext({ locale: 'en', i18nConfig, currentPageSlugMap: slugMap });
2975
+ const node = {
2976
+ type: 'locale-list',
2977
+ showFlag: false,
2978
+ itemStyle: { display: 'flex' },
2979
+ activeItemStyle: { textAlign: 'center' },
2980
+ children: [],
2981
+ } as any;
2982
+ const result = nodeToAstro(node, ctx);
2983
+ const activeLine = result.split('\n').find((l) => l.includes('data-current="true"'));
2984
+ expect(activeLine).toContain('class="flex text-center"');
2985
+ const inactiveLine = result.split('\n').find((l) => l.includes('data-current="false"'));
2986
+ expect(inactiveLine).toContain('class="flex"');
2987
+ expect(inactiveLine).not.toContain('text-center');
2988
+ });
2989
+
2990
+ test('locale list with interactive styles registers element class', () => {
2991
+ const collected = new Map();
2992
+ const ctx = createContext({
2993
+ locale: 'en',
2994
+ i18nConfig,
2995
+ currentPageSlugMap: slugMap,
2996
+ collectedInteractiveStyles: collected as any,
2997
+ });
2998
+ const node = {
2999
+ type: 'locale-list',
3000
+ label: 'Switcher',
3001
+ interactiveStyles: [{ postfix: ':hover', style: { opacity: '0.5' } }],
3002
+ children: [],
3003
+ } as any;
3004
+ const result = nodeToAstro(node, ctx);
3005
+ expect(result).toContain('p_test-page_switcher');
3006
+ expect(collected.has('p_test-page_switcher')).toBe(true);
3007
+ });
3008
+ });
3009
+
3010
+ // =========================================================================
3011
+ // Conditional rendering advanced
3012
+ // =========================================================================
3013
+
3014
+ describe('conditional rendering advanced', () => {
3015
+ test('boolean mapping with single true value emits equality conditional', () => {
3016
+ const ctx = createContext({ isComponentDef: true });
3017
+ const node: ComponentNode = {
3018
+ type: 'node',
3019
+ tag: 'div',
3020
+ if: { _mapping: true, prop: 'show', values: { yes: true, no: false } },
3021
+ children: ['x'],
3022
+ } as any;
3023
+ const result = nodeToAstro(node, ctx);
3024
+ expect(result).toContain(`{show === 'yes' && (`);
3025
+ expect(result).toContain(')}');
3026
+ });
3027
+
3028
+ test('boolean mapping with multiple true values emits includes() conditional', () => {
3029
+ const ctx = createContext({ isComponentDef: true });
3030
+ const node: ComponentNode = {
3031
+ type: 'node',
3032
+ tag: 'div',
3033
+ if: { _mapping: true, prop: 'show', values: { a: true, b: true, c: false } },
3034
+ children: ['x'],
3035
+ } as any;
3036
+ const result = nodeToAstro(node, ctx);
3037
+ expect(result).toContain(`{['a', 'b'].includes(show) && (`);
3038
+ });
3039
+
3040
+ // A boolean mapping with no true values is "never shown". emitIfOpen emits
3041
+ // only a `{/* hidden */}` comment (no `{cond && (` opener), so emitIfClose
3042
+ // must emit nothing — a dangling `)}` is unparseable Astro. Matches the
3043
+ // static `if: false` convention (hidden comment, element still emitted).
3044
+ test('boolean mapping with no true values emits balanced hidden comment', () => {
3045
+ const ctx = createContext({ isComponentDef: true });
3046
+ const node: ComponentNode = {
3047
+ type: 'node',
3048
+ tag: 'div',
3049
+ if: { _mapping: true, prop: 'show', values: { a: false } },
3050
+ children: ['x'],
3051
+ } as any;
3052
+ const result = nodeToAstro(node, ctx);
3053
+ expect(result).toContain('{/* hidden */}');
3054
+ expect(result.trimEnd().endsWith(')}')).toBe(false);
3055
+ expect(result).not.toContain(')}');
3056
+ // Braces and parens balance
3057
+ const count = (s: string, re: RegExp) => (s.match(re) ?? []).length;
3058
+ expect(count(result, /\{/g)).toBe(count(result, /\}/g));
3059
+ expect(count(result, /\(/g)).toBe(count(result, /\)/g));
3060
+ });
3061
+
3062
+ test('list-item meta var conditional resolves index expression', () => {
3063
+ const ctx = createContext({
3064
+ listItemBinding: 'item',
3065
+ listIndexVar: 'i',
3066
+ listSourceVar: 'items',
3067
+ });
3068
+ const node: ComponentNode = {
3069
+ type: 'node',
3070
+ tag: 'div',
3071
+ if: '{{itemLast}}',
3072
+ children: ['x'],
3073
+ } as any;
3074
+ const result = nodeToAstro(node, ctx);
3075
+ expect(result).toContain('{(i === items.length - 1) && (');
3076
+ expect(result).toContain(')}');
3077
+ });
3078
+
3079
+ test('bare identifier string condition wraps in component def', () => {
3080
+ const ctx = createContext({ isComponentDef: true });
3081
+ const node: ComponentNode = {
3082
+ type: 'node',
3083
+ tag: 'div',
3084
+ if: 'showX',
3085
+ children: ['x'],
3086
+ } as any;
3087
+ const result = nodeToAstro(node, ctx);
3088
+ expect(result).toContain('{showX && (');
3089
+ expect(result).toContain(')}');
3090
+ });
3091
+
3092
+ test('non-identifier plain string condition is skipped entirely', () => {
3093
+ const ctx = createContext({ isComponentDef: true });
3094
+ const node: ComponentNode = {
3095
+ type: 'node',
3096
+ tag: 'div',
3097
+ if: 'not an identifier!',
3098
+ children: ['x'],
3099
+ } as any;
3100
+ const result = nodeToAstro(node, ctx);
3101
+ expect(result).not.toContain('&& (');
3102
+ expect(result).not.toContain(')}');
3103
+ expect(result).toContain('<div>');
3104
+ });
3105
+
3106
+ test('string condition in page context emits no conditional', () => {
3107
+ const ctx = createContext({ isComponentDef: false });
3108
+ const node: ComponentNode = {
3109
+ type: 'node',
3110
+ tag: 'div',
3111
+ if: '{{show}}',
3112
+ children: ['x'],
3113
+ } as any;
3114
+ const result = nodeToAstro(node, ctx);
3115
+ expect(result).not.toContain('&& (');
3116
+ expect(result).toContain('<div>');
3117
+ });
3118
+
3119
+ test('mixed-content CMS condition emits no conditional (pinned)', () => {
3120
+ // Only a full-match {{cms.field}} condition is supported; mixed strings
3121
+ // fall through and render unconditionally.
3122
+ const ctx = createContext({ cmsMode: true, cmsEntryBinding: 'entry' });
3123
+ const node: ComponentNode = {
3124
+ type: 'node',
3125
+ tag: 'div',
3126
+ if: 'show {{cms.flag}}',
3127
+ children: ['x'],
3128
+ } as any;
3129
+ const result = nodeToAstro(node, ctx);
3130
+ expect(result).not.toContain('&& (');
3131
+ expect(result).toContain('<div>');
3132
+ });
3133
+
3134
+ test('CMS conditional honors custom entry binding', () => {
3135
+ const ctx = createContext({ cmsMode: true, cmsEntryBinding: 'post' });
3136
+ const node: ComponentNode = {
3137
+ type: 'node',
3138
+ tag: 'div',
3139
+ if: '{{cms.featured}}',
3140
+ children: ['x'],
3141
+ } as any;
3142
+ const result = nodeToAstro(node, ctx);
3143
+ expect(result).toContain('{post.data.featured && (');
3144
+ expect(result).toContain(')}');
3145
+ });
3146
+ });
3147
+
3148
+ // =========================================================================
3149
+ // Children forms (string / single node)
3150
+ // =========================================================================
3151
+
3152
+ describe('children forms', () => {
3153
+ test('string children value is emitted as text', () => {
3154
+ const ctx = createContext();
3155
+ const node: ComponentNode = {
3156
+ type: 'node',
3157
+ tag: 'div',
3158
+ children: 'plain string child',
3159
+ } as any;
3160
+ const result = nodeToAstro(node, ctx);
3161
+ expect(result).toContain('plain string child');
3162
+ expect(result).toContain('<div>');
3163
+ });
3164
+
3165
+ test('single node object children is emitted', () => {
3166
+ const ctx = createContext();
3167
+ const node: ComponentNode = {
3168
+ type: 'node',
3169
+ tag: 'div',
3170
+ children: { type: 'node', tag: 'span', children: ['inner'] },
3171
+ } as any;
3172
+ const result = nodeToAstro(node, ctx);
3173
+ expect(result).toContain('<span>');
3174
+ expect(result).toContain('inner');
3175
+ });
3176
+ });
3177
+
3178
+ // =========================================================================
3179
+ // Sizes attribute inheritance
3180
+ // =========================================================================
3181
+
3182
+ describe('sizes attribute inheritance', () => {
3183
+ test('missing mobile width inherits from tablet', () => {
3184
+ const ctx = createContext({
3185
+ imageMetadataMap: new Map([['/test.jpg', { width: 100, height: 100, srcset: 'a.webp 100w' }]]) as any,
3186
+ });
3187
+ const node: ComponentNode = {
3188
+ type: 'node',
3189
+ tag: 'img',
3190
+ attributes: { src: '/test.jpg', alt: 'T' },
3191
+ style: { base: { width: '700px' }, tablet: { width: '500px' } },
3192
+ children: [],
3193
+ } as any;
3194
+ const result = nodeToAstro(node, ctx);
3195
+ expect(result).toContain('sizes="(max-width: 540px) 500px, (max-width: 1024px) 500px, 700px"');
3196
+ });
3197
+
3198
+ test('responsive style with only base width uses base everywhere', () => {
3199
+ const ctx = createContext({
3200
+ imageMetadataMap: new Map([['/test.jpg', { width: 100, height: 100, srcset: 'a.webp 100w' }]]) as any,
3201
+ });
3202
+ const node: ComponentNode = {
3203
+ type: 'node',
3204
+ tag: 'img',
3205
+ attributes: { src: '/test.jpg', alt: 'T' },
3206
+ style: { base: { width: '600px' } },
3207
+ children: [],
3208
+ } as any;
3209
+ const result = nodeToAstro(node, ctx);
3210
+ expect(result).toContain('sizes="(max-width: 540px) 600px, (max-width: 1024px) 600px, 600px"');
3211
+ });
3212
+ });
1493
3213
  });