@nifrajs/cli 3.5.0 → 4.0.0

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