@nifrajs/cli 3.5.0 → 4.0.2

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 (404) hide show
  1. package/README.md +42 -3
  2. package/dist/app-files.d.ts +11 -0
  3. package/dist/app-files.d.ts.map +1 -0
  4. package/dist/app-files.js +34 -0
  5. package/dist/app-files.js.map +1 -0
  6. package/dist/assure-hydration.d.ts +7 -1
  7. package/dist/assure-hydration.d.ts.map +1 -1
  8. package/dist/assure-hydration.js +128 -43
  9. package/dist/assure-hydration.js.map +1 -1
  10. package/dist/assure.d.ts.map +1 -1
  11. package/dist/assure.js +16 -2
  12. package/dist/assure.js.map +1 -1
  13. package/dist/capabilities-tool.d.ts +5 -1
  14. package/dist/capabilities-tool.d.ts.map +1 -1
  15. package/dist/capabilities-tool.js +32 -10
  16. package/dist/capabilities-tool.js.map +1 -1
  17. package/dist/cdn-check.d.ts +41 -0
  18. package/dist/cdn-check.d.ts.map +1 -0
  19. package/dist/cdn-check.js +207 -0
  20. package/dist/cdn-check.js.map +1 -0
  21. package/dist/check-diagnostics.d.ts +4 -0
  22. package/dist/check-diagnostics.d.ts.map +1 -1
  23. package/dist/check-diagnostics.js +13 -0
  24. package/dist/check-diagnostics.js.map +1 -1
  25. package/dist/check-scan.d.ts +49 -7
  26. package/dist/check-scan.d.ts.map +1 -1
  27. package/dist/check-scan.js +291 -62
  28. package/dist/check-scan.js.map +1 -1
  29. package/dist/check.d.ts.map +1 -1
  30. package/dist/check.js +99 -63
  31. package/dist/check.js.map +1 -1
  32. package/dist/cli.d.ts +42 -16
  33. package/dist/cli.d.ts.map +1 -1
  34. package/dist/cli.js +262 -82
  35. package/dist/cli.js.map +1 -1
  36. package/dist/command-catalog.d.ts +62 -5
  37. package/dist/command-catalog.d.ts.map +1 -1
  38. package/dist/command-catalog.js +275 -35
  39. package/dist/command-catalog.js.map +1 -1
  40. package/dist/config-target.d.ts +8 -0
  41. package/dist/config-target.d.ts.map +1 -0
  42. package/dist/config-target.js +45 -0
  43. package/dist/config-target.js.map +1 -0
  44. package/dist/contracts.d.ts.map +1 -1
  45. package/dist/contracts.js +52 -13
  46. package/dist/contracts.js.map +1 -1
  47. package/dist/db-audit.d.ts +37 -0
  48. package/dist/db-audit.d.ts.map +1 -0
  49. package/dist/db-audit.js +99 -0
  50. package/dist/db-audit.js.map +1 -0
  51. package/dist/db-child.d.ts +64 -0
  52. package/dist/db-child.d.ts.map +1 -0
  53. package/dist/db-child.js +213 -0
  54. package/dist/db-child.js.map +1 -0
  55. package/dist/db-config.d.ts +60 -0
  56. package/dist/db-config.d.ts.map +1 -0
  57. package/dist/db-config.js +176 -0
  58. package/dist/db-config.js.map +1 -0
  59. package/dist/db-tool.d.ts +84 -0
  60. package/dist/db-tool.d.ts.map +1 -0
  61. package/dist/db-tool.js +537 -0
  62. package/dist/db-tool.js.map +1 -0
  63. package/dist/dev-bun-config.d.ts +20 -13
  64. package/dist/dev-bun-config.d.ts.map +1 -1
  65. package/dist/dev-bun-config.js +98 -41
  66. package/dist/dev-bun-config.js.map +1 -1
  67. package/dist/dev-feed-tool.d.ts +106 -0
  68. package/dist/dev-feed-tool.d.ts.map +1 -0
  69. package/dist/dev-feed-tool.js +713 -0
  70. package/dist/dev-feed-tool.js.map +1 -0
  71. package/dist/dev-server-client.d.ts +67 -0
  72. package/dist/dev-server-client.d.ts.map +1 -0
  73. package/dist/dev-server-client.js +195 -0
  74. package/dist/dev-server-client.js.map +1 -0
  75. package/dist/diff-tool.d.ts +3 -3
  76. package/dist/diff-tool.d.ts.map +1 -1
  77. package/dist/diff-tool.js +6 -5
  78. package/dist/diff-tool.js.map +1 -1
  79. package/dist/docs-search.d.ts.map +1 -1
  80. package/dist/docs-search.js +12 -4
  81. package/dist/docs-search.js.map +1 -1
  82. package/dist/doctor.d.ts +17 -0
  83. package/dist/doctor.d.ts.map +1 -1
  84. package/dist/doctor.js +76 -15
  85. package/dist/doctor.js.map +1 -1
  86. package/dist/env-file.d.ts +15 -0
  87. package/dist/env-file.d.ts.map +1 -1
  88. package/dist/env-file.js +73 -1
  89. package/dist/env-file.js.map +1 -1
  90. package/dist/fix-recipes.d.ts.map +1 -1
  91. package/dist/fix-recipes.js +48 -22
  92. package/dist/fix-recipes.js.map +1 -1
  93. package/dist/frontend-guidance.js +5 -5
  94. package/dist/frontend-guidance.js.map +1 -1
  95. package/dist/i18n-check.d.ts +16 -0
  96. package/dist/i18n-check.d.ts.map +1 -0
  97. package/dist/i18n-check.js +154 -0
  98. package/dist/i18n-check.js.map +1 -0
  99. package/dist/init-agents.d.ts +49 -13
  100. package/dist/init-agents.d.ts.map +1 -1
  101. package/dist/init-agents.js +239 -34
  102. package/dist/init-agents.js.map +1 -1
  103. package/dist/internal/code-unit-order.d.ts +4 -0
  104. package/dist/internal/code-unit-order.d.ts.map +1 -0
  105. package/dist/internal/code-unit-order.js +6 -0
  106. package/dist/internal/code-unit-order.js.map +1 -0
  107. package/dist/internal/script-kind.d.ts +7 -0
  108. package/dist/internal/script-kind.d.ts.map +1 -0
  109. package/dist/internal/script-kind.js +12 -0
  110. package/dist/internal/script-kind.js.map +1 -0
  111. package/dist/internal/source-facts.d.ts +5 -0
  112. package/dist/internal/source-facts.d.ts.map +1 -1
  113. package/dist/internal/source-facts.js +61 -5
  114. package/dist/internal/source-facts.js.map +1 -1
  115. package/dist/internal/typescript-import.d.ts +49 -24
  116. package/dist/internal/typescript-import.d.ts.map +1 -1
  117. package/dist/internal/typescript-import.js +507 -67
  118. package/dist/internal/typescript-import.js.map +1 -1
  119. package/dist/introspect.d.ts +5 -1
  120. package/dist/introspect.d.ts.map +1 -1
  121. package/dist/introspect.js +92 -27
  122. package/dist/introspect.js.map +1 -1
  123. package/dist/learn.js +3 -3
  124. package/dist/learn.js.map +1 -1
  125. package/dist/levels-tool.d.ts +10 -0
  126. package/dist/levels-tool.d.ts.map +1 -1
  127. package/dist/levels-tool.js +33 -3
  128. package/dist/levels-tool.js.map +1 -1
  129. package/dist/load.d.ts +67 -17
  130. package/dist/load.d.ts.map +1 -1
  131. package/dist/load.js +78 -18
  132. package/dist/load.js.map +1 -1
  133. package/dist/manifest-tool.d.ts.map +1 -1
  134. package/dist/manifest-tool.js +3 -2
  135. package/dist/manifest-tool.js.map +1 -1
  136. package/dist/mcp-app-tool.d.ts.map +1 -1
  137. package/dist/mcp-app-tool.js +2 -0
  138. package/dist/mcp-app-tool.js.map +1 -1
  139. package/dist/mcp-context.js +1 -1
  140. package/dist/mcp-context.js.map +1 -1
  141. package/dist/mcp-delegate.d.ts +57 -0
  142. package/dist/mcp-delegate.d.ts.map +1 -0
  143. package/dist/mcp-delegate.js +130 -0
  144. package/dist/mcp-delegate.js.map +1 -0
  145. package/dist/mcp-docs-tools.d.ts.map +1 -1
  146. package/dist/mcp-docs-tools.js +23 -5
  147. package/dist/mcp-docs-tools.js.map +1 -1
  148. package/dist/mcp-exec.d.ts +2 -0
  149. package/dist/mcp-exec.d.ts.map +1 -1
  150. package/dist/mcp-exec.js +132 -108
  151. package/dist/mcp-exec.js.map +1 -1
  152. package/dist/mcp-http.d.ts.map +1 -1
  153. package/dist/mcp-http.js +17 -5
  154. package/dist/mcp-http.js.map +1 -1
  155. package/dist/mcp-io.d.ts +14 -0
  156. package/dist/mcp-io.d.ts.map +1 -1
  157. package/dist/mcp-io.js +45 -0
  158. package/dist/mcp-io.js.map +1 -1
  159. package/dist/mcp-isolate.d.ts +48 -0
  160. package/dist/mcp-isolate.d.ts.map +1 -0
  161. package/dist/mcp-isolate.js +222 -0
  162. package/dist/mcp-isolate.js.map +1 -0
  163. package/dist/mcp-project-child.d.ts +74 -0
  164. package/dist/mcp-project-child.d.ts.map +1 -0
  165. package/dist/mcp-project-child.js +130 -0
  166. package/dist/mcp-project-child.js.map +1 -0
  167. package/dist/mcp-render.js +2 -2
  168. package/dist/mcp-render.js.map +1 -1
  169. package/dist/mcp-root.d.ts +37 -9
  170. package/dist/mcp-root.d.ts.map +1 -1
  171. package/dist/mcp-root.js +110 -23
  172. package/dist/mcp-root.js.map +1 -1
  173. package/dist/mcp-run.d.ts +18 -1
  174. package/dist/mcp-run.d.ts.map +1 -1
  175. package/dist/mcp-run.js +82 -9
  176. package/dist/mcp-run.js.map +1 -1
  177. package/dist/mcp-ws.js +4 -1
  178. package/dist/mcp-ws.js.map +1 -1
  179. package/dist/mcp.d.ts +16 -3
  180. package/dist/mcp.d.ts.map +1 -1
  181. package/dist/mcp.js +81 -53
  182. package/dist/mcp.js.map +1 -1
  183. package/dist/migrate-layout.d.ts +44 -0
  184. package/dist/migrate-layout.d.ts.map +1 -0
  185. package/dist/migrate-layout.js +1155 -0
  186. package/dist/migrate-layout.js.map +1 -0
  187. package/dist/openapi-tool.d.ts +2 -2
  188. package/dist/openapi-tool.d.ts.map +1 -1
  189. package/dist/openapi-tool.js.map +1 -1
  190. package/dist/openapi-types.d.ts +2 -1
  191. package/dist/openapi-types.d.ts.map +1 -1
  192. package/dist/openapi-types.js +240 -12
  193. package/dist/openapi-types.js.map +1 -1
  194. package/dist/pipeline-report.d.ts +3 -2
  195. package/dist/pipeline-report.d.ts.map +1 -1
  196. package/dist/pipeline-report.js +19 -16
  197. package/dist/pipeline-report.js.map +1 -1
  198. package/dist/port.d.ts +5 -4
  199. package/dist/port.d.ts.map +1 -1
  200. package/dist/port.js +46 -29
  201. package/dist/port.js.map +1 -1
  202. package/dist/project-facts.d.ts +5 -0
  203. package/dist/project-facts.d.ts.map +1 -1
  204. package/dist/project-facts.js.map +1 -1
  205. package/dist/recipes/4.0.0.d.ts +9 -0
  206. package/dist/recipes/4.0.0.d.ts.map +1 -0
  207. package/dist/recipes/4.0.0.js +36 -0
  208. package/dist/recipes/4.0.0.js.map +1 -0
  209. package/dist/recipes/index.d.ts.map +1 -1
  210. package/dist/recipes/index.js +2 -0
  211. package/dist/recipes/index.js.map +1 -1
  212. package/dist/release-verification.d.ts +11 -1
  213. package/dist/release-verification.d.ts.map +1 -1
  214. package/dist/release-verification.js +47 -4
  215. package/dist/release-verification.js.map +1 -1
  216. package/dist/review.d.ts.map +1 -1
  217. package/dist/review.js +12 -8
  218. package/dist/review.js.map +1 -1
  219. package/dist/route-types.d.ts +28 -0
  220. package/dist/route-types.d.ts.map +1 -0
  221. package/dist/route-types.js +106 -0
  222. package/dist/route-types.js.map +1 -0
  223. package/dist/rules/codes.d.ts +9 -0
  224. package/dist/rules/codes.d.ts.map +1 -1
  225. package/dist/rules/codes.js +9 -0
  226. package/dist/rules/codes.js.map +1 -1
  227. package/dist/rules/data-guard.d.ts +12 -0
  228. package/dist/rules/data-guard.d.ts.map +1 -0
  229. package/dist/rules/data-guard.js +213 -0
  230. package/dist/rules/data-guard.js.map +1 -0
  231. package/dist/rules/index.d.ts +3 -0
  232. package/dist/rules/index.d.ts.map +1 -1
  233. package/dist/rules/index.js +17 -5
  234. package/dist/rules/index.js.map +1 -1
  235. package/dist/rules/islands.d.ts.map +1 -1
  236. package/dist/rules/islands.js +7 -4
  237. package/dist/rules/islands.js.map +1 -1
  238. package/dist/rules/legacy.d.ts.map +1 -1
  239. package/dist/rules/legacy.js +71 -38
  240. package/dist/rules/legacy.js.map +1 -1
  241. package/dist/rules/nano.d.ts.map +1 -1
  242. package/dist/rules/nano.js +15 -6
  243. package/dist/rules/nano.js.map +1 -1
  244. package/dist/rules/pages.d.ts +15 -0
  245. package/dist/rules/pages.d.ts.map +1 -0
  246. package/dist/rules/pages.js +88 -0
  247. package/dist/rules/pages.js.map +1 -0
  248. package/dist/rules/routes.d.ts +11 -0
  249. package/dist/rules/routes.d.ts.map +1 -1
  250. package/dist/rules/routes.js +180 -2
  251. package/dist/rules/routes.js.map +1 -1
  252. package/dist/rules/secrets.d.ts +10 -0
  253. package/dist/rules/secrets.d.ts.map +1 -0
  254. package/dist/rules/secrets.js +169 -0
  255. package/dist/rules/secrets.js.map +1 -0
  256. package/dist/rules/security.d.ts +7 -0
  257. package/dist/rules/security.d.ts.map +1 -1
  258. package/dist/rules/security.js +171 -65
  259. package/dist/rules/security.js.map +1 -1
  260. package/dist/rules/typescript.d.ts +16 -0
  261. package/dist/rules/typescript.d.ts.map +1 -0
  262. package/dist/rules/typescript.js +59 -0
  263. package/dist/rules/typescript.js.map +1 -0
  264. package/dist/rules/zones.d.ts +17 -0
  265. package/dist/rules/zones.d.ts.map +1 -0
  266. package/dist/rules/zones.js +174 -0
  267. package/dist/rules/zones.js.map +1 -0
  268. package/dist/scaffold.d.ts +25 -12
  269. package/dist/scaffold.d.ts.map +1 -1
  270. package/dist/scaffold.js +132 -87
  271. package/dist/scaffold.js.map +1 -1
  272. package/dist/sdk.d.ts +17 -3
  273. package/dist/sdk.d.ts.map +1 -1
  274. package/dist/sdk.js +941 -153
  275. package/dist/sdk.js.map +1 -1
  276. package/dist/search-terms.d.ts +2 -0
  277. package/dist/search-terms.d.ts.map +1 -1
  278. package/dist/search-terms.js +23 -8
  279. package/dist/search-terms.js.map +1 -1
  280. package/dist/smoke.d.ts +79 -0
  281. package/dist/smoke.d.ts.map +1 -0
  282. package/dist/smoke.js +494 -0
  283. package/dist/smoke.js.map +1 -0
  284. package/dist/sync-manifest.d.ts +10 -2
  285. package/dist/sync-manifest.d.ts.map +1 -1
  286. package/dist/sync-manifest.js +44 -19
  287. package/dist/sync-manifest.js.map +1 -1
  288. package/dist/sync-routes.d.ts +4 -3
  289. package/dist/sync-routes.d.ts.map +1 -1
  290. package/dist/sync-routes.js +4 -3
  291. package/dist/sync-routes.js.map +1 -1
  292. package/dist/test-tool.d.ts.map +1 -1
  293. package/dist/test-tool.js +30 -11
  294. package/dist/test-tool.js.map +1 -1
  295. package/dist/types-search.d.ts.map +1 -1
  296. package/dist/types-search.js +11 -5
  297. package/dist/types-search.js.map +1 -1
  298. package/dist/upgrade.d.ts +36 -6
  299. package/dist/upgrade.d.ts.map +1 -1
  300. package/dist/upgrade.js +167 -29
  301. package/dist/upgrade.js.map +1 -1
  302. package/dist/verification-plan.d.ts.map +1 -1
  303. package/dist/verification-plan.js +4 -0
  304. package/dist/verification-plan.js.map +1 -1
  305. package/dist/verification.d.ts.map +1 -1
  306. package/dist/verification.js +45 -12
  307. package/dist/verification.js.map +1 -1
  308. package/dist/web-app-options.d.ts +19 -0
  309. package/dist/web-app-options.d.ts.map +1 -0
  310. package/dist/web-app-options.js +49 -0
  311. package/dist/web-app-options.js.map +1 -0
  312. package/dist/work-graph.d.ts.map +1 -1
  313. package/dist/work-graph.js +29 -13
  314. package/dist/work-graph.js.map +1 -1
  315. package/docs/examples.json +185 -29
  316. package/docs/llms-full.txt +3807 -580
  317. package/docs/types.json +14229 -10939
  318. package/package.json +19 -12
  319. package/src/app-files.ts +40 -0
  320. package/src/assure-hydration.ts +139 -46
  321. package/src/assure.ts +17 -2
  322. package/src/capabilities-tool.ts +35 -9
  323. package/src/cdn-check.ts +268 -0
  324. package/src/check-diagnostics.ts +17 -0
  325. package/src/check-scan.ts +358 -63
  326. package/src/check.ts +97 -62
  327. package/src/cli.ts +296 -87
  328. package/src/command-catalog.ts +389 -48
  329. package/src/config-target.ts +47 -0
  330. package/src/contracts.ts +54 -12
  331. package/src/db-audit.ts +128 -0
  332. package/src/db-child.ts +285 -0
  333. package/src/db-config.ts +224 -0
  334. package/src/db-tool.ts +690 -0
  335. package/src/dev-bun-config.ts +116 -40
  336. package/src/dev-feed-tool.ts +912 -0
  337. package/src/dev-server-client.ts +267 -0
  338. package/src/diff-tool.ts +6 -5
  339. package/src/docs-search.ts +10 -3
  340. package/src/doctor.ts +98 -15
  341. package/src/env-file.ts +78 -1
  342. package/src/fix-recipes.ts +53 -32
  343. package/src/frontend-guidance.ts +5 -5
  344. package/src/i18n-check.ts +179 -0
  345. package/src/init-agents.ts +301 -38
  346. package/src/internal/code-unit-order.ts +5 -0
  347. package/src/internal/script-kind.ts +11 -0
  348. package/src/internal/source-facts.ts +96 -22
  349. package/src/internal/typescript-import.ts +663 -68
  350. package/src/introspect.ts +90 -27
  351. package/src/learn.ts +3 -3
  352. package/src/levels-tool.ts +47 -2
  353. package/src/load.ts +140 -33
  354. package/src/manifest-tool.ts +3 -2
  355. package/src/mcp-app-tool.ts +2 -0
  356. package/src/mcp-context.ts +1 -1
  357. package/src/mcp-delegate.ts +162 -0
  358. package/src/mcp-docs-tools.ts +23 -5
  359. package/src/mcp-exec.ts +160 -132
  360. package/src/mcp-http.ts +19 -5
  361. package/src/mcp-io.ts +57 -0
  362. package/src/mcp-isolate.ts +287 -0
  363. package/src/mcp-project-child.ts +182 -0
  364. package/src/mcp-render.ts +2 -2
  365. package/src/mcp-root.ts +117 -22
  366. package/src/mcp-run.ts +104 -10
  367. package/src/mcp-ws.ts +4 -1
  368. package/src/mcp.ts +106 -64
  369. package/src/migrate-layout.ts +1338 -0
  370. package/src/openapi-tool.ts +7 -3
  371. package/src/openapi-types.ts +315 -13
  372. package/src/pipeline-report.ts +22 -18
  373. package/src/port.ts +53 -30
  374. package/src/project-facts.ts +5 -0
  375. package/src/recipes/4.0.0.ts +37 -0
  376. package/src/recipes/index.ts +2 -0
  377. package/src/release-verification.ts +74 -5
  378. package/src/review.ts +13 -8
  379. package/src/route-types.ts +133 -0
  380. package/src/rules/codes.ts +9 -0
  381. package/src/rules/data-guard.ts +230 -0
  382. package/src/rules/index.ts +18 -5
  383. package/src/rules/islands.ts +12 -4
  384. package/src/rules/legacy.ts +71 -39
  385. package/src/rules/nano.ts +18 -6
  386. package/src/rules/pages.ts +99 -0
  387. package/src/rules/routes.ts +181 -2
  388. package/src/rules/secrets.ts +174 -0
  389. package/src/rules/security.ts +198 -70
  390. package/src/rules/typescript.ts +77 -0
  391. package/src/rules/zones.ts +195 -0
  392. package/src/scaffold.ts +147 -95
  393. package/src/sdk.ts +1215 -158
  394. package/src/search-terms.ts +24 -7
  395. package/src/smoke.ts +607 -0
  396. package/src/sync-manifest.ts +50 -19
  397. package/src/sync-routes.ts +4 -3
  398. package/src/test-tool.ts +32 -11
  399. package/src/types-search.ts +11 -4
  400. package/src/upgrade.ts +205 -25
  401. package/src/verification-plan.ts +13 -0
  402. package/src/verification.ts +50 -12
  403. package/src/web-app-options.ts +57 -0
  404. package/src/work-graph.ts +27 -13
@@ -0,0 +1,1338 @@
1
+ /**
2
+ * `nifra migrate layout` - move an app onto the frontend/backend split.
3
+ *
4
+ * What it does, in order:
5
+ * 1. splits every route file: the exports only the server reads (`loader`, `action`, ...) and the code
6
+ * only they use move to the route's `x.backend.ts`; the frontend file keeps the rest, plus an
7
+ * `import type` for any moved name it still mentions in a type;
8
+ * 2. folds each `_middleware.ts` into its directory's `_layout.backend.ts` as `middleware`;
9
+ * 3. moves `backend.ts` to `backend/app.ts` and `framework.ts` to `backend/framework.ts`;
10
+ * 4. moves each `x.server.ts` under `backend/` without the retired suffix, and zones every other module
11
+ * by who imports it at runtime: only route frontends -> `frontend/`, only the server side ->
12
+ * `backend/`, both -> `shared/`;
13
+ * 5. rewrites every relative import in the app to the new locations, `@nifrajs/web/server-only` to
14
+ * `@nifrajs/web/backend-only`, and `ServerOnly` to `BackendOnly`.
15
+ *
16
+ * It is a dry run unless `write` is set, and it reports what it could not decide (a frontend that uses
17
+ * a moved name at runtime, a helper both halves need, a loader with no output schema) instead of
18
+ * guessing.
19
+ */
20
+ import {
21
+ existsSync,
22
+ mkdirSync,
23
+ readdirSync,
24
+ readFileSync,
25
+ renameSync,
26
+ rmSync,
27
+ statSync,
28
+ writeFileSync,
29
+ } from "node:fs"
30
+ import { dirname, extname, join, posix, relative, resolve } from "node:path"
31
+ import { BACKEND_ROUTE_EXPORTS } from "@nifrajs/web/route-manifest"
32
+ import type * as TS from "typescript"
33
+ import { codeUnitOrder } from "./internal/code-unit-order.ts"
34
+ import { importTypeScript } from "./internal/typescript-import.ts"
35
+ import { missingOutputSchemas } from "./rules/data-guard.ts"
36
+
37
+ /** Exports only the server or the build reads: `@nifrajs/web`'s route-pair contract. */
38
+ const BACKEND_EXPORTS = BACKEND_ROUTE_EXPORTS
39
+
40
+ const ROUTE_FILE = /\.(?:tsx|jsx|svelte|vue|mdx)$/
41
+ const SOURCE_FILE = /\.(?:[cm]?[jt]sx?|svelte|vue|mdx)$/
42
+ const RESOLVE_EXTENSIONS = [
43
+ ".ts",
44
+ ".tsx",
45
+ ".js",
46
+ ".jsx",
47
+ ".mjs",
48
+ ".mts",
49
+ ".svelte",
50
+ ".vue",
51
+ ".mdx",
52
+ ".css",
53
+ ".json",
54
+ ]
55
+ const LEGACY_SERVER = /\.server(\.(?:[cm]?[jt]sx?|svelte|vue|mdx))$/
56
+ const RETIRED_SPECIFIERS: Readonly<Record<string, string>> = {
57
+ "@nifrajs/web/server-only": "@nifrajs/web/backend-only",
58
+ }
59
+ /** Scripts and config that name app files by path: scanned for paths that moved, never rewritten. */
60
+ const TOOLING_FILE =
61
+ /(?:\.(?:json|jsonc|ya?ml|toml|sh|bash|zsh|ps1)|(?:^|\/)(?:Dockerfile|Makefile|Procfile))$/
62
+ const SELF_LOCATION = /\bimport\.meta\.(?:dirname|dir|filename|url)\b|\b__(?:dirname|filename)\b/
63
+ const SKIP_DIRS = new Set(["node_modules", "dist", "build", "coverage", ".git", ".vite", "public"])
64
+ const ZONE_DIRS = new Set(["frontend", "backend", "shared", "routes", "public"])
65
+
66
+ export interface LayoutMigrationIssue {
67
+ readonly file: string
68
+ readonly reason: string
69
+ }
70
+
71
+ export interface LayoutMigrationResult {
72
+ readonly ok: boolean
73
+ readonly write: boolean
74
+ /** Route files whose backend exports moved, and where they went. */
75
+ readonly splits: ReadonlyArray<{
76
+ readonly file: string
77
+ readonly backend: string
78
+ readonly moved: readonly string[]
79
+ }>
80
+ /** Files that moved, app-relative. */
81
+ readonly moves: ReadonlyArray<{ readonly from: string; readonly to: string }>
82
+ /** Files whose imports were rewritten (and were not otherwise created). */
83
+ readonly rewritten: readonly string[]
84
+ readonly issues: readonly LayoutMigrationIssue[]
85
+ }
86
+
87
+ export interface LayoutMigrationOptions {
88
+ readonly write?: boolean
89
+ /** Inject the TypeScript compiler (tests); defaults to the CLI's own. */
90
+ readonly typescript?: typeof TS
91
+ }
92
+
93
+ /** A file's text, as the migration sees it before writing. */
94
+ type Files = Map<string, string>
95
+
96
+ const toPosix = (path: string): string => path.replaceAll("\\", "/")
97
+
98
+ /** Every first-party file under `root`, app-relative and posix. */
99
+ function listFiles(root: string): string[] {
100
+ const out: string[] = []
101
+ const walk = (dir: string): void => {
102
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
103
+ if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue
104
+ const full = join(dir, entry.name)
105
+ if (entry.isDirectory()) walk(full)
106
+ else if (entry.isFile()) out.push(toPosix(relative(root, full)))
107
+ }
108
+ }
109
+ walk(root)
110
+ return out.sort()
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------------------------------
114
+ // Route splitting
115
+ // ---------------------------------------------------------------------------------------------------
116
+
117
+ interface StatementInfo {
118
+ readonly node: TS.Statement
119
+ /** Text including leading comments. */
120
+ readonly text: string
121
+ readonly declares: ReadonlySet<string>
122
+ /** Names this statement exports, local name -> exported names. */
123
+ readonly exports: ReadonlyMap<string, readonly string[]>
124
+ readonly valueRefs: ReadonlySet<string>
125
+ readonly typeRefs: ReadonlySet<string>
126
+ }
127
+
128
+ export interface SplitResult {
129
+ readonly frontend: string
130
+ /** `undefined` when the file exports nothing for the server. */
131
+ readonly backend: string | undefined
132
+ readonly moved: readonly string[]
133
+ readonly issues: readonly string[]
134
+ }
135
+
136
+ function bindingNames(ts: typeof TS, name: TS.BindingName, into: Set<string>): void {
137
+ if (ts.isIdentifier(name)) into.add(name.text)
138
+ else
139
+ for (const element of name.elements)
140
+ if (!ts.isOmittedExpression(element)) bindingNames(ts, element.name, into)
141
+ }
142
+
143
+ const hasModifier = (ts: typeof TS, node: TS.Node, kind: TS.SyntaxKind): boolean =>
144
+ (ts.canHaveModifiers(node) ? (ts.getModifiers(node) ?? []) : []).some((m) => m.kind === kind)
145
+
146
+ /** Identifiers a statement reads, split into value and type positions. Over-approximates on purpose:
147
+ * an extra name only keeps a declaration in a half that did not need it. */
148
+ function references(ts: typeof TS, node: TS.Node): { value: Set<string>; type: Set<string> } {
149
+ const value = new Set<string>()
150
+ const type = new Set<string>()
151
+ const visit = (current: TS.Node, inType: boolean): void => {
152
+ const typed =
153
+ inType ||
154
+ ts.isTypeNode(current) ||
155
+ // @nifra-gate-reviewed: `token` is the heritage clause's syntax kind, not a credential.
156
+ (ts.isHeritageClause(current) && current.token === ts.SyntaxKind.ImplementsKeyword)
157
+ if (ts.isIdentifier(current)) {
158
+ const parent = current.parent
159
+ const isName =
160
+ parent !== undefined &&
161
+ ((ts.isPropertyAccessExpression(parent) && parent.name === current) ||
162
+ (ts.isPropertyAssignment(parent) && parent.name === current) ||
163
+ (ts.isPropertySignature(parent) && parent.name === current) ||
164
+ (ts.isPropertyDeclaration(parent) && parent.name === current) ||
165
+ (ts.isMethodDeclaration(parent) && parent.name === current) ||
166
+ (ts.isMethodSignature(parent) && parent.name === current) ||
167
+ (ts.isQualifiedName(parent) && parent.right === current) ||
168
+ (ts.isJsxAttribute(parent) && parent.name === current) ||
169
+ (ts.isBindingElement(parent) && parent.propertyName === current) ||
170
+ ts.isImportSpecifier(parent) ||
171
+ ts.isExportSpecifier(parent) ||
172
+ ts.isImportClause(parent) ||
173
+ ts.isNamespaceImport(parent))
174
+ if (!isName) (typed ? type : value).add(current.text)
175
+ return
176
+ }
177
+ if (ts.isShorthandPropertyAssignment(current)) value.add(current.name.text)
178
+ ts.forEachChild(current, (child) => visit(child, typed))
179
+ }
180
+ visit(node, false)
181
+ return { value, type }
182
+ }
183
+
184
+ function describeStatements(ts: typeof TS, sf: TS.SourceFile): StatementInfo[] {
185
+ return sf.statements.map((node) => {
186
+ const declares = new Set<string>()
187
+ const exports = new Map<string, string[]>()
188
+ const exported = hasModifier(ts, node, ts.SyntaxKind.ExportKeyword)
189
+ const isDefault = hasModifier(ts, node, ts.SyntaxKind.DefaultKeyword)
190
+ const addExport = (local: string, as: string): void => {
191
+ exports.set(local, [...(exports.get(local) ?? []), as])
192
+ }
193
+ if (ts.isImportDeclaration(node)) {
194
+ const clause = node.importClause
195
+ if (clause?.name) declares.add(clause.name.text)
196
+ const bindings = clause?.namedBindings
197
+ if (bindings && ts.isNamespaceImport(bindings)) declares.add(bindings.name.text)
198
+ if (bindings && ts.isNamedImports(bindings))
199
+ for (const el of bindings.elements) declares.add(el.name.text)
200
+ } else if (ts.isVariableStatement(node)) {
201
+ for (const decl of node.declarationList.declarations) {
202
+ const names = new Set<string>()
203
+ bindingNames(ts, decl.name, names)
204
+ for (const name of names) {
205
+ declares.add(name)
206
+ if (exported) addExport(name, name)
207
+ }
208
+ }
209
+ } else if (
210
+ ts.isFunctionDeclaration(node) ||
211
+ ts.isClassDeclaration(node) ||
212
+ ts.isInterfaceDeclaration(node) ||
213
+ ts.isTypeAliasDeclaration(node) ||
214
+ ts.isEnumDeclaration(node)
215
+ ) {
216
+ const name = node.name?.text
217
+ if (name !== undefined) declares.add(name)
218
+ if (exported) addExport(name ?? "default", isDefault ? "default" : (name ?? "default"))
219
+ } else if (ts.isExportAssignment(node)) {
220
+ addExport("default", "default")
221
+ } else if (
222
+ ts.isExportDeclaration(node) &&
223
+ node.exportClause &&
224
+ ts.isNamedExports(node.exportClause)
225
+ ) {
226
+ for (const el of node.exportClause.elements) {
227
+ addExport(
228
+ node.moduleSpecifier ? `\0${el.name.text}` : (el.propertyName ?? el.name).text,
229
+ el.name.text,
230
+ )
231
+ }
232
+ }
233
+ const refs = ts.isImportDeclaration(node)
234
+ ? { value: new Set<string>(), type: new Set<string>() }
235
+ : references(ts, node)
236
+ for (const name of declares) {
237
+ refs.value.delete(name)
238
+ refs.type.delete(name)
239
+ }
240
+ return {
241
+ node,
242
+ text: node.getFullText(sf),
243
+ declares,
244
+ exports,
245
+ valueRefs: refs.value,
246
+ typeRefs: refs.type,
247
+ }
248
+ })
249
+ }
250
+
251
+ /** Rebuild an import keeping only the bindings `keep` names. `undefined` when none remain. */
252
+ function filterImport(
253
+ ts: typeof TS,
254
+ sf: TS.SourceFile,
255
+ node: TS.ImportDeclaration,
256
+ keep: (name: string) => boolean,
257
+ ): string | undefined {
258
+ const clause = node.importClause
259
+ const spec = node.moduleSpecifier.getText(sf)
260
+ const attributes = node.attributes ? ` ${node.attributes.getText(sf)}` : ""
261
+ if (clause === undefined) return node.getFullText(sf)
262
+ const parts: string[] = []
263
+ if (clause.name && keep(clause.name.text)) parts.push(clause.name.text)
264
+ const bindings = clause.namedBindings
265
+ if (bindings && ts.isNamespaceImport(bindings) && keep(bindings.name.text))
266
+ parts.push(`* as ${bindings.name.text}`)
267
+ if (bindings && ts.isNamedImports(bindings)) {
268
+ const named = bindings.elements.filter((el) => keep(el.name.text)).map((el) => el.getText(sf))
269
+ if (named.length > 0) parts.push(`{ ${named.join(", ")} }`)
270
+ }
271
+ if (parts.length === 0) return undefined
272
+ const typeOnly = clause.isTypeOnly ? "type " : ""
273
+ const leading = node.getFullText(sf).slice(0, node.getStart(sf) - node.getFullStart())
274
+ return `${leading}import ${typeOnly}${parts.join(", ")} from ${spec}${attributes}`
275
+ }
276
+
277
+ /** Rebuild `export { a, b as c }` keeping only the exported names `keep` accepts. */
278
+ function filterExportList(
279
+ ts: typeof TS,
280
+ sf: TS.SourceFile,
281
+ node: TS.ExportDeclaration,
282
+ keep: (exported: string) => boolean,
283
+ ): string | undefined {
284
+ const clause = node.exportClause
285
+ if (!clause || !ts.isNamedExports(clause)) return node.getFullText(sf)
286
+ const kept = clause.elements.filter((el) => keep(el.name.text)).map((el) => el.getText(sf))
287
+ if (kept.length === 0) return undefined
288
+ const from = node.moduleSpecifier ? ` from ${node.moduleSpecifier.getText(sf)}` : ""
289
+ const leading = node.getFullText(sf).slice(0, node.getStart(sf) - node.getFullStart())
290
+ return `${leading}export ${node.isTypeOnly ? "type " : ""}{ ${kept.join(", ")} }${from}`
291
+ }
292
+
293
+ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
294
+
295
+ const joinStatements = (texts: readonly string[]): string => {
296
+ const body = texts.join("").replace(/^\s*\n/, "")
297
+ return body.endsWith("\n") ? body : `${body}\n`
298
+ }
299
+
300
+ /**
301
+ * Split one route module's source into its frontend and backend halves. `backendSpecifier` is how the
302
+ * frontend refers to the backend half in an `import type` (`./index.backend.ts`).
303
+ */
304
+ export function splitRouteSource(
305
+ ts: typeof TS,
306
+ source: string,
307
+ fileName: string,
308
+ backendSpecifier: string,
309
+ ): SplitResult {
310
+ const kind = fileName.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS
311
+ const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, kind)
312
+ const statements = describeStatements(ts, sf)
313
+ const issues: string[] = []
314
+ const exportsOf = (info: StatementInfo): string[] => [...info.exports.values()].flat()
315
+ const isBackendStatement = (info: StatementInfo): boolean =>
316
+ exportsOf(info).some((name) => BACKEND_EXPORTS.has(name))
317
+ const moved = statements.flatMap(exportsOf).filter((name) => BACKEND_EXPORTS.has(name))
318
+ if (moved.length === 0) return { frontend: source, backend: undefined, moved: [], issues: [] }
319
+
320
+ const declaredBy = new Map<string, StatementInfo>()
321
+ for (const info of statements) {
322
+ if (ts.isImportDeclaration(info.node)) continue
323
+ for (const name of info.declares) declaredBy.set(name, info)
324
+ }
325
+
326
+ // The backend half: its export statements plus every top-level declaration they reach.
327
+ const backendSet = new Set<StatementInfo>()
328
+ const queue: StatementInfo[] = statements.filter(isBackendStatement)
329
+ for (const info of queue) backendSet.add(info)
330
+ while (queue.length > 0) {
331
+ const info = queue.pop() as StatementInfo
332
+ for (const name of [...info.valueRefs, ...info.typeRefs]) {
333
+ const target = declaredBy.get(name)
334
+ if (target !== undefined && !backendSet.has(target)) {
335
+ backendSet.add(target)
336
+ queue.push(target)
337
+ }
338
+ }
339
+ // An exported-name statement (`export { loader }`) reaches the declaration it names.
340
+ for (const local of info.exports.keys()) {
341
+ const target = declaredBy.get(local)
342
+ if (target !== undefined && !backendSet.has(target)) {
343
+ backendSet.add(target)
344
+ queue.push(target)
345
+ }
346
+ }
347
+ }
348
+
349
+ // The frontend half: everything that is not a backend export statement, minus declarations that only
350
+ // the backend half needs.
351
+ const movedLocals = new Set<string>()
352
+ for (const info of statements) {
353
+ for (const [local, names] of info.exports)
354
+ if (names.some((name) => BACKEND_EXPORTS.has(name))) movedLocals.add(local)
355
+ }
356
+ const frontendCandidates = statements.filter((info) => !ts.isImportDeclaration(info.node))
357
+ const frontendKeep = new Set<StatementInfo>()
358
+ const seed = frontendCandidates.filter(
359
+ (info) =>
360
+ !backendSet.has(info) ||
361
+ (!isBackendStatement(info) && exportsOf(info).length > 0) ||
362
+ // a statement with no declaration (a side effect) stays where it ran
363
+ (info.declares.size === 0 && info.exports.size === 0),
364
+ )
365
+ const fqueue = [...seed]
366
+ for (const info of fqueue) frontendKeep.add(info)
367
+ while (fqueue.length > 0) {
368
+ const info = fqueue.pop() as StatementInfo
369
+ for (const name of info.valueRefs) {
370
+ if (movedLocals.has(name) && !info.declares.has(name)) continue
371
+ const target = declaredBy.get(name)
372
+ if (target !== undefined && !frontendKeep.has(target) && !isBackendStatement(target)) {
373
+ frontendKeep.add(target)
374
+ fqueue.push(target)
375
+ }
376
+ }
377
+ for (const name of info.typeRefs) {
378
+ if (movedLocals.has(name)) continue
379
+ const target = declaredBy.get(name)
380
+ if (target !== undefined && !frontendKeep.has(target) && !isBackendStatement(target)) {
381
+ frontendKeep.add(target)
382
+ fqueue.push(target)
383
+ }
384
+ }
385
+ }
386
+
387
+ // A moved name the frontend still mentions: in a type it becomes `import type`; at runtime it is an
388
+ // error the author has to resolve.
389
+ const typeImports = new Set<string>()
390
+ for (const info of frontendKeep) {
391
+ if (isBackendStatement(info)) continue
392
+ for (const name of movedLocals) {
393
+ if (info.declares.has(name)) continue
394
+ if (info.valueRefs.has(name))
395
+ issues.push(`the frontend uses "${name}" at runtime, but it runs on the server only`)
396
+ if (info.typeRefs.has(name) || info.valueRefs.has(name)) typeImports.add(name)
397
+ }
398
+ }
399
+ for (const info of frontendKeep) {
400
+ if (!backendSet.has(info) || isBackendStatement(info) || info.declares.size === 0) continue
401
+ const values = [...info.declares].filter(
402
+ () => !ts.isInterfaceDeclaration(info.node) && !ts.isTypeAliasDeclaration(info.node),
403
+ )
404
+ if (values.length > 0)
405
+ issues.push(
406
+ `"${values.join(", ")}" is needed by both halves and was copied into each; consider moving it to shared/`,
407
+ )
408
+ }
409
+
410
+ const used = (infos: Iterable<StatementInfo>): Set<string> => {
411
+ const names = new Set<string>()
412
+ for (const info of infos)
413
+ for (const name of [...info.valueRefs, ...info.typeRefs]) names.add(name)
414
+ return names
415
+ }
416
+ const backendUses = used(backendSet)
417
+ const frontendUses = used([...frontendKeep].filter((info) => !isBackendStatement(info)))
418
+
419
+ const backendTexts: string[] = []
420
+ const frontendTexts: string[] = []
421
+ let afterImports = 0
422
+ for (const info of statements) {
423
+ const node = info.node
424
+ if (ts.isImportDeclaration(node)) {
425
+ const back = filterImport(ts, sf, node, (name) => backendUses.has(name))
426
+ if (back !== undefined) backendTexts.push(node.importClause ? back : "")
427
+ const front = filterImport(ts, sf, node, (name) => frontendUses.has(name))
428
+ if (front !== undefined) frontendTexts.push(front)
429
+ afterImports = frontendTexts.length
430
+ continue
431
+ }
432
+ if (isBackendStatement(info)) {
433
+ if (
434
+ ts.isExportDeclaration(node) &&
435
+ exportsOf(info).some((name) => !BACKEND_EXPORTS.has(name))
436
+ ) {
437
+ backendTexts.push(filterExportList(ts, sf, node, (name) => BACKEND_EXPORTS.has(name)) ?? "")
438
+ frontendTexts.push(
439
+ filterExportList(ts, sf, node, (name) => !BACKEND_EXPORTS.has(name)) ?? "",
440
+ )
441
+ continue
442
+ }
443
+ if (exportsOf(info).some((name) => !BACKEND_EXPORTS.has(name))) {
444
+ issues.push(
445
+ `one statement exports both "${exportsOf(info).join('", "')}"; split it by hand`,
446
+ )
447
+ }
448
+ backendTexts.push(info.text)
449
+ continue
450
+ }
451
+ if (backendSet.has(info)) backendTexts.push(stripExport(ts, sf, info))
452
+ if (frontendKeep.has(info)) frontendTexts.push(info.text)
453
+ }
454
+ // After the other imports: the nearest relative path sorts last.
455
+ if (typeImports.size > 0) {
456
+ frontendTexts.splice(
457
+ afterImports,
458
+ 0,
459
+ `\nimport type { ${[...typeImports].sort().join(", ")} } from "${backendSpecifier}"`,
460
+ )
461
+ }
462
+ // A JSX pragma counts only in the file's leading comments, and the backend half has no JSX.
463
+ const pragmas = (ts.getLeadingCommentRanges(source, 0) ?? [])
464
+ .map((range) => source.slice(range.pos, range.end))
465
+ .filter((comment) => /@jsx/.test(comment))
466
+ const lift = (text: string): string =>
467
+ pragmas.reduce(
468
+ (out, pragma) => out.replace(new RegExp(`${escapeRegExp(pragma)}[ \\t]*\\n?`), ""),
469
+ text,
470
+ )
471
+ const frontend = lift(joinStatements(frontendTexts))
472
+ return {
473
+ frontend: pragmas.length > 0 ? `${pragmas.join("\n")}\n${frontend}` : frontend,
474
+ backend: lift(joinStatements(backendTexts)),
475
+ moved: [...new Set(moved)].sort(),
476
+ issues,
477
+ }
478
+ }
479
+
480
+ /** A helper the backend half copies keeps its declaration; an `export` on it would add a backend export. */
481
+ function stripExport(ts: typeof TS, sf: TS.SourceFile, info: StatementInfo): string {
482
+ if (info.exports.size === 0) return info.text
483
+ const node = info.node
484
+ const exportKeyword = (ts.canHaveModifiers(node) ? (ts.getModifiers(node) ?? []) : []).find(
485
+ (m) => m.kind === ts.SyntaxKind.ExportKeyword,
486
+ )
487
+ if (exportKeyword === undefined) return info.text
488
+ const start = exportKeyword.getStart(sf) - node.getFullStart()
489
+ const end = exportKeyword.getEnd() - node.getFullStart()
490
+ return info.text.slice(0, start) + info.text.slice(end).replace(/^\s+/, "")
491
+ }
492
+
493
+ /** `_middleware.ts`'s default export, as the `middleware` export of a `_layout.backend.ts`. */
494
+ export function middlewareSource(ts: typeof TS, source: string, fileName: string): string {
495
+ const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
496
+ let out = source
497
+ // Edit from the end so earlier offsets stay valid.
498
+ for (const node of [...sf.statements].reverse()) {
499
+ if (ts.isExportAssignment(node) && !node.isExportEquals) {
500
+ out = `${out.slice(0, node.getStart(sf))}export const middleware = ${node.expression.getText(sf)}${out.slice(node.getEnd())}`
501
+ } else if (
502
+ ts.isFunctionDeclaration(node) &&
503
+ hasModifier(ts, node, ts.SyntaxKind.DefaultKeyword)
504
+ ) {
505
+ const modifiers = (ts.getModifiers(node) ?? []).filter(
506
+ (m) => m.kind === ts.SyntaxKind.ExportKeyword || m.kind === ts.SyntaxKind.DefaultKeyword,
507
+ )
508
+ const first = modifiers[0]
509
+ const last = modifiers[modifiers.length - 1]
510
+ if (first === undefined || last === undefined) continue
511
+ if (node.name === undefined) {
512
+ const text = out
513
+ .slice(last.getEnd(), node.getEnd())
514
+ .replace(/^(\s*(?:async\s+)?function\s*\*?)\s*/, "$1 middleware")
515
+ out = `${out.slice(0, first.getStart(sf))}export${text}${out.slice(node.getEnd())}`
516
+ } else {
517
+ out = `${out.slice(0, first.getStart(sf))}${out.slice(last.getEnd()).replace(/^\s+/, "")}`
518
+ out = `${out.trimEnd()}\nexport { ${node.name.text} as middleware }\n`
519
+ }
520
+ }
521
+ }
522
+ return out
523
+ }
524
+
525
+ /** Merge two backend modules: imports first (identical ones once), then the rest of each in order. */
526
+ function mergeModules(ts: typeof TS, first: string, second: string): string {
527
+ if (first.trim() === "") return second
528
+ const split = (text: string): { imports: string[]; body: string[] } => {
529
+ const sf = ts.createSourceFile("merge.ts", text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
530
+ const imports: string[] = []
531
+ const body: string[] = []
532
+ for (const node of sf.statements) {
533
+ ;(ts.isImportDeclaration(node) ? imports : body).push(
534
+ node.getFullText(sf).replace(/^\s*\n/, ""),
535
+ )
536
+ }
537
+ return { imports, body }
538
+ }
539
+ const a = split(first)
540
+ const b = split(second)
541
+ const imports = [...new Set([...a.imports, ...b.imports].map((text) => text.trim()))]
542
+ const body = [...a.body, ...b.body].map((text) => text.trim()).join("\n\n")
543
+ return `${imports.join("\n")}${imports.length > 0 ? "\n\n" : ""}${body}\n`
544
+ }
545
+
546
+ // ---------------------------------------------------------------------------------------------------
547
+ // SFC script blocks
548
+ // ---------------------------------------------------------------------------------------------------
549
+
550
+ /** The `<script>` blocks of a Svelte/Vue file: their content range and whether it is the module script. */
551
+ function scriptBlocks(
552
+ source: string,
553
+ ): Array<{ start: number; end: number; module: boolean; setup: boolean }> {
554
+ const blocks: Array<{ start: number; end: number; module: boolean; setup: boolean }> = []
555
+ // A closing tag ends at any space, slash or `>` after its name, as the compilers read it.
556
+ const pattern = /<script\b([^>]*)>([\s\S]*?)<\/script(?=[\s/>])[^>]*>/gi
557
+ // A `<script>` written inside an HTML comment is prose, not a block; blank comments out, keeping offsets.
558
+ const visible = source.replace(/<!--[\s\S]*?-->/g, (comment) => comment.replace(/[^\n]/g, " "))
559
+ for (const match of visible.matchAll(pattern)) {
560
+ const attrs = match[1] ?? ""
561
+ const start = (match.index ?? 0) + match[0].indexOf(">") + 1
562
+ blocks.push({
563
+ start,
564
+ end: start + (match[2] ?? "").length,
565
+ module: /\bmodule\b|context=["']module["']/.test(attrs),
566
+ setup: /\bsetup\b/.test(attrs),
567
+ })
568
+ }
569
+ return blocks
570
+ }
571
+
572
+ // ---------------------------------------------------------------------------------------------------
573
+ // Import graph + zoning
574
+ // ---------------------------------------------------------------------------------------------------
575
+
576
+ interface ImportRef {
577
+ readonly specifier: string
578
+ /** Erased at build time: `import type`, a type-only specifier list, `typeof import()`. */
579
+ readonly typeOnly: boolean
580
+ }
581
+
582
+ function importsOf(ts: typeof TS, file: string, text: string): ImportRef[] {
583
+ if (/\.(?:svelte|vue)$/.test(file)) {
584
+ return scriptBlocks(text).flatMap((block) =>
585
+ importsOf(ts, "block.ts", text.slice(block.start, block.end)),
586
+ )
587
+ }
588
+ if (file.endsWith(".css")) {
589
+ return [...text.matchAll(/@import\s+(?:url\()?["']([^"']+)["']/g)].map((m) => ({
590
+ specifier: m[1] ?? "",
591
+ typeOnly: false,
592
+ }))
593
+ }
594
+ if (!/\.(?:[cm]?[jt]sx?|mdx)$/.test(file)) return []
595
+ const code = file.endsWith(".mdx")
596
+ ? text
597
+ .split("\n")
598
+ .filter((line) => /^(?:import|export)\s/.test(line))
599
+ .join("\n")
600
+ : text
601
+ const sf = ts.createSourceFile(
602
+ file,
603
+ code,
604
+ ts.ScriptTarget.Latest,
605
+ true,
606
+ file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
607
+ )
608
+ const refs: ImportRef[] = []
609
+ const visit = (node: TS.Node): void => {
610
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
611
+ const clause = node.importClause
612
+ const named = clause?.namedBindings
613
+ const typeOnly =
614
+ clause !== undefined &&
615
+ (clause.isTypeOnly ||
616
+ (clause.name === undefined &&
617
+ named !== undefined &&
618
+ ts.isNamedImports(named) &&
619
+ named.elements.length > 0 &&
620
+ named.elements.every((el) => el.isTypeOnly)))
621
+ refs.push({ specifier: node.moduleSpecifier.text, typeOnly })
622
+ } else if (
623
+ ts.isExportDeclaration(node) &&
624
+ node.moduleSpecifier &&
625
+ ts.isStringLiteral(node.moduleSpecifier)
626
+ ) {
627
+ refs.push({ specifier: node.moduleSpecifier.text, typeOnly: node.isTypeOnly })
628
+ } else if (ts.isImportTypeNode(node)) {
629
+ const arg = node.argument
630
+ if (ts.isLiteralTypeNode(arg) && ts.isStringLiteral(arg.literal))
631
+ refs.push({ specifier: arg.literal.text, typeOnly: true })
632
+ } else if (ts.isCallExpression(node) && node.arguments.length > 0) {
633
+ const arg = node.arguments[0]
634
+ const isImport = node.expression.kind === ts.SyntaxKind.ImportKeyword
635
+ const isRequire = ts.isIdentifier(node.expression) && node.expression.text === "require"
636
+ if ((isImport || isRequire) && arg !== undefined && ts.isStringLiteralLike(arg))
637
+ refs.push({ specifier: arg.text, typeOnly: false })
638
+ } else if (
639
+ ts.isImportEqualsDeclaration(node) &&
640
+ ts.isExternalModuleReference(node.moduleReference)
641
+ ) {
642
+ const expr = node.moduleReference.expression
643
+ if (ts.isStringLiteral(expr)) refs.push({ specifier: expr.text, typeOnly: node.isTypeOnly })
644
+ }
645
+ ts.forEachChild(node, visit)
646
+ }
647
+ visit(sf)
648
+ return refs
649
+ }
650
+
651
+ /** Resolve a relative specifier against the file map, the way a bundler would. */
652
+ function resolveIn(
653
+ files: ReadonlySet<string>,
654
+ from: string,
655
+ specifier: string,
656
+ ): string | undefined {
657
+ if (!specifier.startsWith(".")) return undefined
658
+ const clean = specifier.replace(/[?#].*$/, "")
659
+ const base = posix.normalize(posix.join(posix.dirname(from), clean))
660
+ const candidates = [
661
+ base,
662
+ ...RESOLVE_EXTENSIONS.map((ext) => base + ext),
663
+ ...RESOLVE_EXTENSIONS.map((ext) => `${base}/index${ext}`),
664
+ ]
665
+ // `./x.js` written for a `./x.ts` source.
666
+ const js = /\.(?:[cm]?js|jsx)$/.exec(base)
667
+ if (js !== null) {
668
+ const stem = base.slice(0, -js[0].length)
669
+ candidates.push(`${stem}.ts`, `${stem}.tsx`, `${stem}.mts`)
670
+ }
671
+ return candidates.find((candidate) => files.has(candidate))
672
+ }
673
+
674
+ /** Where `text` spells, inside quotes, an import specifier that resolves among `files`: [start, end). */
675
+ function liveSpecifierSpans(
676
+ ts: typeof TS,
677
+ file: string,
678
+ text: string,
679
+ files: ReadonlySet<string>,
680
+ ): Array<readonly [number, number]> {
681
+ const spans: Array<readonly [number, number]> = []
682
+ for (const { specifier } of importsOf(ts, file, text)) {
683
+ if (resolveIn(files, file, specifier) === undefined) continue
684
+ for (const quote of ['"', "'", "`"]) {
685
+ const quoted = `${quote}${specifier}${quote}`
686
+ for (let at = text.indexOf(quoted); at !== -1; at = text.indexOf(quoted, at + 1))
687
+ spans.push([at + 1, at + 1 + specifier.length])
688
+ }
689
+ }
690
+ return spans
691
+ }
692
+
693
+ const OWN_DIR = new Set(["import.meta.dir", "import.meta.dirname", "__dirname"])
694
+
695
+ /**
696
+ * Re-point the paths a module moving from `from` to `to` builds from its own location, so each still
697
+ * reaches what it did: `join(import.meta.dir, "..", "x")` (or `resolve`), `import.meta.dir + "/../x"`,
698
+ * `` `${__dirname}/../x` `` and `new URL("../x", import.meta.url)`, each with literal segments. `locate`
699
+ * maps an app-relative target to where it ends up. Returns the new text and the first use of the
700
+ * module's location it could not follow.
701
+ */
702
+ function rebaseOwnPaths(
703
+ ts: typeof TS,
704
+ from: string,
705
+ to: string,
706
+ text: string,
707
+ locate: (target: string) => string,
708
+ ): { readonly text: string; readonly unfollowed: string | undefined } {
709
+ const sf = ts.createSourceFile(
710
+ to,
711
+ text,
712
+ ts.ScriptTarget.Latest,
713
+ true,
714
+ to.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
715
+ )
716
+ const edits: Array<{ start: number; end: number; text: string }> = []
717
+ const followed: Array<readonly [number, number]> = []
718
+ const isOwnDir = (node: TS.Node | undefined): node is TS.Expression =>
719
+ node !== undefined && OWN_DIR.has(node.getText(sf))
720
+ const isLiteral = (
721
+ node: TS.Node | undefined,
722
+ ): node is TS.StringLiteral | TS.NoSubstitutionTemplateLiteral =>
723
+ node !== undefined && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
724
+ const quoted = (node: TS.Node, value: string): string => {
725
+ const quote = node.getText(sf)[0] ?? '"'
726
+ return `${quote}${value}${quote}`
727
+ }
728
+ // The path `relative` names from the module's old folder, named from its new folder.
729
+ const rebased = (relative: string): string => {
730
+ const target = locate(posix.normalize(posix.join(posix.dirname(from), relative)))
731
+ const next = posix.relative(posix.dirname(to), target) || "."
732
+ return relative.endsWith("/") && next !== "." ? `${next}/` : next
733
+ }
734
+ const follow = (anchor: TS.Node, start: number, end: number, replacement: string): void => {
735
+ followed.push([anchor.getStart(sf), anchor.getEnd()])
736
+ if (text.slice(start, end) !== replacement) edits.push({ start, end, text: replacement })
737
+ }
738
+
739
+ const visit = (node: TS.Node): void => {
740
+ if (ts.isCallExpression(node)) {
741
+ const [dir, ...parts] = node.arguments
742
+ const callee = node.expression.getText(sf)
743
+ const first = parts[0]
744
+ const last = parts[parts.length - 1]
745
+ if (
746
+ isOwnDir(dir) &&
747
+ /(?:^|\.)(?:join|resolve)$/.test(callee) &&
748
+ first !== undefined &&
749
+ last !== undefined &&
750
+ parts.every(isLiteral) &&
751
+ !(callee.endsWith("resolve") && parts.some((part) => part.text.startsWith("/")))
752
+ ) {
753
+ const next = rebased(posix.join(...parts.map((part) => part.text)))
754
+ const segments = parts.length === 1 ? [next] : next.split("/").filter((s) => s !== "")
755
+ const replacement = segments.map((segment) => quoted(first, segment)).join(", ")
756
+ follow(dir, first.getStart(sf), last.getEnd(), replacement)
757
+ return
758
+ }
759
+ }
760
+ if (
761
+ ts.isBinaryExpression(node) &&
762
+ node.operatorToken.kind === ts.SyntaxKind.PlusToken &&
763
+ isOwnDir(node.left) &&
764
+ isLiteral(node.right) &&
765
+ node.right.text.startsWith("/")
766
+ ) {
767
+ const next = rebased(node.right.text.slice(1) || ".")
768
+ follow(
769
+ node.left,
770
+ node.right.getStart(sf),
771
+ node.right.getEnd(),
772
+ quoted(node.right, `/${next}`),
773
+ )
774
+ return
775
+ }
776
+ if (ts.isTemplateExpression(node) && node.head.text === "" && node.templateSpans.length === 1) {
777
+ const span = node.templateSpans[0]
778
+ if (span !== undefined && isOwnDir(span.expression) && span.literal.text.startsWith("/")) {
779
+ const next = rebased(span.literal.text.slice(1) || ".")
780
+ const replacement = `\`\${${span.expression.getText(sf)}}/${next}\``
781
+ follow(span.expression, node.getStart(sf), node.getEnd(), replacement)
782
+ return
783
+ }
784
+ }
785
+ if (
786
+ ts.isNewExpression(node) &&
787
+ node.expression.getText(sf) === "URL" &&
788
+ node.arguments?.length === 2 &&
789
+ node.arguments[1]?.getText(sf) === "import.meta.url"
790
+ ) {
791
+ const path = node.arguments[0]
792
+ if (isLiteral(path) && !/^(?:\/|[a-z][a-z\d+.-]*:)/i.test(path.text)) {
793
+ const next = rebased(path.text)
794
+ const replacement = quoted(path, next.startsWith("..") ? next : `./${next}`)
795
+ follow(node.arguments[1], path.getStart(sf), path.getEnd(), replacement)
796
+ return
797
+ }
798
+ }
799
+ ts.forEachChild(node, visit)
800
+ }
801
+ visit(sf)
802
+
803
+ const unfollowed = [...text.matchAll(new RegExp(SELF_LOCATION.source, "g"))].find(
804
+ (match) => !followed.some(([start, end]) => start <= match.index && match.index < end),
805
+ )?.[0]
806
+ let next = text
807
+ for (const edit of edits.sort((a, b) => b.start - a.start))
808
+ next = next.slice(0, edit.start) + edit.text + next.slice(edit.end)
809
+ return { text: next, unfollowed }
810
+ }
811
+
812
+ /** Rewrite one relative specifier from `from`'s new location to `target`'s new location, keeping the
813
+ * author's style: extension kept when written, dropped when not, `/index` collapsed when it was. */
814
+ function respecify(
815
+ original: string,
816
+ oldTarget: string,
817
+ newFrom: string,
818
+ newTarget: string,
819
+ ): string {
820
+ const query = /[?#].*$/.exec(original)?.[0] ?? ""
821
+ const clean = original.slice(0, original.length - query.length)
822
+ let target = newTarget
823
+ const writtenExt = extname(clean)
824
+ const targetExt = extname(oldTarget)
825
+ if (writtenExt === "" || (writtenExt !== targetExt && !RESOLVE_EXTENSIONS.includes(writtenExt))) {
826
+ target = target.slice(0, target.length - extname(target).length)
827
+ if (
828
+ posix.basename(oldTarget).startsWith("index.") &&
829
+ !posix.basename(clean).startsWith("index")
830
+ ) {
831
+ target = posix.dirname(target)
832
+ }
833
+ } else if (writtenExt !== targetExt) {
834
+ target = target.slice(0, target.length - targetExt.length) + writtenExt
835
+ }
836
+ let rel = posix.relative(posix.dirname(newFrom), target)
837
+ if (!rel.startsWith(".")) rel = `./${rel}`
838
+ return rel + query
839
+ }
840
+
841
+ /** A relative specifier whose target does not move, rewritten for an importer that does. */
842
+ function rebase(specifier: string, from: string, newFrom: string): string | undefined {
843
+ if (!specifier.startsWith(".") || from === newFrom) return undefined
844
+ const target = posix.join(posix.dirname(from), specifier)
845
+ const rel = posix.relative(posix.dirname(newFrom), target)
846
+ return rel.startsWith(".") ? rel : `./${rel}`
847
+ }
848
+
849
+ function rewriteImports(
850
+ ts: typeof TS,
851
+ file: string,
852
+ text: string,
853
+ rewrite: (specifier: string) => string | undefined,
854
+ ): string {
855
+ const replace = (
856
+ code: string,
857
+ offset: number,
858
+ out: Array<{ start: number; end: number; text: string }>,
859
+ kind: TS.ScriptKind,
860
+ ): void => {
861
+ const sf = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true, kind)
862
+ const visit = (node: TS.Node): void => {
863
+ let literal: TS.StringLiteralLike | undefined
864
+ if (
865
+ (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
866
+ node.moduleSpecifier &&
867
+ ts.isStringLiteral(node.moduleSpecifier)
868
+ )
869
+ literal = node.moduleSpecifier
870
+ else if (
871
+ ts.isImportTypeNode(node) &&
872
+ ts.isLiteralTypeNode(node.argument) &&
873
+ ts.isStringLiteral(node.argument.literal)
874
+ )
875
+ literal = node.argument.literal
876
+ else if (
877
+ ts.isCallExpression(node) &&
878
+ (node.expression.kind === ts.SyntaxKind.ImportKeyword ||
879
+ (ts.isIdentifier(node.expression) && node.expression.text === "require"))
880
+ ) {
881
+ const arg = node.arguments[0]
882
+ if (arg !== undefined && ts.isStringLiteralLike(arg)) literal = arg
883
+ }
884
+ if (literal !== undefined) {
885
+ const next = rewrite(literal.text)
886
+ if (next !== undefined && next !== literal.text) {
887
+ out.push({
888
+ start: offset + literal.getStart(sf) + 1,
889
+ end: offset + literal.getEnd() - 1,
890
+ text: next,
891
+ })
892
+ }
893
+ }
894
+ ts.forEachChild(node, visit)
895
+ }
896
+ visit(sf)
897
+ }
898
+ const edits: Array<{ start: number; end: number; text: string }> = []
899
+ if (/\.(?:svelte|vue)$/.test(file)) {
900
+ for (const block of scriptBlocks(text))
901
+ replace(text.slice(block.start, block.end), block.start, edits, ts.ScriptKind.TS)
902
+ } else if (file.endsWith(".css")) {
903
+ for (const match of text.matchAll(/(@import\s+(?:url\()?["'])([^"']+)["']/g)) {
904
+ const next = rewrite(match[2] ?? "")
905
+ if (next !== undefined) {
906
+ const start = (match.index ?? 0) + (match[1] ?? "").length
907
+ edits.push({ start, end: start + (match[2] ?? "").length, text: next })
908
+ }
909
+ }
910
+ } else if (file.endsWith(".mdx")) {
911
+ for (const match of text.matchAll(/^((?:import|export)\s[^\n]*?from\s+["'])([^"']+)["']/gm)) {
912
+ const next = rewrite(match[2] ?? "")
913
+ if (next !== undefined) {
914
+ const start = (match.index ?? 0) + (match[1] ?? "").length
915
+ edits.push({ start, end: start + (match[2] ?? "").length, text: next })
916
+ }
917
+ }
918
+ } else if (/\.[cm]?[jt]sx?$/.test(file)) {
919
+ replace(text, 0, edits, file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
920
+ }
921
+ let out = text
922
+ for (const edit of edits.sort((a, b) => b.start - a.start))
923
+ out = out.slice(0, edit.start) + edit.text + out.slice(edit.end)
924
+ return out
925
+ }
926
+
927
+ /** `ServerOnly` imported from `@nifrajs/web` becomes `BackendOnly`, along with every use of it. */
928
+ function renameRetiredTypes(ts: typeof TS, file: string, text: string): string {
929
+ if (!text.includes("ServerOnly")) return text
930
+ const edits: Array<{ start: number; end: number; text: string }> = []
931
+ const scan = (code: string, offset: number, kind: TS.ScriptKind): void => {
932
+ const sf = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true, kind)
933
+ const rename = (node: TS.Node): void => {
934
+ edits.push({
935
+ start: offset + node.getStart(sf),
936
+ end: offset + node.getEnd(),
937
+ text: "BackendOnly",
938
+ })
939
+ }
940
+ let unaliased = false
941
+ for (const statement of sf.statements) {
942
+ if (
943
+ !ts.isImportDeclaration(statement) ||
944
+ !ts.isStringLiteral(statement.moduleSpecifier) ||
945
+ statement.moduleSpecifier.text !== "@nifrajs/web"
946
+ )
947
+ continue
948
+ const bindings = statement.importClause?.namedBindings
949
+ if (bindings === undefined || !ts.isNamedImports(bindings)) continue
950
+ for (const element of bindings.elements) {
951
+ if (element.propertyName?.text === "ServerOnly") rename(element.propertyName)
952
+ else if (element.propertyName === undefined && element.name.text === "ServerOnly")
953
+ unaliased = true
954
+ }
955
+ }
956
+ if (!unaliased) return
957
+ const visit = (node: TS.Node): void => {
958
+ if (ts.isIdentifier(node) && node.text === "ServerOnly") rename(node)
959
+ ts.forEachChild(node, visit)
960
+ }
961
+ visit(sf)
962
+ }
963
+ if (/\.(?:svelte|vue)$/.test(file)) {
964
+ for (const block of scriptBlocks(text))
965
+ scan(text.slice(block.start, block.end), block.start, ts.ScriptKind.TS)
966
+ } else if (/\.[cm]?[jt]sx?$/.test(file)) {
967
+ scan(text, 0, file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
968
+ }
969
+ let out = text
970
+ for (const edit of edits.sort((a, b) => b.start - a.start))
971
+ out = out.slice(0, edit.start) + edit.text + out.slice(edit.end)
972
+ return out
973
+ }
974
+
975
+ // ---------------------------------------------------------------------------------------------------
976
+ // The migration
977
+ // ---------------------------------------------------------------------------------------------------
978
+
979
+ const ROOT_MOVES: Readonly<Record<string, string>> = {
980
+ "backend.ts": "backend/app.ts",
981
+ "framework.ts": "backend/framework.ts",
982
+ }
983
+ /** Root files that stay where they are: the CLI's config, and scripts no build loads. */
984
+ const isRootScript = (file: string): boolean =>
985
+ !file.includes("/") && SOURCE_FILE.test(file) && ROOT_MOVES[file] === undefined
986
+ const isTestFile = (file: string): boolean =>
987
+ /(?:^|\/)(?:test|tests|__tests__)\//.test(file) || /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file)
988
+
989
+ export async function migrateLayout(
990
+ appRoot: string,
991
+ options: LayoutMigrationOptions = {},
992
+ ): Promise<LayoutMigrationResult> {
993
+ const ts = options.typescript ?? (await importTypeScript())
994
+ if (ts === undefined)
995
+ throw new Error("[nifra] `nifra migrate layout` needs TypeScript: bun add -d typescript")
996
+ const root = resolve(appRoot)
997
+ if (!existsSync(join(root, "routes"))) throw new Error(`[nifra] no routes/ directory in ${root}`)
998
+ const original: Files = new Map()
999
+ const everyFile = listFiles(root)
1000
+ for (const file of everyFile) {
1001
+ if (SOURCE_FILE.test(file) || file.endsWith(".css"))
1002
+ original.set(file, readFileSync(join(root, file), "utf8"))
1003
+ }
1004
+ const issues: LayoutMigrationIssue[] = []
1005
+ const splits: Array<{ file: string; backend: string; moved: string[] }> = []
1006
+ /** The app after the migration: new path -> text. */
1007
+ const next: Files = new Map(original)
1008
+ /** Old path -> new path, for every file that moves. */
1009
+ const moveMap = new Map<string, string>()
1010
+
1011
+ // 1-2. Route halves and middleware.
1012
+ const backendHalves = new Map<string, string>()
1013
+ for (const [file, text] of original) {
1014
+ if (!file.startsWith("routes/") || file.includes(".backend.")) continue
1015
+ if (/(?:^|\/)_middleware\.[cm]?[jt]sx?$/.test(file)) {
1016
+ const target = posix.join(posix.dirname(file), "_layout.backend.ts")
1017
+ backendHalves.set(
1018
+ target,
1019
+ mergeModules(ts, backendHalves.get(target) ?? "", middlewareSource(ts, text, file)),
1020
+ )
1021
+ next.delete(file)
1022
+ splits.push({ file, backend: target, moved: ["middleware"] })
1023
+ continue
1024
+ }
1025
+ if (!ROUTE_FILE.test(file)) continue
1026
+ const backendFile = file.replace(ROUTE_FILE, ".backend.ts")
1027
+ const specifier = `./${posix.basename(backendFile)}`
1028
+ let result: SplitResult
1029
+ if (/\.(?:svelte|vue)$/.test(file)) {
1030
+ const blocks = scriptBlocks(text)
1031
+ const block =
1032
+ blocks.find((b) => b.module) ?? blocks.find((b) => !b.setup && file.endsWith(".vue"))
1033
+ if (block === undefined) continue
1034
+ const inner = splitRouteSource(ts, text.slice(block.start, block.end), "block.ts", specifier)
1035
+ result = {
1036
+ ...inner,
1037
+ frontend: `${text.slice(0, block.start)}\n${inner.frontend}${text.slice(block.end)}`,
1038
+ }
1039
+ } else if (file.endsWith(".mdx")) {
1040
+ if (
1041
+ /^export\s+(?:async\s+)?(?:const|function|let)\s+(?:loader|action|getStaticPaths|prerender|revalidate)\b/m.test(
1042
+ text,
1043
+ )
1044
+ ) {
1045
+ issues.push({
1046
+ file,
1047
+ reason: "an MDX route exports server code; move it to the route's .backend.ts by hand",
1048
+ })
1049
+ }
1050
+ continue
1051
+ } else {
1052
+ result = splitRouteSource(ts, text, file, specifier)
1053
+ }
1054
+ if (result.backend === undefined) continue
1055
+ next.set(file, result.frontend)
1056
+ backendHalves.set(
1057
+ backendFile,
1058
+ mergeModules(ts, backendHalves.get(backendFile) ?? "", result.backend),
1059
+ )
1060
+ splits.push({ file, backend: backendFile, moved: [...result.moved] })
1061
+ for (const reason of result.issues) issues.push({ file, reason })
1062
+ }
1063
+ for (const [file, text] of backendHalves) {
1064
+ const existing = next.get(file)
1065
+ next.set(file, existing === undefined ? text : mergeModules(ts, existing, text))
1066
+ }
1067
+
1068
+ // 3-4. Zoning by runtime reachability, on the post-split graph.
1069
+ for (const [from, to] of Object.entries(ROOT_MOVES)) if (next.has(from)) moveMap.set(from, to)
1070
+ // Assets (images, fonts) resolve too, so they move with the modules that import them.
1071
+ const known = new Set([...next.keys(), ...everyFile])
1072
+ const edges = new Map<string, Array<{ target: string; typeOnly: boolean }>>()
1073
+ for (const [file, text] of next) {
1074
+ const refs: Array<{ target: string; typeOnly: boolean }> = []
1075
+ for (const ref of importsOf(ts, file, text)) {
1076
+ if (ref.specifier === "@nifrajs/web/plugins/vite-server-only") {
1077
+ issues.push({
1078
+ file,
1079
+ reason:
1080
+ "imports the retired @nifrajs/web/plugins/vite-server-only; remove it: nifra's Vite builds and dev server enforce the zones themselves (a hand-built Vite config uses viteLeakGuard from @nifrajs/web/plugins/vite-leak-guard)",
1081
+ })
1082
+ }
1083
+ const target = resolveIn(known, file, ref.specifier)
1084
+ if (target !== undefined) refs.push({ target, typeOnly: ref.typeOnly })
1085
+ else if (/^[@~]\//.test(ref.specifier))
1086
+ issues.push({
1087
+ file,
1088
+ reason: `the path alias "${ref.specifier}" is left as written; point it at the new location by hand`,
1089
+ })
1090
+ }
1091
+ edges.set(file, refs)
1092
+ }
1093
+ const files = [...new Set([...next.keys(), ...everyFile.filter((f) => !original.has(f))])]
1094
+ const frontendRoots = files.filter(
1095
+ (f) => f.startsWith("routes/") && ROUTE_FILE.test(f) && !f.includes(".backend."),
1096
+ )
1097
+ // A route page the server imports - a generated `server-manifest.ts` imports them all - is rendered
1098
+ // there, not run as server code, so the server's walk stops at it: what only pages use stays frontend.
1099
+ const pages = new Set(frontendRoots)
1100
+ const reach = (roots: readonly string[], stopAt?: ReadonlySet<string>): Set<string> => {
1101
+ const seen = new Set<string>()
1102
+ const queue = [...roots]
1103
+ while (queue.length > 0) {
1104
+ const file = queue.pop() as string
1105
+ if (seen.has(file)) continue
1106
+ seen.add(file)
1107
+ if (stopAt?.has(file)) continue
1108
+ for (const edge of edges.get(file) ?? []) if (!edge.typeOnly) queue.push(edge.target)
1109
+ }
1110
+ return seen
1111
+ }
1112
+ const backendRoots = files.filter(
1113
+ (f) =>
1114
+ (f.startsWith("routes/") && f.includes(".backend.")) ||
1115
+ f.startsWith("backend/") ||
1116
+ ROOT_MOVES[f] !== undefined ||
1117
+ (isRootScript(f) && f !== "nifra.config.ts" && !isTestFile(f)),
1118
+ )
1119
+ const browser = reach(frontendRoots)
1120
+ const server = reach(backendRoots, pages)
1121
+ // What the app's own server modules reach, without the root scripts: a root file in here is a module
1122
+ // the app imports, not an entry, so it moves into a zone.
1123
+ const appServer = reach(
1124
+ backendRoots.filter((f) => f.includes("/") || ROOT_MOVES[f] !== undefined),
1125
+ pages,
1126
+ )
1127
+ // A `.server` module ran empty in the browser, so only server code used it: it moves under backend/.
1128
+ for (const file of files) {
1129
+ if (!LEGACY_SERVER.test(file) || isTestFile(file)) continue
1130
+ const parts = file.replace(LEGACY_SERVER, "$1").split("/")
1131
+ if (parts.length > 1 && ZONE_DIRS.has(parts[0] ?? "")) parts.shift()
1132
+ const to = `backend/${parts.join("/")}`
1133
+ moveMap.set(file, to)
1134
+ if (browser.has(file)) {
1135
+ issues.push({
1136
+ file,
1137
+ reason: `a route frontend still imports it at runtime, where it ran empty as a ".server" module; move that use into the route's backend half before it moves to ${to}`,
1138
+ })
1139
+ }
1140
+ }
1141
+ const zoneOf = (file: string): "frontend" | "backend" | "shared" =>
1142
+ browser.has(file) && server.has(file) ? "shared" : browser.has(file) ? "frontend" : "backend"
1143
+ for (const file of files) {
1144
+ if (
1145
+ moveMap.has(file) ||
1146
+ isTestFile(file) ||
1147
+ file.endsWith(".d.ts") ||
1148
+ file === "nifra.config.ts"
1149
+ )
1150
+ continue
1151
+ if (/\.(?:frontend|backend|shared|fn)\.[cm]?[jt]sx?$/.test(file)) continue
1152
+ const top = file.split("/")[0] ?? ""
1153
+ if (file.includes("/")) {
1154
+ if (ZONE_DIRS.has(top) || (!browser.has(file) && !server.has(file))) continue
1155
+ } else if (!browser.has(file) && !appServer.has(file)) continue
1156
+ moveMap.set(file, `${zoneOf(file)}/${file}`)
1157
+ }
1158
+ const claimed = new Map<string, string>()
1159
+ for (const [from, to] of moveMap) {
1160
+ const holder = claimed.get(to) ?? (known.has(to) && !moveMap.has(to) ? to : undefined)
1161
+ if (holder === undefined) {
1162
+ claimed.set(to, from)
1163
+ continue
1164
+ }
1165
+ issues.push({
1166
+ file: from,
1167
+ reason: `would move to "${to}", which ${holder === to ? "already exists" : `"${holder}" moves to as well`}; move it by hand`,
1168
+ })
1169
+ moveMap.delete(from)
1170
+ }
1171
+ for (const [from, to] of moveMap) {
1172
+ if (to.startsWith("shared/") && /from\s+["'](?:node:|bun:)/.test(next.get(from) ?? "")) {
1173
+ issues.push({
1174
+ file: from,
1175
+ reason: `both halves import it at runtime, but it uses a server built-in; split it before moving to ${to}`,
1176
+ })
1177
+ }
1178
+ }
1179
+
1180
+ // 5. Rewrite imports to the new locations.
1181
+ const newPath = (file: string): string => moveMap.get(file) ?? file
1182
+ const final: Files = new Map()
1183
+ const rewritten: string[] = []
1184
+ for (const [file, text] of next) {
1185
+ const imports = rewriteImports(ts, file, text, (specifier) => {
1186
+ const retired = RETIRED_SPECIFIERS[specifier]
1187
+ if (retired !== undefined) return retired
1188
+ const target = resolveIn(known, file, specifier)
1189
+ // Outside the app (or a skipped dir): the target stays put, so only a moved importer re-bases it.
1190
+ if (target === undefined) return rebase(specifier, file, newPath(file))
1191
+ if (newPath(target) === target && newPath(file) === file) return undefined
1192
+ return respecify(specifier, target, newPath(file), newPath(target))
1193
+ })
1194
+ const updated = renameRetiredTypes(ts, file, imports)
1195
+ final.set(newPath(file), updated)
1196
+ if (updated !== text && !moveMap.has(file) && original.get(file) === text) rewritten.push(file)
1197
+ }
1198
+
1199
+ const moves = [...moveMap]
1200
+ .map(([from, to]) => ({ from, to }))
1201
+ .sort((a, b) => codeUnitOrder(a.from, b.from))
1202
+
1203
+ // A moved module's paths from its own location now start from its new folder: re-point the ones it
1204
+ // spells out, and report the first one it cannot follow.
1205
+ const unfollowed = new Map<string, string>()
1206
+ for (const { from, to } of moves) {
1207
+ const text = final.get(to)
1208
+ if (text === undefined || !SELF_LOCATION.test(text)) continue
1209
+ if (!/\.[cm]?[jt]sx?$/.test(to)) {
1210
+ unfollowed.set(to, SELF_LOCATION.exec(text)?.[0] ?? "")
1211
+ continue
1212
+ }
1213
+ const result = rebaseOwnPaths(ts, from, to, text, (target) =>
1214
+ known.has(target) ? newPath(target) : target,
1215
+ )
1216
+ final.set(to, result.text)
1217
+ if (result.unfollowed !== undefined) unfollowed.set(to, result.unfollowed)
1218
+ }
1219
+
1220
+ // A path in a string or comment is not an import, so nothing above rewrote it: a build script that
1221
+ // writes `data/x.json` would keep writing to the old place, and so would a deploy script or
1222
+ // `package.json` beside the code.
1223
+ const settled = new Set([...known].map(newPath))
1224
+ const scanned: Array<readonly [string, string]> = [...final]
1225
+ for (const file of everyFile) {
1226
+ if (
1227
+ !original.has(file) &&
1228
+ TOOLING_FILE.test(file) &&
1229
+ statSync(join(root, file)).size <= 256_000
1230
+ )
1231
+ scanned.push([newPath(file), readFileSync(join(root, file), "utf8")])
1232
+ }
1233
+ for (const [file, text] of scanned) {
1234
+ let live: ReadonlyArray<readonly [number, number]> | undefined
1235
+ for (const [from, to] of moveMap) {
1236
+ // `shared/lib/x.ts` contains `lib/x.ts`; that is the new path, not a stale one.
1237
+ const newPrefix = to.endsWith(from) ? to.slice(0, to.length - from.length) : undefined
1238
+ const stale = [
1239
+ ...text.matchAll(new RegExp(`(?<![\\w.-])${escapeRegExp(from)}(?![\\w])`, "g")),
1240
+ ].some((match) => {
1241
+ if (newPrefix !== undefined && text.slice(0, match.index).endsWith(newPrefix)) return false
1242
+ // An import that resolves after the moves is current, even when it still reads like the old
1243
+ // path: `./x.json` from a module that moved together with `x.json`.
1244
+ live ??= liveSpecifierSpans(ts, file, text, settled)
1245
+ const end = match.index + from.length
1246
+ return !live.some(([start, stop]) => start <= match.index && end <= stop)
1247
+ })
1248
+ if (stale) issues.push({ file, reason: `mentions "${from}" by path; it moved to "${to}"` })
1249
+ }
1250
+ }
1251
+
1252
+ // Paths a module finds at run time are not imports either: one built from the module's own location
1253
+ // now starts from its new folder, and a folder listed at run time loses whatever moved out of it.
1254
+ const leaving = new Map<string, Array<{ from: string; to: string }>>()
1255
+ for (const move of moves) {
1256
+ const own = unfollowed.get(move.to)
1257
+ if (own !== undefined) {
1258
+ issues.push({
1259
+ file: move.to,
1260
+ reason: `builds a path from its own location (${own}) that the migration could not re-point, and it moved from "${move.from}"; check that path by hand`,
1261
+ })
1262
+ }
1263
+ const dir = posix.dirname(move.from)
1264
+ if (dir === "." || ZONE_DIRS.has(dir.split("/")[0] ?? "")) continue
1265
+ leaving.set(dir, [...(leaving.get(dir) ?? []), move])
1266
+ }
1267
+ for (const [dir, group] of leaving) {
1268
+ const staying = files.filter(
1269
+ (f) => posix.dirname(f) === dir && !moveMap.has(f) && !isTestFile(f) && !f.endsWith(".d.ts"),
1270
+ )
1271
+ const first = group[0]
1272
+ if (staying.length === 0 || first === undefined) continue
1273
+ const others = group.length === 1 ? "" : ` with ${group.length - 1} other file(s)`
1274
+ const left =
1275
+ staying.length === 1
1276
+ ? `"${staying[0]}" stays`
1277
+ : `${staying.length} files such as "${staying[0]}" stay`
1278
+ issues.push({
1279
+ file: first.from,
1280
+ reason: `moves to "${first.to}"${others} but ${left} in ${dir}/; code or tooling that lists ${dir}/ no longer finds what moved - move the folder together by hand`,
1281
+ })
1282
+ }
1283
+
1284
+ // The server refuses data a loader or action returns without an output schema; only the author
1285
+ // knows which fields the browser may see.
1286
+ for (const [file, text] of final) {
1287
+ if (!file.startsWith("routes/") || !/\.backend\.[cm]?[jt]s$/.test(file)) continue
1288
+ for (const { name, schema } of missingOutputSchemas(text)) {
1289
+ issues.push({
1290
+ file,
1291
+ reason: `exports ${name === "action" ? "an" : "a"} ${name} but no ${schema}, so data it returns fails the request. Declare what the browser may see: export const ${schema} = t.object({ ... })`,
1292
+ })
1293
+ }
1294
+ }
1295
+
1296
+ if (options.write === true) {
1297
+ for (const [file, text] of final) {
1298
+ if (original.get(file) === text) continue
1299
+ const path = join(root, file)
1300
+ mkdirSync(dirname(path), { recursive: true })
1301
+ writeFileSync(path, text)
1302
+ }
1303
+ for (const file of original.keys()) {
1304
+ if (!final.has(file)) rmSync(join(root, file), { force: true })
1305
+ }
1306
+ for (const { from, to } of moves) {
1307
+ if (original.has(from) || !existsSync(join(root, from))) continue
1308
+ mkdirSync(dirname(join(root, to)), { recursive: true })
1309
+ renameSync(join(root, from), join(root, to))
1310
+ }
1311
+ pruneEmptyDirs(root)
1312
+ }
1313
+ return {
1314
+ ok: issues.length === 0,
1315
+ write: options.write === true,
1316
+ splits: splits.sort((a, b) => codeUnitOrder(a.file, b.file)),
1317
+ moves,
1318
+ rewritten: rewritten.sort(),
1319
+ issues,
1320
+ }
1321
+ }
1322
+
1323
+ function pruneEmptyDirs(root: string): void {
1324
+ const prune = (dir: string): boolean => {
1325
+ let empty = true
1326
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1327
+ if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) {
1328
+ empty = false
1329
+ continue
1330
+ }
1331
+ const full = join(dir, entry.name)
1332
+ if (entry.isDirectory() ? !prune(full) : true) empty = false
1333
+ }
1334
+ if (empty && dir !== root) rmSync(dir, { recursive: true, force: true })
1335
+ return empty
1336
+ }
1337
+ prune(root)
1338
+ }