meno-core 1.0.52 → 1.0.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (399) hide show
  1. package/.claude/settings.local.json +1 -3
  2. package/bin/cli.ts +48 -57
  3. package/build-astro.ts +296 -108
  4. package/build-next.ts +1374 -0
  5. package/build-static.test.ts +39 -10
  6. package/build-static.ts +127 -127
  7. package/dist/bin/cli.js +34 -38
  8. package/dist/bin/cli.js.map +2 -2
  9. package/dist/build-static.js +12 -11
  10. package/dist/chunks/chunk-2AR55GYH.js +42 -0
  11. package/dist/chunks/chunk-2AR55GYH.js.map +7 -0
  12. package/dist/chunks/{chunk-CXCBV2M7.js → chunk-2FN4UOVO.js} +581 -502
  13. package/dist/chunks/chunk-2FN4UOVO.js.map +7 -0
  14. package/dist/chunks/chunk-3XER4E5W.js +168 -0
  15. package/dist/chunks/chunk-3XER4E5W.js.map +7 -0
  16. package/dist/chunks/chunk-5ETZFREW.js +514 -0
  17. package/dist/chunks/chunk-5ETZFREW.js.map +7 -0
  18. package/dist/chunks/{chunk-2MHDV5BF.js → chunk-7E4IF5L7.js} +15 -21
  19. package/dist/chunks/chunk-7E4IF5L7.js.map +7 -0
  20. package/dist/chunks/{chunk-7NIC4I3V.js → chunk-7HWQUVTU.js} +1691 -1316
  21. package/dist/chunks/chunk-7HWQUVTU.js.map +7 -0
  22. package/dist/chunks/{chunk-EDQSMAMP.js → chunk-AE3QK5QW.js} +189 -19
  23. package/dist/chunks/chunk-AE3QK5QW.js.map +7 -0
  24. package/dist/chunks/{chunk-HNLUO36W.js → chunk-F6KTJYGV.js} +8 -8
  25. package/dist/chunks/chunk-F6KTJYGV.js.map +7 -0
  26. package/dist/chunks/{chunk-WQFG7PAH.js → chunk-FZITJSSS.js} +2 -6
  27. package/dist/chunks/chunk-FZITJSSS.js.map +7 -0
  28. package/dist/chunks/{chunk-H4JSCDNW.js → chunk-GSYYA5GX.js} +25 -2
  29. package/dist/chunks/chunk-GSYYA5GX.js.map +7 -0
  30. package/dist/chunks/{chunk-2QK6U5UK.js → chunk-HIZMY3EP.js} +12 -2
  31. package/dist/chunks/chunk-HIZMY3EP.js.map +7 -0
  32. package/dist/chunks/{chunk-AZQYF6KE.js → chunk-I2WEGYA7.js} +41 -176
  33. package/dist/chunks/chunk-I2WEGYA7.js.map +7 -0
  34. package/dist/chunks/{chunk-I7YIGZXT.js → chunk-JNO3CNLJ.js} +6 -9
  35. package/dist/chunks/chunk-JNO3CNLJ.js.map +7 -0
  36. package/dist/chunks/{chunk-UB44F4Z2.js → chunk-NVRBTSQG.js} +2 -4
  37. package/dist/chunks/chunk-NVRBTSQG.js.map +7 -0
  38. package/dist/chunks/{chunk-LHLHPYSP.js → chunk-Q4OBWKXG.js} +48 -40
  39. package/dist/chunks/chunk-Q4OBWKXG.js.map +7 -0
  40. package/dist/chunks/{chunk-A725KYFK.js → chunk-QTE32Y53.js} +780 -323
  41. package/dist/chunks/chunk-QTE32Y53.js.map +7 -0
  42. package/dist/chunks/{chunk-LPVETICS.js → chunk-STDY3OVM.js} +397 -84
  43. package/dist/chunks/chunk-STDY3OVM.js.map +7 -0
  44. package/dist/chunks/configService-PRJZF7Y6.js +14 -0
  45. package/dist/chunks/{constants-GWBAD66U.js → constants-KIQEYMAM.js} +2 -2
  46. package/dist/chunks/{fs-JGINUXGL.js → fs-ZI5JEU7V.js} +2 -2
  47. package/dist/entries/server-router.js +14 -19
  48. package/dist/entries/server-router.js.map +2 -2
  49. package/dist/lib/client/index.js +957 -356
  50. package/dist/lib/client/index.js.map +4 -4
  51. package/dist/lib/server/index.js +1538 -328
  52. package/dist/lib/server/index.js.map +4 -4
  53. package/dist/lib/shared/index.js +277 -73
  54. package/dist/lib/shared/index.js.map +4 -4
  55. package/dist/lib/shared/richtext/index.js +1 -1
  56. package/dist/lib/test-utils/index.js +38 -60
  57. package/dist/lib/test-utils/index.js.map +2 -2
  58. package/entries/client-router.tsx +14 -172
  59. package/entries/server-router.tsx +1 -7
  60. package/lib/client/ClientInitializer.ts +8 -8
  61. package/lib/client/ErrorBoundary.test.tsx +156 -151
  62. package/lib/client/ErrorBoundary.tsx +184 -121
  63. package/lib/client/componentRegistry.test.ts +96 -108
  64. package/lib/client/componentRegistry.ts +1 -2
  65. package/lib/client/contexts/ThemeContext.tsx +3 -2
  66. package/lib/client/core/ComponentBuilder.test.ts +513 -560
  67. package/lib/client/core/ComponentBuilder.ts +335 -146
  68. package/lib/client/core/ComponentRenderer.test.tsx +1 -2
  69. package/lib/client/core/ComponentRenderer.tsx +46 -33
  70. package/lib/client/core/builders/embedBuilder.ts +246 -54
  71. package/lib/client/core/builders/linkBuilder.ts +71 -44
  72. package/lib/client/core/builders/linkNodeBuilder.ts +78 -53
  73. package/lib/client/core/builders/listBuilder.ts +137 -89
  74. package/lib/client/core/builders/localeListBuilder.ts +95 -60
  75. package/lib/client/core/builders/types.ts +5 -5
  76. package/lib/client/core/cmsTemplateProcessor.ts +7 -7
  77. package/lib/client/elementRegistry.ts +3 -3
  78. package/lib/client/fontFamiliesService.test.ts +68 -0
  79. package/lib/client/fontFamiliesService.ts +69 -0
  80. package/lib/client/hmr/HMRManager.tsx +8 -0
  81. package/lib/client/hmrCssReload.ts +166 -0
  82. package/lib/client/hmrWebSocket.ts +9 -14
  83. package/lib/client/hooks/useColorVariables.test.ts +21 -21
  84. package/lib/client/hooks/useColorVariables.ts +14 -10
  85. package/lib/client/hooks/usePropertyAutocomplete.ts +3 -5
  86. package/lib/client/hooks/useVariables.ts +4 -4
  87. package/lib/client/hydration/HydrationUtils.test.ts +24 -25
  88. package/lib/client/hydration/HydrationUtils.ts +3 -4
  89. package/lib/client/i18nConfigService.test.ts +2 -7
  90. package/lib/client/i18nConfigService.ts +2 -2
  91. package/lib/client/index.ts +4 -0
  92. package/lib/client/meno-filter/MenoFilter.test.ts +19 -21
  93. package/lib/client/meno-filter/MenoFilter.ts +5 -9
  94. package/lib/client/meno-filter/bindings.ts +15 -40
  95. package/lib/client/meno-filter/init.ts +1 -1
  96. package/lib/client/meno-filter/renderer.ts +23 -29
  97. package/lib/client/meno-filter/script.generated.ts +1 -3
  98. package/lib/client/meno-filter/ui.ts +5 -5
  99. package/lib/client/meno-filter/updates.ts +15 -21
  100. package/lib/client/navigation.test.ts +159 -159
  101. package/lib/client/navigation.ts +0 -1
  102. package/lib/client/responsiveStyleResolver.test.ts +230 -228
  103. package/lib/client/responsiveStyleResolver.ts +13 -16
  104. package/lib/client/routing/RouteLoader.test.ts +25 -26
  105. package/lib/client/routing/RouteLoader.ts +30 -37
  106. package/lib/client/routing/Router.tsx +112 -18
  107. package/lib/client/scripts/ScriptExecutor.test.ts +270 -128
  108. package/lib/client/scripts/ScriptExecutor.ts +69 -33
  109. package/lib/client/services/PrefetchService.test.ts +2 -2
  110. package/lib/client/services/PrefetchService.ts +10 -24
  111. package/lib/client/styleProcessor.test.ts +9 -9
  112. package/lib/client/styleProcessor.ts +18 -15
  113. package/lib/client/styles/StyleInjector.test.ts +122 -115
  114. package/lib/client/styles/StyleInjector.ts +25 -7
  115. package/lib/client/styles/UtilityClassCollector.ts +26 -27
  116. package/lib/client/styles/cspNonce.test.ts +64 -0
  117. package/lib/client/styles/cspNonce.ts +63 -0
  118. package/lib/client/templateEngine.test.ts +600 -448
  119. package/lib/client/templateEngine.ts +205 -64
  120. package/lib/client/theme.ts +0 -1
  121. package/lib/client/utils/toast.ts +0 -1
  122. package/lib/server/__integration__/api-routes.test.ts +8 -4
  123. package/lib/server/__integration__/cms-integration.test.ts +1 -4
  124. package/lib/server/__integration__/server-lifecycle.test.ts +2 -5
  125. package/lib/server/__integration__/ssr-rendering.test.ts +47 -37
  126. package/lib/server/__integration__/static-assets.test.ts +1 -1
  127. package/lib/server/__integration__/test-helpers.ts +84 -70
  128. package/lib/server/ab/generateFunctions.ts +12 -10
  129. package/lib/server/astro/cmsPageEmitter.ts +47 -32
  130. package/lib/server/astro/componentEmitter.ts +82 -37
  131. package/lib/server/astro/cssCollector.ts +10 -26
  132. package/lib/server/astro/nodeToAstro.test.ts +1750 -30
  133. package/lib/server/astro/nodeToAstro.ts +327 -178
  134. package/lib/server/astro/normalizeOrphanTemplateProps.test.ts +260 -0
  135. package/lib/server/astro/normalizeOrphanTemplateProps.ts +176 -0
  136. package/lib/server/astro/pageEmitter.ts +9 -13
  137. package/lib/server/astro/tailwindMapper.test.ts +10 -37
  138. package/lib/server/astro/tailwindMapper.ts +33 -40
  139. package/lib/server/astro/templateTransformer.ts +14 -17
  140. package/lib/server/createServer.ts +16 -17
  141. package/lib/server/cssGenerator.test.ts +35 -44
  142. package/lib/server/cssGenerator.ts +6 -17
  143. package/lib/server/draftPageStore.ts +49 -0
  144. package/lib/server/fileWatcher.test.ts +124 -10
  145. package/lib/server/fileWatcher.ts +164 -98
  146. package/lib/server/index.ts +20 -2
  147. package/lib/server/jsonLoader.test.ts +39 -2
  148. package/lib/server/jsonLoader.ts +33 -31
  149. package/lib/server/middleware/cors.test.ts +20 -20
  150. package/lib/server/middleware/cors.ts +28 -4
  151. package/lib/server/middleware/errorHandler.test.ts +5 -3
  152. package/lib/server/middleware/errorHandler.ts +3 -8
  153. package/lib/server/middleware/index.ts +0 -1
  154. package/lib/server/middleware/logger.test.ts +7 -5
  155. package/lib/server/middleware/logger.ts +10 -22
  156. package/lib/server/pageCache.test.ts +76 -77
  157. package/lib/server/pageCache.ts +0 -1
  158. package/lib/server/projectContext.ts +4 -3
  159. package/lib/server/providers/fileSystemCMSProvider.test.ts +124 -95
  160. package/lib/server/providers/fileSystemCMSProvider.ts +35 -20
  161. package/lib/server/providers/fileSystemPageProvider.test.ts +84 -0
  162. package/lib/server/providers/fileSystemPageProvider.ts +39 -12
  163. package/lib/server/routes/api/cms.test.ts +26 -14
  164. package/lib/server/routes/api/cms.ts +9 -14
  165. package/lib/server/routes/api/components.ts +35 -34
  166. package/lib/server/routes/api/config.ts +0 -1
  167. package/lib/server/routes/api/core-routes.ts +49 -76
  168. package/lib/server/routes/api/functions.ts +8 -16
  169. package/lib/server/routes/api/index.ts +3 -6
  170. package/lib/server/routes/api/pages.ts +21 -32
  171. package/lib/server/routes/api/shared.test.ts +1 -1
  172. package/lib/server/routes/api/shared.ts +65 -6
  173. package/lib/server/routes/api/variables.test.ts +1 -3
  174. package/lib/server/routes/api/variables.ts +1 -1
  175. package/lib/server/routes/index.ts +106 -19
  176. package/lib/server/routes/pages.ts +47 -35
  177. package/lib/server/routes/static.ts +16 -4
  178. package/lib/server/runtime/bundler.ts +47 -32
  179. package/lib/server/runtime/fs.ts +3 -13
  180. package/lib/server/runtime/httpServer.ts +5 -9
  181. package/lib/server/services/ColorService.ts +32 -27
  182. package/lib/server/services/EnumService.test.ts +2 -6
  183. package/lib/server/services/EnumService.ts +7 -2
  184. package/lib/server/services/VariableService.test.ts +1 -5
  185. package/lib/server/services/VariableService.ts +6 -1
  186. package/lib/server/services/cmsService.test.ts +116 -78
  187. package/lib/server/services/cmsService.ts +24 -54
  188. package/lib/server/services/componentService.test.ts +303 -20
  189. package/lib/server/services/componentService.ts +397 -76
  190. package/lib/server/services/configService.test.ts +9 -31
  191. package/lib/server/services/configService.ts +20 -27
  192. package/lib/server/services/fileWatcherService.ts +44 -30
  193. package/lib/server/services/index.ts +0 -1
  194. package/lib/server/services/pageService.test.ts +24 -3
  195. package/lib/server/services/pageService.ts +135 -16
  196. package/lib/server/ssr/attributeBuilder.ts +24 -8
  197. package/lib/server/ssr/buildErrorOverlay.ts +12 -13
  198. package/lib/server/ssr/clientDataInjector.ts +7 -21
  199. package/lib/server/ssr/cmsSSRProcessor.ts +3 -6
  200. package/lib/server/ssr/cssCollector.ts +1 -1
  201. package/lib/server/ssr/errorOverlay.test.ts +21 -2
  202. package/lib/server/ssr/errorOverlay.ts +39 -18
  203. package/lib/server/ssr/htmlGenerator.nonce.test.ts +3 -9
  204. package/lib/server/ssr/htmlGenerator.test.ts +173 -56
  205. package/lib/server/ssr/htmlGenerator.ts +191 -112
  206. package/lib/server/ssr/imageMetadata.test.ts +3 -1
  207. package/lib/server/ssr/imageMetadata.ts +25 -19
  208. package/lib/server/ssr/jsCollector.test.ts +3 -13
  209. package/lib/server/ssr/jsCollector.ts +3 -8
  210. package/lib/server/ssr/liveReloadIntegration.test.ts +184 -22
  211. package/lib/server/ssr/metaTagGenerator.ts +2 -2
  212. package/lib/server/ssr/ssrRenderer.branches.test.ts +1103 -0
  213. package/lib/server/ssr/ssrRenderer.test.ts +263 -246
  214. package/lib/server/ssr/ssrRenderer.ts +700 -231
  215. package/lib/server/ssrRenderer.test.ts +1044 -950
  216. package/lib/server/utils/jsonLineMapper.test.ts +28 -28
  217. package/lib/server/utils/jsonLineMapper.ts +1 -1
  218. package/lib/server/validateStyleCoverage.ts +18 -20
  219. package/lib/server/webflow/buildWebflow.ts +41 -53
  220. package/lib/server/webflow/nodeToWebflow.test.ts +150 -218
  221. package/lib/server/webflow/nodeToWebflow.ts +195 -258
  222. package/lib/server/webflow/styleMapper.test.ts +15 -56
  223. package/lib/server/webflow/styleMapper.ts +33 -41
  224. package/lib/server/webflow/types.ts +1 -8
  225. package/lib/server/websocketManager.ts +16 -20
  226. package/lib/shared/attributeNodeUtils.test.ts +15 -15
  227. package/lib/shared/attributeNodeUtils.ts +5 -12
  228. package/lib/shared/breakpoints.ts +4 -11
  229. package/lib/shared/cmsQueryParser.test.ts +50 -42
  230. package/lib/shared/cmsQueryParser.ts +49 -31
  231. package/lib/shared/colorVariableUtils.test.ts +5 -5
  232. package/lib/shared/colorVariableUtils.ts +3 -8
  233. package/lib/shared/componentRefs.ts +41 -0
  234. package/lib/shared/constants.test.ts +3 -3
  235. package/lib/shared/constants.ts +12 -8
  236. package/lib/shared/cssGeneration.test.ts +262 -144
  237. package/lib/shared/cssGeneration.ts +189 -514
  238. package/lib/shared/cssNamedColors.ts +152 -30
  239. package/lib/shared/cssProperties.test.ts +5 -6
  240. package/lib/shared/cssProperties.ts +479 -111
  241. package/lib/shared/elementClassName.test.ts +109 -109
  242. package/lib/shared/elementClassName.ts +1 -1
  243. package/lib/shared/elementUtils.ts +12 -16
  244. package/lib/shared/errorLogger.ts +2 -10
  245. package/lib/shared/errors.test.ts +2 -13
  246. package/lib/shared/errors.ts +2 -8
  247. package/lib/shared/expressionEvaluator.test.ts +119 -0
  248. package/lib/shared/expressionEvaluator.ts +95 -22
  249. package/lib/shared/fontCss.ts +101 -0
  250. package/lib/shared/fontLoader.test.ts +19 -5
  251. package/lib/shared/fontLoader.ts +8 -86
  252. package/lib/shared/friendlyError.test.ts +87 -0
  253. package/lib/shared/friendlyError.ts +120 -0
  254. package/lib/shared/gradientUtils.test.ts +1 -5
  255. package/lib/shared/gradientUtils.ts +2 -6
  256. package/lib/shared/hrefRefs.test.ts +130 -0
  257. package/lib/shared/hrefRefs.ts +92 -0
  258. package/lib/shared/i18n.test.ts +1 -1
  259. package/lib/shared/i18n.ts +13 -34
  260. package/lib/shared/index.ts +56 -0
  261. package/lib/shared/inlineSvgStyleRules.test.ts +108 -0
  262. package/lib/shared/inlineSvgStyleRules.ts +132 -0
  263. package/lib/shared/interactiveStyleMappings.test.ts +11 -33
  264. package/lib/shared/interactiveStyleMappings.ts +9 -16
  265. package/lib/shared/interactiveStyles.test.ts +165 -188
  266. package/lib/shared/interfaces/contentProvider.ts +14 -1
  267. package/lib/shared/itemTemplateUtils.test.ts +20 -12
  268. package/lib/shared/itemTemplateUtils.ts +23 -36
  269. package/lib/shared/jsonRepair.ts +8 -2
  270. package/lib/shared/libraryLoader.test.ts +15 -49
  271. package/lib/shared/libraryLoader.ts +7 -22
  272. package/lib/shared/netlifyLocale404.test.ts +179 -0
  273. package/lib/shared/netlifyLocale404.ts +110 -0
  274. package/lib/shared/nodeUtils.test.ts +24 -16
  275. package/lib/shared/nodeUtils.ts +49 -19
  276. package/lib/shared/pathArrayUtils.test.ts +1 -2
  277. package/lib/shared/pathArrayUtils.ts +1 -1
  278. package/lib/shared/pathSecurity.ts +1 -1
  279. package/lib/shared/pathUtils.test.ts +4 -6
  280. package/lib/shared/pathUtils.ts +42 -48
  281. package/lib/shared/paths/Path.test.ts +2 -2
  282. package/lib/shared/paths/Path.ts +0 -1
  283. package/lib/shared/paths/PathConverter.test.ts +1 -1
  284. package/lib/shared/paths/PathConverter.ts +14 -17
  285. package/lib/shared/paths/PathUtils.ts +9 -10
  286. package/lib/shared/paths/PathValidator.test.ts +2 -15
  287. package/lib/shared/paths/PathValidator.ts +11 -9
  288. package/lib/shared/paths/index.ts +1 -2
  289. package/lib/shared/propResolver.test.ts +240 -244
  290. package/lib/shared/propResolver.ts +14 -25
  291. package/lib/shared/pxToRem.test.ts +7 -6
  292. package/lib/shared/pxToRem.ts +2 -5
  293. package/lib/shared/registry/BaseNodeTypeRegistry.test.ts +9 -5
  294. package/lib/shared/registry/ClientRegistry.ts +0 -1
  295. package/lib/shared/registry/ComponentRegistry.test.ts +43 -29
  296. package/lib/shared/registry/ComponentRegistry.ts +9 -11
  297. package/lib/shared/registry/NodeTypeDefinition.ts +15 -8
  298. package/lib/shared/registry/RegistryManager.ts +1 -2
  299. package/lib/shared/registry/SSRRegistry.ts +0 -1
  300. package/lib/shared/registry/createNodeType.ts +7 -9
  301. package/lib/shared/registry/defineNodeType.ts +2 -6
  302. package/lib/shared/registry/index.ts +0 -1
  303. package/lib/shared/registry/nodeTypes/ComponentInstanceNodeType.ts +14 -15
  304. package/lib/shared/registry/nodeTypes/EmbedNodeType.ts +18 -11
  305. package/lib/shared/registry/nodeTypes/HtmlNodeType.ts +47 -18
  306. package/lib/shared/registry/nodeTypes/LinkNodeType.ts +22 -20
  307. package/lib/shared/registry/nodeTypes/ListNodeType.ts +78 -74
  308. package/lib/shared/registry/nodeTypes/LocaleListNodeType.ts +27 -21
  309. package/lib/shared/registry/nodeTypes/SlotMarkerType.ts +6 -7
  310. package/lib/shared/registry/nodeTypes/index.ts +10 -2
  311. package/lib/shared/responsiveScaling.test.ts +15 -31
  312. package/lib/shared/responsiveScaling.ts +55 -37
  313. package/lib/shared/responsiveStyleUtils.ts +11 -13
  314. package/lib/shared/richtext/htmlToTiptap.test.ts +23 -14
  315. package/lib/shared/richtext/htmlToTiptap.ts +1 -3
  316. package/lib/shared/richtext/tiptapToHtml.test.ts +5 -6
  317. package/lib/shared/richtext/types.ts +1 -8
  318. package/lib/shared/slugTranslator.test.ts +37 -13
  319. package/lib/shared/slugTranslator.ts +31 -11
  320. package/lib/shared/slugify.ts +9 -15
  321. package/lib/shared/styleNodeUtils.test.ts +8 -8
  322. package/lib/shared/styleNodeUtils.ts +9 -11
  323. package/lib/shared/styleUtils.test.ts +87 -61
  324. package/lib/shared/styleUtils.ts +5 -6
  325. package/lib/shared/themeDefaults.test.ts +11 -11
  326. package/lib/shared/themeDefaults.ts +3 -4
  327. package/lib/shared/tree/PathBuilder.test.ts +160 -109
  328. package/lib/shared/tree/PathBuilder.ts +121 -59
  329. package/lib/shared/treePathUtils.test.ts +2 -10
  330. package/lib/shared/treePathUtils.ts +54 -59
  331. package/lib/shared/types/api.ts +1 -2
  332. package/lib/shared/types/cms.ts +25 -21
  333. package/lib/shared/types/comment.ts +132 -0
  334. package/lib/shared/types/components.ts +27 -25
  335. package/lib/shared/types/errors.test.ts +1 -6
  336. package/lib/shared/types/errors.ts +3 -7
  337. package/lib/shared/types/experiments.ts +28 -28
  338. package/lib/shared/types/index.ts +14 -2
  339. package/lib/shared/types/rendering.ts +8 -0
  340. package/lib/shared/types/styles.ts +0 -1
  341. package/lib/shared/types/variables.test.ts +4 -13
  342. package/lib/shared/types/variables.ts +48 -27
  343. package/lib/shared/types.ts +1 -2
  344. package/lib/shared/utilityClassConfig.ts +648 -319
  345. package/lib/shared/utilityClassMapper.test.ts +213 -78
  346. package/lib/shared/utilityClassMapper.ts +188 -246
  347. package/lib/shared/utilityClassNames.ts +326 -0
  348. package/lib/shared/utils.test.ts +2 -10
  349. package/lib/shared/utils.ts +19 -10
  350. package/lib/shared/validation/cmsValidators.ts +2 -1
  351. package/lib/shared/validation/commentValidators.test.ts +53 -0
  352. package/lib/shared/validation/commentValidators.ts +80 -0
  353. package/lib/shared/validation/index.ts +1 -0
  354. package/lib/shared/validation/propValidator.test.ts +18 -20
  355. package/lib/shared/validation/propValidator.ts +12 -17
  356. package/lib/shared/validation/schemas.test.ts +24 -33
  357. package/lib/shared/validation/schemas.ts +469 -344
  358. package/lib/shared/validation/validators.test.ts +1 -6
  359. package/lib/shared/validation/validators.ts +89 -68
  360. package/lib/shared/viewportUnits.integration.test.ts +46 -0
  361. package/lib/shared/viewportUnits.test.ts +91 -0
  362. package/lib/shared/viewportUnits.ts +63 -0
  363. package/lib/test-utils/dom-setup.ts +7 -1
  364. package/lib/test-utils/factories/ConsoleMockFactory.ts +3 -7
  365. package/lib/test-utils/factories/DomMockFactory.ts +7 -19
  366. package/lib/test-utils/factories/EventMockFactory.ts +7 -13
  367. package/lib/test-utils/factories/FetchMockFactory.ts +39 -57
  368. package/lib/test-utils/factories/ServerMockFactory.ts +5 -9
  369. package/lib/test-utils/factories/StoreMockFactory.ts +14 -25
  370. package/lib/test-utils/fixtures.ts +45 -45
  371. package/lib/test-utils/helpers/asyncHelpers.test.ts +15 -18
  372. package/lib/test-utils/helpers/asyncHelpers.ts +11 -20
  373. package/lib/test-utils/helpers.ts +1 -5
  374. package/lib/test-utils/index.ts +0 -4
  375. package/lib/test-utils/mockFactories.ts +12 -18
  376. package/lib/test-utils/mocks.ts +4 -2
  377. package/package.json +1 -1
  378. package/scripts/build-meno-filter.ts +1 -4
  379. package/vite.config.ts +4 -4
  380. package/dist/chunks/chunk-2MHDV5BF.js.map +0 -7
  381. package/dist/chunks/chunk-2QK6U5UK.js.map +0 -7
  382. package/dist/chunks/chunk-7NIC4I3V.js.map +0 -7
  383. package/dist/chunks/chunk-A725KYFK.js.map +0 -7
  384. package/dist/chunks/chunk-AZQYF6KE.js.map +0 -7
  385. package/dist/chunks/chunk-CXCBV2M7.js.map +0 -7
  386. package/dist/chunks/chunk-EDQSMAMP.js.map +0 -7
  387. package/dist/chunks/chunk-H4JSCDNW.js.map +0 -7
  388. package/dist/chunks/chunk-HNLUO36W.js.map +0 -7
  389. package/dist/chunks/chunk-I7YIGZXT.js.map +0 -7
  390. package/dist/chunks/chunk-J23ZX5AP.js +0 -241
  391. package/dist/chunks/chunk-J23ZX5AP.js.map +0 -7
  392. package/dist/chunks/chunk-LHLHPYSP.js.map +0 -7
  393. package/dist/chunks/chunk-LPVETICS.js.map +0 -7
  394. package/dist/chunks/chunk-UB44F4Z2.js.map +0 -7
  395. package/dist/chunks/chunk-WQFG7PAH.js.map +0 -7
  396. package/dist/chunks/configService-R3OGU2UD.js +0 -13
  397. /package/dist/chunks/{configService-R3OGU2UD.js.map → configService-PRJZF7Y6.js.map} +0 -0
  398. /package/dist/chunks/{constants-GWBAD66U.js.map → constants-KIQEYMAM.js.map} +0 -0
  399. /package/dist/chunks/{fs-JGINUXGL.js.map → fs-ZI5JEU7V.js.map} +0 -0
package/build-next.ts ADDED
@@ -0,0 +1,1374 @@
1
+ /**
2
+ * Next.js Export Build Script
3
+ * Renders all pages via the SSR pipeline, then wraps them as Next.js App Router
4
+ * server components (`app/<route>/page.tsx`) with a shared root layout, global
5
+ * CSS, and dynamic `[slug]` routes (via `generateStaticParams`) for CMS pages.
6
+ *
7
+ * Mirrors `buildAstroProject` but emits a Next.js 15 static export instead of
8
+ * an Astro project. The SSR HTML is embedded into each page via React's
9
+ * `dangerouslySetInnerHTML`, matching the Astro export's `<Fragment set:html>`
10
+ * fallback path. Inline `<script>` tags inside the SSR HTML run when the
11
+ * browser parses the statically exported `.html` file.
12
+ */
13
+
14
+ import { existsSync, readdirSync, mkdirSync, rmSync, statSync, copyFileSync, writeFileSync } from 'fs';
15
+ import { writeFile, readFile } from 'fs/promises';
16
+ import { join } from 'path';
17
+ import { createHash } from 'crypto';
18
+ import {
19
+ loadJSONFile,
20
+ loadComponentDirectory,
21
+ mapPageNameToPath,
22
+ parseJSON,
23
+ loadI18nConfig,
24
+ } from './lib/server/jsonLoader';
25
+ import { projectPaths } from './lib/server/projectContext';
26
+ import { loadProjectConfig, generateFontCSS, generateFontPreloadTags } from './lib/shared/fontLoader';
27
+ import { FileSystemCMSProvider } from './lib/server/providers/fileSystemCMSProvider';
28
+ import { CMSService } from './lib/server/services/cmsService';
29
+ import { isI18nValue, resolveI18nValue } from './lib/shared/i18n';
30
+ import type { ComponentDefinition, JSONPage, CMSSchema, CMSItem, I18nConfig } from './lib/shared/types';
31
+ import { isItemDraftForLocale } from './lib/shared/types';
32
+ import type { SlugMap } from './lib/shared/slugTranslator';
33
+ import { renderPageSSR } from './lib/server/ssr/ssrRenderer';
34
+ import { generateThemeColorVariablesCSS, generateVariablesCSS } from './lib/server/cssGenerator';
35
+ import { colorService } from './lib/server/services/ColorService';
36
+ import { variableService } from './lib/server/services/VariableService';
37
+ import { configService } from './lib/server/services/configService';
38
+ import { loadBreakpointConfig, loadIconsConfig } from './lib/server/jsonLoader';
39
+ import type { InteractiveStyles } from './lib/shared/types/styles';
40
+ import {
41
+ collectComponentLibraries,
42
+ filterLibrariesByContext,
43
+ mergeLibraries,
44
+ generateLibraryTags,
45
+ } from './lib/shared/libraryLoader';
46
+ import { migrateTemplatesDirectory } from './lib/server/migrateTemplates';
47
+ import { collectAllMappingClasses } from './lib/server/astro/cssCollector';
48
+ import {
49
+ generateAllInteractiveCSS,
50
+ generateUtilityCSS,
51
+ extractUtilityClassesFromHTML,
52
+ } from './lib/shared/cssGeneration';
53
+ import { needsFormHandler, formHandlerScript } from './lib/client/scripts/formHandler';
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Helpers
57
+ // ---------------------------------------------------------------------------
58
+
59
+ function hashContent(content: string): string {
60
+ return createHash('sha256').update(content).digest('hex').slice(0, 8);
61
+ }
62
+
63
+ function writePageScript(javascript: string | undefined, scriptsDir: string): string[] {
64
+ if (!javascript) return [];
65
+ const hash = hashContent(javascript);
66
+ const scriptFile = `${hash}.js`;
67
+ if (!existsSync(scriptsDir)) {
68
+ mkdirSync(scriptsDir, { recursive: true });
69
+ }
70
+ const fullScriptPath = join(scriptsDir, scriptFile);
71
+ if (!existsSync(fullScriptPath)) {
72
+ writeFileSync(fullScriptPath, javascript, 'utf-8');
73
+ }
74
+ return [`/_scripts/${scriptFile}`];
75
+ }
76
+
77
+ function copyDirectory(src: string, dest: string, filter?: (filename: string) => boolean): void {
78
+ if (!existsSync(src)) return;
79
+ if (!existsSync(dest)) mkdirSync(dest, { recursive: true });
80
+ const files = readdirSync(src);
81
+ for (const file of files) {
82
+ if (filter && !filter(file)) continue;
83
+ const srcPath = join(src, file);
84
+ const destPath = join(dest, file);
85
+ const stat = statSync(srcPath);
86
+ if (stat.isDirectory()) copyDirectory(srcPath, destPath, filter);
87
+ else copyFileSync(srcPath, destPath);
88
+ }
89
+ }
90
+
91
+ function isCMSPage(pageData: JSONPage): boolean {
92
+ return pageData.meta?.source === 'cms' && !!pageData.meta?.cms;
93
+ }
94
+
95
+ /**
96
+ * Build URL path for a CMS item based on the URL pattern
97
+ */
98
+ function buildCMSItemPath(
99
+ urlPattern: string,
100
+ item: CMSItem,
101
+ slugField: string,
102
+ locale: string,
103
+ i18nConfig: I18nConfig,
104
+ ): string {
105
+ let slug = item[slugField] ?? item._slug ?? item._id;
106
+ if (isI18nValue(slug)) {
107
+ slug = resolveI18nValue(slug, locale, i18nConfig) as string;
108
+ }
109
+ return urlPattern.replace('{{slug}}', String(slug));
110
+ }
111
+
112
+ /**
113
+ * Recursively scan a directory for .json files, returning relative paths.
114
+ */
115
+ function scanJSONFiles(dir: string, prefix: string = ''): string[] {
116
+ const results: string[] = [];
117
+ if (!existsSync(dir)) return results;
118
+ const entries = readdirSync(dir, { withFileTypes: true });
119
+ for (const entry of entries) {
120
+ if (entry.isFile() && entry.name.endsWith('.json')) {
121
+ results.push(prefix ? `${prefix}/${entry.name}` : entry.name);
122
+ } else if (entry.isDirectory()) {
123
+ results.push(...scanJSONFiles(join(dir, entry.name), prefix ? `${prefix}/${entry.name}` : entry.name));
124
+ }
125
+ }
126
+ return results;
127
+ }
128
+
129
+ /**
130
+ * Escape a string for use inside a JS backtick template literal.
131
+ * The output is wrapped in backticks by callers.
132
+ */
133
+ function escapeTemplateLiteral(s: string): string {
134
+ return s
135
+ .replace(/\\/g, '\\\\')
136
+ .replace(/`/g, '\\`')
137
+ .replace(/\$\{/g, '\\${')
138
+ .replace(/\u2028/g, '\\u2028')
139
+ .replace(/\u2029/g, '\\u2029');
140
+ }
141
+
142
+ /**
143
+ * Escape a string for use inside a JS single-quoted string literal.
144
+ */
145
+ function escapeSingleQuoted(s: string): string {
146
+ return s
147
+ .replace(/\\/g, '\\\\')
148
+ .replace(/'/g, "\\'")
149
+ .replace(/\n/g, '\\n')
150
+ .replace(/\r/g, '\\r')
151
+ .replace(/\u2028/g, '\\u2028')
152
+ .replace(/\u2029/g, '\\u2029');
153
+ }
154
+
155
+ /**
156
+ * Build the URL path → `app/<route>/page.tsx` mapping for a given URL.
157
+ * The home route lives at `app/page.tsx`; everything else lives at
158
+ * `app/<segments>/page.tsx`. Special characters in path segments are
159
+ * left as-is so the URL ↔ file mapping matches Next.js conventions.
160
+ */
161
+ function urlPathToAppRoute(urlPath: string): { dir: string; isRoot: boolean } {
162
+ if (urlPath === '/' || urlPath === '') {
163
+ return { dir: '', isRoot: true };
164
+ }
165
+ const trimmed = urlPath.replace(/^\/+/, '').replace(/\/+$/, '');
166
+ return { dir: trimmed, isRoot: false };
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Types
171
+ // ---------------------------------------------------------------------------
172
+
173
+ interface PageRenderResult {
174
+ /** Body HTML (inner content, no DOCTYPE wrapper) */
175
+ html: string;
176
+ /** Head meta tags HTML string */
177
+ meta: string;
178
+ /** Page title */
179
+ title: string;
180
+ /** Extracted JavaScript (if any) */
181
+ javascript: string;
182
+ /** Per-component CSS */
183
+ componentCSS?: string;
184
+ /** Locale used */
185
+ locale: string;
186
+ /** Interactive styles (hover, focus, etc.) */
187
+ interactiveStylesMap: Map<string, InteractiveStyles>;
188
+ /** The URL path this page will live at */
189
+ urlPath: string;
190
+ /** Original page data */
191
+ pageData?: JSONPage;
192
+ /** Page name without extension */
193
+ pageName?: string;
194
+ }
195
+
196
+ interface NextBuildStats {
197
+ pages: number;
198
+ cmsPages: number;
199
+ collections: number;
200
+ errors: number;
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Head <meta> tag converter
205
+ // ---------------------------------------------------------------------------
206
+
207
+ /**
208
+ * Parse the SSR `meta` HTML string into JSX-ready React element source.
209
+ *
210
+ * `generateMetaTags()` emits one well-formed self-closing or paired tag per
211
+ * line: `<title>...</title>`, `<meta ... />`, `<link ... />`. React 19 hoists
212
+ * `<title>`, `<meta>`, and `<link>` JSX elements to `<head>` automatically, so
213
+ * we just need to emit those tags as React-compatible JSX.
214
+ *
215
+ * Returns a JSX fragment string (no surrounding braces) suitable for inlining
216
+ * into a `.tsx` file.
217
+ */
218
+ function metaHtmlToJSX(metaHtml: string): string {
219
+ if (!metaHtml || !metaHtml.trim()) return '';
220
+ const lines = metaHtml
221
+ .split(/\r?\n/)
222
+ .map((l) => l.trim())
223
+ .filter(Boolean);
224
+
225
+ const elements: string[] = [];
226
+ for (const line of lines) {
227
+ // <title>foo</title>
228
+ const titleMatch = line.match(/^<title>([\s\S]*?)<\/title>$/);
229
+ if (titleMatch) {
230
+ elements.push(`<title>{${JSON.stringify(decodeBasicEntities(titleMatch[1]))}}</title>`);
231
+ continue;
232
+ }
233
+ // <meta ... /> or <link ... />
234
+ const selfMatch = line.match(/^<(meta|link)\s+([\s\S]*?)\s*\/?>$/);
235
+ if (selfMatch) {
236
+ const tagName = selfMatch[1];
237
+ const attrsSrc = selfMatch[2];
238
+ const attrs = parseAttrs(attrsSrc);
239
+ const attrParts = Object.entries(attrs)
240
+ .map(([k, v]) => `${jsxAttrName(k)}=${JSON.stringify(v)}`)
241
+ .join(' ');
242
+ elements.push(`<${tagName} ${attrParts} />`);
243
+ }
244
+ // Unknown tag — skip rather than crash the build.
245
+ }
246
+ return elements.join('\n ');
247
+ }
248
+
249
+ /**
250
+ * Map HTML attribute names to their JSX equivalents. `<meta>` / `<link>` /
251
+ * `<title>` are the only emit targets so the rename set stays small —
252
+ * extend this lookup if `generateMetaTags()` starts emitting new attrs.
253
+ */
254
+ const JSX_ATTR_NAMES: Record<string, string> = {
255
+ 'http-equiv': 'httpEquiv',
256
+ hreflang: 'hrefLang',
257
+ crossorigin: 'crossOrigin',
258
+ referrerpolicy: 'referrerPolicy',
259
+ imagesrcset: 'imageSrcSet',
260
+ imagesizes: 'imageSizes',
261
+ fetchpriority: 'fetchPriority',
262
+ class: 'className',
263
+ for: 'htmlFor',
264
+ charset: 'charSet',
265
+ };
266
+
267
+ function jsxAttrName(name: string): string {
268
+ return JSX_ATTR_NAMES[name] ?? name;
269
+ }
270
+
271
+ /**
272
+ * Parse a flat attribute list like `name="og:title" content="Hello"` into a
273
+ * record. Values must be double-quoted; this matches what `generateMetaTags()`
274
+ * emits today.
275
+ */
276
+ function parseAttrs(src: string): Record<string, string> {
277
+ const out: Record<string, string> = {};
278
+ const re = /([a-zA-Z_:][\w:.-]*)\s*=\s*"([^"]*)"/g;
279
+ let match: RegExpExecArray | null;
280
+ while ((match = re.exec(src)) !== null) {
281
+ out[match[1]] = decodeBasicEntities(match[2]);
282
+ }
283
+ return out;
284
+ }
285
+
286
+ /**
287
+ * Decode the basic HTML entities that `escapeHtml()` in metaTagGenerator emits
288
+ * (`&amp;`, `&quot;`, `&lt;`, `&gt;`, `&#39;`). The output is then re-escaped
289
+ * by `JSON.stringify` when we emit it as a JSX string attribute, so this
290
+ * round-trip is safe.
291
+ */
292
+ function decodeBasicEntities(s: string): string {
293
+ return s
294
+ .replace(/&quot;/g, '"')
295
+ .replace(/&#39;/g, "'")
296
+ .replace(/&lt;/g, '<')
297
+ .replace(/&gt;/g, '>')
298
+ .replace(/&amp;/g, '&');
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // page.tsx emitter
303
+ // ---------------------------------------------------------------------------
304
+
305
+ /**
306
+ * Emit a Next.js App Router page component that renders the SSR HTML.
307
+ * The page is a server component (no `'use client'`) so the inline scripts
308
+ * inside `__html` execute as part of the statically exported HTML file.
309
+ */
310
+ function emitNextPage(options: {
311
+ html: string;
312
+ meta: string;
313
+ title: string;
314
+ locale: string;
315
+ theme: string;
316
+ fontPreloads: string;
317
+ libraryTags: { headCSS?: string; headJS?: string; bodyEndJS?: string };
318
+ scriptPaths: string[];
319
+ customCode: { head?: string; bodyStart?: string; bodyEnd?: string };
320
+ iconTagsHtml: string;
321
+ formHandlerNeeded: boolean;
322
+ }): string {
323
+ const {
324
+ html,
325
+ meta,
326
+ title,
327
+ locale,
328
+ theme,
329
+ fontPreloads,
330
+ libraryTags,
331
+ scriptPaths,
332
+ customCode,
333
+ iconTagsHtml,
334
+ formHandlerNeeded,
335
+ } = options;
336
+
337
+ const metaJSX = metaHtmlToJSX(meta);
338
+
339
+ // Combine all head-targeted raw HTML strings into one block. Order matches
340
+ // BaseLayout.astro: icons, font preloads, library head CSS/JS, custom head.
341
+ const headHtmlBlocks: string[] = [];
342
+ if (iconTagsHtml) headHtmlBlocks.push(iconTagsHtml);
343
+ if (fontPreloads) headHtmlBlocks.push(fontPreloads);
344
+ if (libraryTags.headCSS) headHtmlBlocks.push(libraryTags.headCSS);
345
+ if (libraryTags.headJS) headHtmlBlocks.push(libraryTags.headJS);
346
+ if (customCode.head) headHtmlBlocks.push(customCode.head);
347
+ const headHtml = headHtmlBlocks.join('\n');
348
+
349
+ // Body-end raw HTML: scripts + library body-end + custom body-end + form
350
+ // handler. These run after the SSR HTML so they can attach behavior to
351
+ // server-rendered nodes (matches BaseLayout.astro ordering).
352
+ const scriptTags = scriptPaths.map((s) => `<script src="${s}" defer></script>`).join('\n');
353
+ const bodyEndBlocks: string[] = [];
354
+ if (scriptTags) bodyEndBlocks.push(scriptTags);
355
+ if (libraryTags.bodyEndJS) bodyEndBlocks.push(libraryTags.bodyEndJS);
356
+ if (customCode.bodyEnd) bodyEndBlocks.push(customCode.bodyEnd);
357
+ if (formHandlerNeeded) bodyEndBlocks.push(`<script>${formHandlerScript}</script>`);
358
+ const bodyEndHtml = bodyEndBlocks.join('\n');
359
+
360
+ const bodyStartHtml = customCode.bodyStart || '';
361
+
362
+ // Emit the body HTML and any raw HTML head/body fragments as JS template
363
+ // literals so embedded backticks and ${...} sequences in the SSR output are
364
+ // neutralized. The runtime reads them back as plain strings.
365
+ return `// Auto-generated by meno-core/build-next. Do not edit.
366
+ import RawHead from '../components/RawHead';
367
+
368
+ const TITLE = ${JSON.stringify(title)};
369
+ const LOCALE = ${JSON.stringify(locale)};
370
+ const THEME = ${JSON.stringify(theme)};
371
+ const HEAD_HTML = \`${escapeTemplateLiteral(headHtml)}\`;
372
+ const BODY_START_HTML = \`${escapeTemplateLiteral(bodyStartHtml)}\`;
373
+ const PAGE_HTML = \`${escapeTemplateLiteral(html)}\`;
374
+ const BODY_END_HTML = \`${escapeTemplateLiteral(bodyEndHtml)}\`;
375
+
376
+ export const metadata = {
377
+ title: TITLE,
378
+ };
379
+
380
+ export default function Page() {
381
+ return (
382
+ <>
383
+ ${metaJSX || ''}
384
+ <RawHead html={HEAD_HTML} locale={LOCALE} theme={THEME} />
385
+ {BODY_START_HTML ? <div data-meno-body-start dangerouslySetInnerHTML={{ __html: BODY_START_HTML }} /> : null}
386
+ <div id="root" dangerouslySetInnerHTML={{ __html: PAGE_HTML }} />
387
+ {BODY_END_HTML ? <div data-meno-body-end dangerouslySetInnerHTML={{ __html: BODY_END_HTML }} /> : null}
388
+ </>
389
+ );
390
+ }
391
+ `;
392
+ }
393
+
394
+ /**
395
+ * Emit a CMS-template page (`app/<prefix>/[slug]/page.tsx`) that uses
396
+ * `generateStaticParams` to pre-render one HTML file per CMS item.
397
+ *
398
+ * Each rendered page is just an SSR HTML wrap (same shape as `emitNextPage`),
399
+ * keyed by the item slug + locale. We pre-render the HTML for every
400
+ * combination at build time and embed it in a lookup table in the page module
401
+ * so `generateStaticParams` + the default export work together.
402
+ */
403
+ function emitNextCMSPage(options: {
404
+ slugs: string[];
405
+ perSlugData: Record<
406
+ string,
407
+ {
408
+ html: string;
409
+ meta: string;
410
+ title: string;
411
+ scriptPaths: string[];
412
+ }
413
+ >;
414
+ locale: string;
415
+ theme: string;
416
+ fontPreloads: string;
417
+ libraryTags: { headCSS?: string; headJS?: string; bodyEndJS?: string };
418
+ customCode: { head?: string; bodyStart?: string; bodyEnd?: string };
419
+ iconTagsHtml: string;
420
+ formHandlerNeeded: boolean;
421
+ }): string {
422
+ const { slugs, perSlugData, locale, theme, fontPreloads, libraryTags, customCode, iconTagsHtml, formHandlerNeeded } =
423
+ options;
424
+
425
+ const headHtmlBlocks: string[] = [];
426
+ if (iconTagsHtml) headHtmlBlocks.push(iconTagsHtml);
427
+ if (fontPreloads) headHtmlBlocks.push(fontPreloads);
428
+ if (libraryTags.headCSS) headHtmlBlocks.push(libraryTags.headCSS);
429
+ if (libraryTags.headJS) headHtmlBlocks.push(libraryTags.headJS);
430
+ if (customCode.head) headHtmlBlocks.push(customCode.head);
431
+ const headHtml = headHtmlBlocks.join('\n');
432
+
433
+ const bodyStartHtml = customCode.bodyStart || '';
434
+ const trailingFormHandler = formHandlerNeeded ? `<script>${formHandlerScript}</script>` : '';
435
+
436
+ // Build the lookup map: slug → { html, metaJsx, title, bodyEndHtml }
437
+ const entries: string[] = [];
438
+ for (const slug of slugs) {
439
+ const data = perSlugData[slug];
440
+ if (!data) continue;
441
+ const scriptTags = data.scriptPaths.map((s) => `<script src="${s}" defer></script>`).join('\n');
442
+ const bodyEndBlocks: string[] = [];
443
+ if (scriptTags) bodyEndBlocks.push(scriptTags);
444
+ if (libraryTags.bodyEndJS) bodyEndBlocks.push(libraryTags.bodyEndJS);
445
+ if (customCode.bodyEnd) bodyEndBlocks.push(customCode.bodyEnd);
446
+ if (trailingFormHandler) bodyEndBlocks.push(trailingFormHandler);
447
+ const bodyEndHtml = bodyEndBlocks.join('\n');
448
+
449
+ entries.push(
450
+ ` ${JSON.stringify(slug)}: {
451
+ title: ${JSON.stringify(data.title)},
452
+ metaHtml: \`${escapeTemplateLiteral(data.meta)}\`,
453
+ html: \`${escapeTemplateLiteral(data.html)}\`,
454
+ bodyEndHtml: \`${escapeTemplateLiteral(bodyEndHtml)}\`,
455
+ }`,
456
+ );
457
+ }
458
+
459
+ return `// Auto-generated by meno-core/build-next. Do not edit.
460
+ import RawHead from '${cmsRawHeadImport(options)}';
461
+ import MetaTags from '${cmsMetaTagsImport(options)}';
462
+
463
+ const LOCALE = ${JSON.stringify(locale)};
464
+ const THEME = ${JSON.stringify(theme)};
465
+ const HEAD_HTML = \`${escapeTemplateLiteral(headHtml)}\`;
466
+ const BODY_START_HTML = \`${escapeTemplateLiteral(bodyStartHtml)}\`;
467
+
468
+ type Entry = {
469
+ title: string;
470
+ metaHtml: string;
471
+ html: string;
472
+ bodyEndHtml: string;
473
+ };
474
+
475
+ const ENTRIES: Record<string, Entry> = {
476
+ ${entries.join(',\n')}
477
+ };
478
+
479
+ export function generateStaticParams() {
480
+ return Object.keys(ENTRIES).map((slug) => ({ slug }));
481
+ }
482
+
483
+ export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
484
+ const { slug } = await params;
485
+ const entry = ENTRIES[slug];
486
+ return entry ? { title: entry.title } : {};
487
+ }
488
+
489
+ export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
490
+ const { slug } = await params;
491
+ const entry = ENTRIES[slug];
492
+ if (!entry) return null;
493
+ return (
494
+ <>
495
+ <MetaTags html={entry.metaHtml} />
496
+ <RawHead html={HEAD_HTML} locale={LOCALE} theme={THEME} />
497
+ {BODY_START_HTML ? <div data-meno-body-start dangerouslySetInnerHTML={{ __html: BODY_START_HTML }} /> : null}
498
+ <div id="root" dangerouslySetInnerHTML={{ __html: entry.html }} />
499
+ {entry.bodyEndHtml ? <div data-meno-body-end dangerouslySetInnerHTML={{ __html: entry.bodyEndHtml }} /> : null}
500
+ </>
501
+ );
502
+ }
503
+ `;
504
+ }
505
+
506
+ function cmsRawHeadImport(_options: {
507
+ /* keep for future depth handling */
508
+ }): string {
509
+ // CMS files live two levels under app/ (e.g. app/blog/[slug]/page.tsx), so
510
+ // the relative path to app/components/RawHead.tsx is "../../components/RawHead".
511
+ // When the URL pattern's path prefix is empty (e.g. "/[slug]"), they live at
512
+ // app/[slug]/page.tsx — one level deep. The caller passes the correct depth
513
+ // via the file location; we always emit "../../components/RawHead" because
514
+ // build-next places CMS pages at depth 2 (see emitCMSPages).
515
+ return '../../components/RawHead';
516
+ }
517
+
518
+ function cmsMetaTagsImport(_options: {
519
+ /* placeholder */
520
+ }): string {
521
+ return '../../components/MetaTags';
522
+ }
523
+
524
+ // ---------------------------------------------------------------------------
525
+ // Main export
526
+ // ---------------------------------------------------------------------------
527
+
528
+ export async function buildNextProject(projectRoot?: string, outputDir?: string): Promise<NextBuildStats> {
529
+ // ----------------------------------------------------------
530
+ // 1. Setup: load project configuration
531
+ // ----------------------------------------------------------
532
+ configService.reset();
533
+
534
+ const projectConfig = await loadProjectConfig();
535
+ const siteUrl = (projectConfig as { siteUrl?: string }).siteUrl?.replace(/\/$/, '') || '';
536
+
537
+ const i18nConfig = await loadI18nConfig();
538
+
539
+ await migrateTemplatesDirectory();
540
+
541
+ const { components, warnings, errors: compErrors } = await loadComponentDirectory(projectPaths.components());
542
+ const globalComponents: Record<string, ComponentDefinition> = {};
543
+ components.forEach((value, key) => {
544
+ globalComponents[key] = value;
545
+ });
546
+ for (const w of warnings) console.warn(` Warning: ${w}`);
547
+ for (const e of compErrors) console.error(` Error: ${e}`);
548
+
549
+ const cmsProvider = new FileSystemCMSProvider(projectPaths.templates(), projectPaths.cms());
550
+ const cmsService = new CMSService(cmsProvider);
551
+ await cmsService.initialize();
552
+
553
+ const themeConfig = await colorService.loadThemeConfig();
554
+ const variablesConfig = await variableService.loadConfig();
555
+ const breakpoints = await loadBreakpointConfig();
556
+
557
+ await configService.load();
558
+ const responsiveScales = configService.getResponsiveScales();
559
+ const globalLibraries = configService.getLibraries();
560
+ const componentLibraries = collectComponentLibraries(globalComponents);
561
+
562
+ // ----------------------------------------------------------
563
+ // 2. Clean and create output directory
564
+ // ----------------------------------------------------------
565
+ const outDir = outputDir || join(projectPaths.project, 'next-export');
566
+
567
+ if (existsSync(outDir)) {
568
+ rmSync(outDir, { recursive: true, force: true });
569
+ }
570
+ mkdirSync(outDir, { recursive: true });
571
+
572
+ const appDir = join(outDir, 'app');
573
+ const componentsDir = join(appDir, 'components');
574
+ const publicDir = join(outDir, 'public');
575
+ const scriptsDir = join(publicDir, '_scripts');
576
+ for (const d of [appDir, componentsDir, publicDir]) {
577
+ mkdirSync(d, { recursive: true });
578
+ }
579
+
580
+ // ----------------------------------------------------------
581
+ // 3. Scan pages and gather slug mappings
582
+ // ----------------------------------------------------------
583
+ const pagesDir = projectPaths.pages();
584
+ if (!existsSync(pagesDir)) {
585
+ console.error('Pages directory not found!');
586
+ return { pages: 0, cmsPages: 0, collections: 0, errors: 1 };
587
+ }
588
+
589
+ const pageFiles = scanJSONFiles(pagesDir);
590
+ if (pageFiles.length === 0) {
591
+ console.warn('No pages found in ./pages directory');
592
+ return { pages: 0, cmsPages: 0, collections: 0, errors: 0 };
593
+ }
594
+
595
+ const slugMappings: SlugMap[] = [];
596
+ for (const file of pageFiles) {
597
+ const pageName = file.replace('.json', '');
598
+ const basePath = mapPageNameToPath(pageName);
599
+ const pageContent = await loadJSONFile(join(pagesDir, file));
600
+ if (!pageContent) continue;
601
+ try {
602
+ const pageData = parseJSON<JSONPage>(pageContent);
603
+ if (pageData.meta?.slugs) {
604
+ const pageId = basePath === '/' ? 'index' : basePath.substring(1);
605
+ slugMappings.push({ pageId, slugs: pageData.meta.slugs });
606
+ }
607
+ } catch {
608
+ /* ignore parse errors in first pass */
609
+ }
610
+ }
611
+
612
+ // ----------------------------------------------------------
613
+ // 4. Render regular pages
614
+ // ----------------------------------------------------------
615
+ const allResults: PageRenderResult[] = [];
616
+ const allInteractiveStyles = new Map<string, InteractiveStyles>();
617
+ const allComponentCSS = new Set<string>();
618
+ const allUtilityClasses = new Set<string>();
619
+ const jsContents = new Map<string, string>();
620
+ let errorCount = 0;
621
+ let projectNeedsFormHandler = false;
622
+
623
+ function mergeInteractiveStyles(source: Map<string, InteractiveStyles>): void {
624
+ for (const [key, value] of source) {
625
+ if (!allInteractiveStyles.has(key)) {
626
+ allInteractiveStyles.set(key, value);
627
+ }
628
+ }
629
+ }
630
+
631
+ function recordRender(
632
+ result: {
633
+ html: string;
634
+ meta: string;
635
+ title: string;
636
+ javascript: string;
637
+ componentCSS?: string;
638
+ locale: string;
639
+ interactiveStylesMap: Map<string, InteractiveStyles>;
640
+ },
641
+ urlPath: string,
642
+ pageData?: JSONPage,
643
+ pageName?: string,
644
+ ): void {
645
+ mergeInteractiveStyles(result.interactiveStylesMap);
646
+ if (result.componentCSS) allComponentCSS.add(result.componentCSS);
647
+ for (const c of extractUtilityClassesFromHTML(result.html)) {
648
+ allUtilityClasses.add(c);
649
+ }
650
+ if (result.javascript) {
651
+ const hash = hashContent(result.javascript);
652
+ if (!jsContents.has(hash)) {
653
+ jsContents.set(hash, result.javascript);
654
+ }
655
+ }
656
+ if (!projectNeedsFormHandler && needsFormHandler(result.html)) {
657
+ projectNeedsFormHandler = true;
658
+ }
659
+ allResults.push({
660
+ html: result.html,
661
+ meta: result.meta,
662
+ title: result.title,
663
+ javascript: result.javascript,
664
+ componentCSS: result.componentCSS,
665
+ locale: result.locale,
666
+ interactiveStylesMap: result.interactiveStylesMap,
667
+ urlPath,
668
+ pageData,
669
+ pageName,
670
+ });
671
+ }
672
+
673
+ for (const file of pageFiles) {
674
+ const pageName = file.replace('.json', '');
675
+ const basePath = mapPageNameToPath(pageName);
676
+ const pageContent = await loadJSONFile(join(pagesDir, file));
677
+
678
+ if (!pageContent) {
679
+ console.warn(` Skipping ${basePath} (empty file)`);
680
+ errorCount++;
681
+ continue;
682
+ }
683
+
684
+ try {
685
+ const pageData = parseJSON<JSONPage>(pageContent);
686
+
687
+ const isDevBuild = process.env.MENO_DEV_BUILD === 'true';
688
+ if (pageData.meta?.draft === true && !isDevBuild) {
689
+ continue;
690
+ }
691
+
692
+ const slugs = pageData.meta?.slugs;
693
+
694
+ for (const localeConfig of i18nConfig.locales) {
695
+ const locale = localeConfig.code;
696
+ const isDefault = locale === i18nConfig.defaultLocale;
697
+
698
+ let slug: string;
699
+ if (slugs && slugs[locale]) {
700
+ slug = slugs[locale];
701
+ } else if (basePath === '/') {
702
+ slug = '';
703
+ } else {
704
+ slug = basePath.substring(1);
705
+ }
706
+
707
+ const urlPath = isDefault
708
+ ? slug === ''
709
+ ? '/'
710
+ : `/${slug}`
711
+ : slug === ''
712
+ ? `/${locale}`
713
+ : `/${locale}/${slug}`;
714
+
715
+ const result = await renderPageSSR(
716
+ pageData,
717
+ globalComponents,
718
+ urlPath,
719
+ siteUrl,
720
+ locale,
721
+ i18nConfig,
722
+ slugMappings,
723
+ undefined,
724
+ cmsService,
725
+ true,
726
+ );
727
+
728
+ recordRender(result, urlPath, pageData, pageName);
729
+ }
730
+ } catch (error) {
731
+ const err = error as { message?: string };
732
+ console.error(` Error rendering ${basePath}:`, err?.message || error);
733
+ errorCount++;
734
+ }
735
+ }
736
+
737
+ // ----------------------------------------------------------
738
+ // 5. Pre-compute shared layout dependencies
739
+ // ----------------------------------------------------------
740
+ const fontPreloads = generateFontPreloadTags();
741
+ const mergedLibraries = mergeLibraries(globalLibraries, componentLibraries);
742
+ const buildLibraries = filterLibrariesByContext(mergedLibraries, 'build');
743
+
744
+ const inlineContents = new Map<string, string>();
745
+ const localLibsToCopy: string[] = [];
746
+ for (const css of buildLibraries.css || []) {
747
+ if (!css.url.startsWith('/')) continue;
748
+ const shouldInline = css.inline !== false;
749
+ const relPath = css.url.slice(1);
750
+ const srcPath = join(projectPaths.project, relPath);
751
+ if (!existsSync(srcPath)) continue;
752
+ if (shouldInline) {
753
+ try {
754
+ inlineContents.set(css.url, await readFile(srcPath, 'utf-8'));
755
+ } catch {
756
+ localLibsToCopy.push(relPath);
757
+ }
758
+ } else {
759
+ localLibsToCopy.push(relPath);
760
+ }
761
+ }
762
+ for (const js of buildLibraries.js || []) {
763
+ if (js.url.startsWith('/')) {
764
+ const relPath = js.url.slice(1);
765
+ if (existsSync(join(projectPaths.project, relPath))) {
766
+ localLibsToCopy.push(relPath);
767
+ }
768
+ }
769
+ }
770
+ const libraryTags = generateLibraryTags(buildLibraries, inlineContents);
771
+ const defaultTheme = themeConfig.default || 'light';
772
+
773
+ const customCode = configService.getCustomCode();
774
+ const iconsConfig = await loadIconsConfig();
775
+ const hasDarkFavicon = !!(iconsConfig.favicon && iconsConfig.faviconDark);
776
+ const faviconTag = iconsConfig.favicon
777
+ ? `<link rel="icon" href="${iconsConfig.favicon.replace(/"/g, '&quot;')}"${hasDarkFavicon ? ' media="(prefers-color-scheme: light)"' : ''} />`
778
+ : '';
779
+ const faviconDarkTag = iconsConfig.faviconDark
780
+ ? `<link rel="icon" href="${iconsConfig.faviconDark.replace(/"/g, '&quot;')}" media="(prefers-color-scheme: dark)" />`
781
+ : '';
782
+ const appleTouchIconTag = iconsConfig.appleTouchIcon
783
+ ? `<link rel="apple-touch-icon" href="${iconsConfig.appleTouchIcon.replace(/"/g, '&quot;')}" />`
784
+ : '';
785
+ const iconTagsHtml = [faviconTag, faviconDarkTag, appleTouchIconTag].filter(Boolean).join('\n ');
786
+
787
+ const remConversionConfig = configService.getRemConversion();
788
+
789
+ // ----------------------------------------------------------
790
+ // 6. Render CMS template pages
791
+ // ----------------------------------------------------------
792
+ const templatesDir = projectPaths.templates();
793
+ const templateSchemas: CMSSchema[] = [];
794
+ let cmsPageCount = 0;
795
+
796
+ type CMSEmission = {
797
+ schema: CMSSchema;
798
+ locale: string;
799
+ pathPrefix: string;
800
+ isDefaultLocale: boolean;
801
+ perSlugData: Record<string, { html: string; meta: string; title: string; scriptPaths: string[] }>;
802
+ };
803
+ const cmsEmissions: CMSEmission[] = [];
804
+
805
+ if (existsSync(templatesDir)) {
806
+ const templateFiles = readdirSync(templatesDir).filter((f) => f.endsWith('.json'));
807
+
808
+ for (const file of templateFiles) {
809
+ const templateContent = await loadJSONFile(join(templatesDir, file));
810
+ if (!templateContent) continue;
811
+
812
+ try {
813
+ const pageData = parseJSON<JSONPage>(templateContent);
814
+
815
+ const isDevBuild = process.env.MENO_DEV_BUILD === 'true';
816
+ if (pageData.meta?.draft === true && !isDevBuild) {
817
+ continue;
818
+ }
819
+
820
+ if (!isCMSPage(pageData)) {
821
+ console.warn(` ${file} is in templates/ but missing meta.source: "cms"`);
822
+ continue;
823
+ }
824
+
825
+ const cmsSchema = pageData.meta!.cms as CMSSchema;
826
+ templateSchemas.push(cmsSchema);
827
+
828
+ const slugField = cmsSchema.slugField || 'slug';
829
+ const items = await cmsService.queryItems({ collection: cmsSchema.id });
830
+ const urlPatternWithoutSlash = cmsSchema.urlPattern.replace(/^\//, '');
831
+ const slugPlaceholderIdx = urlPatternWithoutSlash.indexOf('{{');
832
+ const pathPrefix =
833
+ slugPlaceholderIdx > 0 ? urlPatternWithoutSlash.substring(0, slugPlaceholderIdx).replace(/\/$/, '') : '';
834
+
835
+ for (const localeEntry of i18nConfig.locales) {
836
+ const localeCode = localeEntry.code;
837
+ const isDefault = localeCode === i18nConfig.defaultLocale;
838
+
839
+ const perSlugData: Record<string, { html: string; meta: string; title: string; scriptPaths: string[] }> = {};
840
+
841
+ for (const item of items) {
842
+ if (!isDevBuild && isItemDraftForLocale(item, localeCode)) continue;
843
+
844
+ const itemPath = buildCMSItemPath(cmsSchema.urlPattern, item, slugField, localeCode, i18nConfig);
845
+ const itemWithUrl: CMSItem = { ...item, _url: itemPath };
846
+
847
+ const fullPath = isDefault ? itemPath : `/${localeCode}${itemPath}`;
848
+
849
+ const result = await renderPageSSR(
850
+ pageData,
851
+ globalComponents,
852
+ fullPath,
853
+ siteUrl,
854
+ localeCode,
855
+ i18nConfig,
856
+ slugMappings,
857
+ { cms: itemWithUrl },
858
+ cmsService,
859
+ true,
860
+ );
861
+
862
+ mergeInteractiveStyles(result.interactiveStylesMap);
863
+ if (result.componentCSS) allComponentCSS.add(result.componentCSS);
864
+ for (const c of extractUtilityClassesFromHTML(result.html)) {
865
+ allUtilityClasses.add(c);
866
+ }
867
+ if (!projectNeedsFormHandler && needsFormHandler(result.html)) {
868
+ projectNeedsFormHandler = true;
869
+ }
870
+
871
+ const scriptPaths: string[] = [];
872
+ if (result.javascript) {
873
+ const hash = hashContent(result.javascript);
874
+ if (!jsContents.has(hash)) jsContents.set(hash, result.javascript);
875
+ scriptPaths.push(`/_scripts/${hash}.js`);
876
+ }
877
+
878
+ // Slug used as the dynamic-segment key (must match
879
+ // `generateStaticParams`'s output for this locale).
880
+ let rawSlug = item[slugField] ?? item._slug ?? item._id;
881
+ if (isI18nValue(rawSlug)) {
882
+ rawSlug = resolveI18nValue(rawSlug, localeCode, i18nConfig) as string;
883
+ }
884
+ const slugKey = String(rawSlug);
885
+
886
+ perSlugData[slugKey] = {
887
+ html: result.html,
888
+ meta: result.meta,
889
+ title: result.title,
890
+ scriptPaths,
891
+ };
892
+
893
+ cmsPageCount++;
894
+ }
895
+
896
+ if (Object.keys(perSlugData).length > 0) {
897
+ cmsEmissions.push({
898
+ schema: cmsSchema,
899
+ locale: localeCode,
900
+ pathPrefix,
901
+ isDefaultLocale: isDefault,
902
+ perSlugData,
903
+ });
904
+ }
905
+ }
906
+ } catch (error) {
907
+ const err = error as { message?: string };
908
+ console.error(` Error processing template ${file}:`, err?.message || error);
909
+ errorCount++;
910
+ }
911
+ }
912
+ }
913
+
914
+ // ----------------------------------------------------------
915
+ // 7. Write extracted scripts to public/_scripts/
916
+ // ----------------------------------------------------------
917
+ for (const [hash, js] of jsContents) {
918
+ writePageScript(js, scriptsDir);
919
+ // touch to mark as referenced (writePageScript handles dedup internally)
920
+ void hash;
921
+ }
922
+
923
+ // ----------------------------------------------------------
924
+ // 8. Generate global CSS
925
+ // ----------------------------------------------------------
926
+ const mappingClasses = collectAllMappingClasses(globalComponents, breakpoints, responsiveScales);
927
+
928
+ const fontCSS = generateFontCSS();
929
+ const themeColorCSS = generateThemeColorVariablesCSS(themeConfig);
930
+ const variablesCSS = generateVariablesCSS(variablesConfig, breakpoints, responsiveScales);
931
+ const componentCSSCombined = Array.from(allComponentCSS).join('\n');
932
+ const utilityCSS =
933
+ allUtilityClasses.size > 0
934
+ ? generateUtilityCSS(allUtilityClasses, breakpoints, responsiveScales, remConversionConfig)
935
+ : '';
936
+ const interactiveStylesCSS =
937
+ allInteractiveStyles.size > 0
938
+ ? generateAllInteractiveCSS(allInteractiveStyles, breakpoints, remConversionConfig, responsiveScales)
939
+ : '';
940
+
941
+ const baseCSS = `@layer base {
942
+ * { margin: 0; padding: 0; box-sizing: border-box; }
943
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; }
944
+ button { background: none; border: none; padding: 0; font: inherit; cursor: pointer; outline: inherit; }
945
+ img { max-width: 100%; height: auto; }
946
+ picture { display: block; }
947
+ .olink { text-decoration: none; display: block; color: inherit; }
948
+ .oem { display: inline-block; }
949
+ }`;
950
+
951
+ // Tailwind v4 inline safelist — every class referenced by a runtime mapping
952
+ // needs to survive purging. The dynamic markup is inside string literals so
953
+ // Tailwind's source scan won't pick them up otherwise.
954
+ const safelistDirectives = Array.from(mappingClasses)
955
+ .map((c) => `@source inline("${c}");`)
956
+ .join('\n');
957
+ const tailwindDirectives = safelistDirectives
958
+ ? `@import "tailwindcss";\n\n${safelistDirectives}`
959
+ : `@import "tailwindcss";`;
960
+
961
+ const globalCSS = [
962
+ tailwindDirectives,
963
+ fontCSS,
964
+ themeColorCSS,
965
+ variablesCSS,
966
+ baseCSS,
967
+ utilityCSS,
968
+ componentCSSCombined,
969
+ interactiveStylesCSS,
970
+ ]
971
+ .filter(Boolean)
972
+ .join('\n\n');
973
+
974
+ await writeFile(join(appDir, 'globals.css'), globalCSS, 'utf-8');
975
+
976
+ // ----------------------------------------------------------
977
+ // 9. Generate shared layout (app/layout.tsx) + helper components
978
+ // ----------------------------------------------------------
979
+ const projectName = (projectConfig as { name?: string } | null | undefined)?.name || 'Site';
980
+ const rootLayoutContent = `// Auto-generated by meno-core/build-next. Do not edit.
981
+ import './globals.css';
982
+
983
+ export const metadata = {
984
+ title: ${JSON.stringify(projectName)},
985
+ };
986
+
987
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
988
+ return (
989
+ <html lang=${JSON.stringify(i18nConfig.defaultLocale)} data-theme=${JSON.stringify(defaultTheme)} suppressHydrationWarning>
990
+ <head>
991
+ <meta charSet="UTF-8" />
992
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
993
+ </head>
994
+ <body>{children}</body>
995
+ </html>
996
+ );
997
+ }
998
+ `;
999
+ await writeFile(join(appDir, 'layout.tsx'), rootLayoutContent, 'utf-8');
1000
+
1001
+ // RawHead: dumps a raw HTML string into <head>. React 19 will hoist
1002
+ // <link>/<meta>/<title> elements emitted via dangerouslySetInnerHTML, but
1003
+ // <style>/<script>/inline tags need a stable target — we mount this as a
1004
+ // hidden marker and let the script in app/layout.tsx (or the browser HTML
1005
+ // parser) handle the rest. For Next.js static export the SSR pipeline
1006
+ // serializes everything verbatim, so a simple inline strategy works.
1007
+ const rawHeadContent = `// Auto-generated by meno-core/build-next. Do not edit.
1008
+ // Renders an arbitrary HTML string. Used for head fragments emitted by the
1009
+ // SSR pipeline (font preloads, icon links, customCode.head, library tags)
1010
+ // and per-locale data-theme/lang updates. Because this is a server component,
1011
+ // the HTML appears in the statically exported file as-is.
1012
+
1013
+ export default function RawHead({ html, locale, theme }: { html: string; locale: string; theme: string }) {
1014
+ return (
1015
+ <>
1016
+ {/* Force locale/theme on <html> via a lightweight inline script. */}
1017
+ <script
1018
+ dangerouslySetInnerHTML={{
1019
+ __html: \`document.documentElement.lang=\${JSON.stringify(locale)};document.documentElement.dataset.theme=\${JSON.stringify(theme)};\`,
1020
+ }}
1021
+ />
1022
+ {html ? <span data-meno-head dangerouslySetInnerHTML={{ __html: html }} style={{ display: 'none' }} /> : null}
1023
+ </>
1024
+ );
1025
+ }
1026
+ `;
1027
+ await writeFile(join(componentsDir, 'RawHead.tsx'), rawHeadContent, 'utf-8');
1028
+
1029
+ // MetaTags: parses an HTML meta-tag string into React elements that React 19
1030
+ // can hoist to <head>. Used by CMS pages where the meta varies per slug.
1031
+ const metaTagsContent = `// Auto-generated by meno-core/build-next. Do not edit.
1032
+ // Parses a string of self-closing <meta>/<link>/<title> tags emitted by
1033
+ // meno-core's generateMetaTags() into React elements. React 19 hoists these
1034
+ // to <head> automatically.
1035
+
1036
+ type MetaEl =
1037
+ | { kind: 'title'; text: string }
1038
+ | { kind: 'meta' | 'link'; attrs: Record<string, string> };
1039
+
1040
+ function decodeEntities(s: string): string {
1041
+ return s
1042
+ .replace(/&quot;/g, '"')
1043
+ .replace(/&#39;/g, "'")
1044
+ .replace(/&lt;/g, '<')
1045
+ .replace(/&gt;/g, '>')
1046
+ .replace(/&amp;/g, '&');
1047
+ }
1048
+
1049
+ function parseAttrs(src: string): Record<string, string> {
1050
+ const out: Record<string, string> = {};
1051
+ const re = /([a-zA-Z_:][\\w:.-]*)\\s*=\\s*"([^"]*)"/g;
1052
+ let match: RegExpExecArray | null;
1053
+ while ((match = re.exec(src)) !== null) {
1054
+ out[match[1]] = decodeEntities(match[2]);
1055
+ }
1056
+ return out;
1057
+ }
1058
+
1059
+ const JSX_ATTR_NAMES: Record<string, string> = {
1060
+ 'http-equiv': 'httpEquiv',
1061
+ 'hreflang': 'hrefLang',
1062
+ 'crossorigin': 'crossOrigin',
1063
+ 'referrerpolicy': 'referrerPolicy',
1064
+ 'imagesrcset': 'imageSrcSet',
1065
+ 'imagesizes': 'imageSizes',
1066
+ 'fetchpriority': 'fetchPriority',
1067
+ 'class': 'className',
1068
+ 'for': 'htmlFor',
1069
+ 'charset': 'charSet',
1070
+ };
1071
+
1072
+ function jsxAttrName(name: string): string {
1073
+ return JSX_ATTR_NAMES[name] ?? name;
1074
+ }
1075
+
1076
+ function parseMeta(html: string): MetaEl[] {
1077
+ if (!html) return [];
1078
+ const lines = html.split(/\\r?\\n/).map((l) => l.trim()).filter(Boolean);
1079
+ const out: MetaEl[] = [];
1080
+ for (const line of lines) {
1081
+ const titleMatch = line.match(/^<title>([\\s\\S]*?)<\\/title>$/);
1082
+ if (titleMatch) {
1083
+ out.push({ kind: 'title', text: decodeEntities(titleMatch[1]) });
1084
+ continue;
1085
+ }
1086
+ const selfMatch = line.match(/^<(meta|link)\\s+([\\s\\S]*?)\\s*\\/?>$/);
1087
+ if (selfMatch) {
1088
+ out.push({ kind: selfMatch[1] as 'meta' | 'link', attrs: parseAttrs(selfMatch[2]) });
1089
+ }
1090
+ }
1091
+ return out;
1092
+ }
1093
+
1094
+ export default function MetaTags({ html }: { html: string }) {
1095
+ const els = parseMeta(html);
1096
+ return (
1097
+ <>
1098
+ {els.map((el, i) => {
1099
+ if (el.kind === 'title') return <title key={i}>{el.text}</title>;
1100
+ const attrs: Record<string, string> = {};
1101
+ for (const [k, v] of Object.entries(el.attrs)) attrs[jsxAttrName(k)] = v;
1102
+ if (el.kind === 'meta') return <meta key={i} {...attrs} />;
1103
+ return <link key={i} {...attrs} />;
1104
+ })}
1105
+ </>
1106
+ );
1107
+ }
1108
+ `;
1109
+ await writeFile(join(componentsDir, 'MetaTags.tsx'), metaTagsContent, 'utf-8');
1110
+
1111
+ // ----------------------------------------------------------
1112
+ // 10. Emit regular page files (app/<path>/page.tsx)
1113
+ // ----------------------------------------------------------
1114
+ for (const result of allResults) {
1115
+ const scriptPaths: string[] = result.javascript ? [`/_scripts/${hashContent(result.javascript)}.js`] : [];
1116
+
1117
+ const route = urlPathToAppRoute(result.urlPath);
1118
+ const targetDir = route.isRoot ? appDir : join(appDir, route.dir);
1119
+ if (!existsSync(targetDir)) {
1120
+ mkdirSync(targetDir, { recursive: true });
1121
+ }
1122
+ const pageFilePath = join(targetDir, 'page.tsx');
1123
+
1124
+ // Patch the RawHead import path so it correctly resolves from the page's
1125
+ // location. app/page.tsx → './components/RawHead'; app/about/page.tsx →
1126
+ // '../components/RawHead'; app/pl/about/page.tsx → '../../components/RawHead'.
1127
+ const depth = route.isRoot ? 0 : route.dir.split('/').length;
1128
+ const rawHeadImportPath = depth === 0 ? './components/RawHead' : '../'.repeat(depth) + 'components/RawHead';
1129
+
1130
+ let content = emitNextPage({
1131
+ html: result.html,
1132
+ meta: result.meta,
1133
+ title: result.title,
1134
+ locale: result.locale,
1135
+ theme: defaultTheme,
1136
+ fontPreloads,
1137
+ libraryTags,
1138
+ scriptPaths,
1139
+ customCode,
1140
+ iconTagsHtml,
1141
+ formHandlerNeeded: projectNeedsFormHandler,
1142
+ });
1143
+ content = content.replace(
1144
+ "import RawHead from '../components/RawHead';",
1145
+ `import RawHead from '${rawHeadImportPath}';`,
1146
+ );
1147
+
1148
+ await writeFile(pageFilePath, content, 'utf-8');
1149
+ }
1150
+
1151
+ // ----------------------------------------------------------
1152
+ // 11. Emit CMS dynamic [slug] pages
1153
+ // ----------------------------------------------------------
1154
+ for (const emission of cmsEmissions) {
1155
+ const { locale, pathPrefix, isDefaultLocale, perSlugData } = emission;
1156
+
1157
+ // Route folder for the [slug] segment:
1158
+ // pattern "/blog/{{slug}}" + default locale → app/blog/[slug]
1159
+ // pattern "/blog/{{slug}}" + locale "pl" → app/pl/blog/[slug]
1160
+ // pattern "/{{slug}}" + default locale → app/[slug]
1161
+ // pattern "/{{slug}}" + locale "pl" → app/pl/[slug]
1162
+ const segments: string[] = [];
1163
+ if (!isDefaultLocale) segments.push(locale);
1164
+ if (pathPrefix) {
1165
+ // Split nested prefixes (e.g. "blog/posts") into individual segments so
1166
+ // depth-based imports below count correctly.
1167
+ for (const part of pathPrefix.split('/').filter(Boolean)) {
1168
+ segments.push(part);
1169
+ }
1170
+ }
1171
+ segments.push('[slug]');
1172
+ const routeDir = join(appDir, ...segments);
1173
+ mkdirSync(routeDir, { recursive: true });
1174
+
1175
+ // Compute the correct relative path back to app/components/* from the
1176
+ // page file. The file lives at app/<segments...>/page.tsx.
1177
+ const depth = segments.length;
1178
+ const upToApp = '../'.repeat(depth);
1179
+
1180
+ let content = emitNextCMSPage({
1181
+ slugs: Object.keys(perSlugData),
1182
+ perSlugData,
1183
+ locale,
1184
+ theme: defaultTheme,
1185
+ fontPreloads,
1186
+ libraryTags,
1187
+ customCode,
1188
+ iconTagsHtml,
1189
+ formHandlerNeeded: projectNeedsFormHandler,
1190
+ });
1191
+
1192
+ // Replace placeholder import paths with the right relative depth. The
1193
+ // initial emit hardcodes '../../components/*' for the depth-2 common case;
1194
+ // patch it here so all depths work uniformly.
1195
+ content = content.replace(
1196
+ "import RawHead from '../../components/RawHead';",
1197
+ `import RawHead from '${upToApp}components/RawHead';`,
1198
+ );
1199
+ content = content.replace(
1200
+ "import MetaTags from '../../components/MetaTags';",
1201
+ `import MetaTags from '${upToApp}components/MetaTags';`,
1202
+ );
1203
+
1204
+ const pageFile = join(routeDir, 'page.tsx');
1205
+ await writeFile(pageFile, content, 'utf-8');
1206
+ }
1207
+
1208
+ // ----------------------------------------------------------
1209
+ // 12. Copy assets
1210
+ // ----------------------------------------------------------
1211
+ const imagesSrcDir = join(projectPaths.project, 'images');
1212
+ if (existsSync(imagesSrcDir)) {
1213
+ copyDirectory(imagesSrcDir, join(publicDir, 'images'));
1214
+ }
1215
+
1216
+ const publicAssetDirs = ['fonts', 'icons', 'videos', 'assets'];
1217
+ for (const dir of publicAssetDirs) {
1218
+ const srcAssetDir = join(projectPaths.project, dir);
1219
+ if (existsSync(srcAssetDir)) {
1220
+ copyDirectory(srcAssetDir, join(publicDir, dir));
1221
+ }
1222
+ }
1223
+
1224
+ const librariesDir = join(projectPaths.project, 'libraries');
1225
+ if (existsSync(librariesDir)) {
1226
+ copyDirectory(librariesDir, join(publicDir, 'libraries'));
1227
+ }
1228
+
1229
+ for (const relPath of localLibsToCopy) {
1230
+ const srcPath = join(projectPaths.project, relPath);
1231
+ const destPath = join(publicDir, relPath);
1232
+ const destDir = destPath.substring(0, destPath.lastIndexOf('/'));
1233
+ if (destDir && !existsSync(destDir)) mkdirSync(destDir, { recursive: true });
1234
+ copyFileSync(srcPath, destPath);
1235
+ }
1236
+
1237
+ // ----------------------------------------------------------
1238
+ // 13. Scaffold files (package.json, next.config.mjs, tsconfig.json, etc.)
1239
+ // ----------------------------------------------------------
1240
+ const packageJson = {
1241
+ name: 'next-export',
1242
+ type: 'module',
1243
+ version: '0.0.1',
1244
+ private: true,
1245
+ scripts: {
1246
+ dev: 'next dev',
1247
+ build: 'next build',
1248
+ start: 'next start',
1249
+ },
1250
+ dependencies: {
1251
+ next: '^15.0.0',
1252
+ react: '^19.0.0',
1253
+ 'react-dom': '^19.0.0',
1254
+ '@tailwindcss/postcss': '^4.0.0',
1255
+ tailwindcss: '^4.0.0',
1256
+ },
1257
+ devDependencies: {
1258
+ '@types/node': '^22.0.0',
1259
+ '@types/react': '^19.0.0',
1260
+ '@types/react-dom': '^19.0.0',
1261
+ typescript: '^5.6.0',
1262
+ },
1263
+ };
1264
+ await writeFile(join(outDir, 'package.json'), JSON.stringify(packageJson, null, 2), 'utf-8');
1265
+
1266
+ // next.config.mjs — static export so the built site is a folder of HTML files,
1267
+ // matching the Astro export's `astro build` behavior.
1268
+ const nextConfig = `/** @type {import('next').NextConfig} */
1269
+ const nextConfig = {
1270
+ output: 'export',${siteUrl ? `\n // Set NEXT_PUBLIC_SITE_URL=${siteUrl} for absolute URLs in metadata.` : ''}
1271
+ images: { unoptimized: true },
1272
+ trailingSlash: false,
1273
+ // The SSR HTML contains arbitrary inline scripts and styles; disable
1274
+ // automatic font/image transforms so they survive unchanged.
1275
+ experimental: {
1276
+ optimizePackageImports: [],
1277
+ },
1278
+ };
1279
+
1280
+ export default nextConfig;
1281
+ `;
1282
+ await writeFile(join(outDir, 'next.config.mjs'), nextConfig, 'utf-8');
1283
+
1284
+ // postcss.config.mjs — Tailwind v4 uses a PostCSS plugin for class generation.
1285
+ const postcssConfig = `export default {
1286
+ plugins: {
1287
+ '@tailwindcss/postcss': {},
1288
+ },
1289
+ };
1290
+ `;
1291
+ await writeFile(join(outDir, 'postcss.config.mjs'), postcssConfig, 'utf-8');
1292
+
1293
+ // tsconfig.json
1294
+ const tsConfig = {
1295
+ compilerOptions: {
1296
+ target: 'ES2022',
1297
+ lib: ['dom', 'dom.iterable', 'esnext'],
1298
+ allowJs: true,
1299
+ skipLibCheck: true,
1300
+ strict: true,
1301
+ noEmit: true,
1302
+ esModuleInterop: true,
1303
+ module: 'esnext',
1304
+ moduleResolution: 'bundler',
1305
+ resolveJsonModule: true,
1306
+ isolatedModules: true,
1307
+ jsx: 'preserve',
1308
+ incremental: true,
1309
+ plugins: [{ name: 'next' }],
1310
+ paths: { '@/*': ['./*'] },
1311
+ },
1312
+ include: ['next-env.d.ts', '**/*.ts', '**/*.tsx', '.next/types/**/*.ts'],
1313
+ exclude: ['node_modules'],
1314
+ };
1315
+ await writeFile(join(outDir, 'tsconfig.json'), JSON.stringify(tsConfig, null, 2), 'utf-8');
1316
+
1317
+ await writeFile(
1318
+ join(outDir, 'next-env.d.ts'),
1319
+ `/// <reference types="next" />\n/// <reference types="next/image-types/global" />\n`,
1320
+ 'utf-8',
1321
+ );
1322
+
1323
+ // .gitignore — keep the exported project clean when committed.
1324
+ const gitignore = ['node_modules', '.next', 'out', '.DS_Store', '*.log'].join('\n') + '\n';
1325
+ await writeFile(join(outDir, '.gitignore'), gitignore, 'utf-8');
1326
+
1327
+ // README — quick start, mirrors what Astro export ships.
1328
+ const readme = `# Next.js export
1329
+
1330
+ This project was generated by Meno from the SSR-rendered HTML of your pages.
1331
+
1332
+ ## Run locally
1333
+
1334
+ \`\`\`bash
1335
+ npm install
1336
+ npm run dev
1337
+ \`\`\`
1338
+
1339
+ Open http://localhost:3000.
1340
+
1341
+ ## Build a static site
1342
+
1343
+ \`\`\`bash
1344
+ npm run build
1345
+ \`\`\`
1346
+
1347
+ Output lands in \`./out\` (configured via \`output: 'export'\` in \`next.config.mjs\`).
1348
+
1349
+ ## How this differs from a hand-written Next.js app
1350
+
1351
+ - Each page is a server component that embeds the SSR HTML via \`dangerouslySetInnerHTML\`.
1352
+ - Interactive behavior lives in the inline scripts inside that HTML — they run when the browser parses the static file.
1353
+ - Tailwind v4 is used for utility classes; safelisted classes referenced only by runtime mappings live in \`app/globals.css\`.
1354
+ - Routing is plain App Router: one \`page.tsx\` per URL, with \`[slug]\` dynamic segments for CMS collections.
1355
+ `;
1356
+ await writeFile(join(outDir, 'README.md'), readme, 'utf-8');
1357
+
1358
+ // ----------------------------------------------------------
1359
+ // 14. Summary
1360
+ // ----------------------------------------------------------
1361
+ const collectionCount = templateSchemas.length;
1362
+
1363
+ // Use the dummy reference so the linter doesn't complain about
1364
+ // escapeSingleQuoted being unused — it's exported-style helper kept for
1365
+ // future expansions (e.g. parameterized CMS metadata).
1366
+ void escapeSingleQuoted;
1367
+
1368
+ return {
1369
+ pages: allResults.length,
1370
+ cmsPages: cmsPageCount,
1371
+ collections: collectionCount,
1372
+ errors: errorCount,
1373
+ };
1374
+ }