@nifrajs/cli 3.3.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.
- package/README.md +42 -3
- package/dist/app-files.d.ts +11 -0
- package/dist/app-files.d.ts.map +1 -0
- package/dist/app-files.js +34 -0
- package/dist/app-files.js.map +1 -0
- package/dist/assure-hydration.d.ts +7 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +128 -43
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +16 -2
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts +5 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +32 -10
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/cdn-check.d.ts +41 -0
- package/dist/cdn-check.d.ts.map +1 -0
- package/dist/cdn-check.js +207 -0
- package/dist/cdn-check.js.map +1 -0
- package/dist/check-diagnostics.d.ts +7 -15
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +162 -713
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +58 -7
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +323 -62
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts +1 -0
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +145 -54
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +42 -16
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +306 -87
- package/dist/cli.js.map +1 -1
- package/dist/command-catalog.d.ts +60 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +298 -33
- package/dist/command-catalog.js.map +1 -1
- package/dist/config-target.d.ts +8 -0
- package/dist/config-target.d.ts.map +1 -0
- package/dist/config-target.js +45 -0
- package/dist/config-target.js.map +1 -0
- package/dist/contracts.d.ts +3 -2
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +56 -16
- package/dist/contracts.js.map +1 -1
- package/dist/db-audit.d.ts +37 -0
- package/dist/db-audit.d.ts.map +1 -0
- package/dist/db-audit.js +99 -0
- package/dist/db-audit.js.map +1 -0
- package/dist/db-child.d.ts +64 -0
- package/dist/db-child.d.ts.map +1 -0
- package/dist/db-child.js +213 -0
- package/dist/db-child.js.map +1 -0
- package/dist/db-config.d.ts +60 -0
- package/dist/db-config.d.ts.map +1 -0
- package/dist/db-config.js +176 -0
- package/dist/db-config.js.map +1 -0
- package/dist/db-tool.d.ts +84 -0
- package/dist/db-tool.d.ts.map +1 -0
- package/dist/db-tool.js +537 -0
- package/dist/db-tool.js.map +1 -0
- package/dist/dev-bun-config.d.ts +20 -13
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +98 -41
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/dev-feed-tool.d.ts +106 -0
- package/dist/dev-feed-tool.d.ts.map +1 -0
- package/dist/dev-feed-tool.js +713 -0
- package/dist/dev-feed-tool.js.map +1 -0
- package/dist/dev-server-client.d.ts +67 -0
- package/dist/dev-server-client.d.ts.map +1 -0
- package/dist/dev-server-client.js +195 -0
- package/dist/dev-server-client.js.map +1 -0
- package/dist/diagnostics.d.ts +86 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +85 -0
- package/dist/diagnostics.js.map +1 -1
- package/dist/diff-tool.d.ts +3 -3
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +6 -5
- package/dist/diff-tool.js.map +1 -1
- package/dist/docs-search.d.ts.map +1 -1
- package/dist/docs-search.js +12 -4
- package/dist/docs-search.js.map +1 -1
- package/dist/doctor.d.ts +17 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +76 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +15 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +73 -1
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +41 -21
- package/dist/fix-recipes.js.map +1 -1
- package/dist/frontend-guidance.js +5 -5
- package/dist/frontend-guidance.js.map +1 -1
- package/dist/i18n-check.d.ts +16 -0
- package/dist/i18n-check.d.ts.map +1 -0
- package/dist/i18n-check.js +154 -0
- package/dist/i18n-check.js.map +1 -0
- package/dist/init-agents.d.ts +49 -13
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +239 -34
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/code-unit-order.d.ts +4 -0
- package/dist/internal/code-unit-order.d.ts.map +1 -0
- package/dist/internal/code-unit-order.js +6 -0
- package/dist/internal/code-unit-order.js.map +1 -0
- package/dist/internal/script-kind.d.ts +7 -0
- package/dist/internal/script-kind.d.ts.map +1 -0
- package/dist/internal/script-kind.js +12 -0
- package/dist/internal/script-kind.js.map +1 -0
- package/dist/internal/source-facts.d.ts +5 -0
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +61 -5
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +49 -24
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +507 -67
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -4
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +93 -74
- package/dist/introspect.js.map +1 -1
- package/dist/learn.js +3 -3
- package/dist/learn.js.map +1 -1
- package/dist/levels-tool.d.ts +10 -0
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +33 -3
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +71 -16
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +78 -18
- package/dist/load.js.map +1 -1
- package/dist/manifest-tool.d.ts.map +1 -1
- package/dist/manifest-tool.js +3 -2
- package/dist/manifest-tool.js.map +1 -1
- package/dist/mcp-app-tool.d.ts.map +1 -1
- package/dist/mcp-app-tool.js +2 -0
- package/dist/mcp-app-tool.js.map +1 -1
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +5 -6
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-delegate.d.ts +57 -0
- package/dist/mcp-delegate.d.ts.map +1 -0
- package/dist/mcp-delegate.js +130 -0
- package/dist/mcp-delegate.js.map +1 -0
- package/dist/mcp-docs-tools.d.ts.map +1 -1
- package/dist/mcp-docs-tools.js +23 -5
- package/dist/mcp-docs-tools.js.map +1 -1
- package/dist/mcp-exec.d.ts +3 -0
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +263 -131
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.d.ts.map +1 -1
- package/dist/mcp-http.js +20 -5
- package/dist/mcp-http.js.map +1 -1
- package/dist/mcp-io.d.ts +26 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +113 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts +48 -0
- package/dist/mcp-isolate.d.ts.map +1 -0
- package/dist/mcp-isolate.js +222 -0
- package/dist/mcp-isolate.js.map +1 -0
- package/dist/mcp-path.d.ts +10 -0
- package/dist/mcp-path.d.ts.map +1 -0
- package/dist/mcp-path.js +51 -0
- package/dist/mcp-path.js.map +1 -0
- package/dist/mcp-project-child.d.ts +74 -0
- package/dist/mcp-project-child.d.ts.map +1 -0
- package/dist/mcp-project-child.js +130 -0
- package/dist/mcp-project-child.js.map +1 -0
- package/dist/mcp-reflect.d.ts.map +1 -1
- package/dist/mcp-reflect.js +2 -2
- package/dist/mcp-reflect.js.map +1 -1
- package/dist/mcp-render.d.ts.map +1 -1
- package/dist/mcp-render.js +64 -45
- package/dist/mcp-render.js.map +1 -1
- package/dist/mcp-root.d.ts +37 -9
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +124 -25
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-run.d.ts +18 -1
- package/dist/mcp-run.d.ts.map +1 -1
- package/dist/mcp-run.js +159 -55
- package/dist/mcp-run.js.map +1 -1
- package/dist/mcp-ws.js +4 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts +18 -5
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +165 -82
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +44 -0
- package/dist/migrate-layout.d.ts.map +1 -0
- package/dist/migrate-layout.js +982 -0
- package/dist/migrate-layout.js.map +1 -0
- package/dist/openapi-tool.d.ts +2 -2
- package/dist/openapi-tool.d.ts.map +1 -1
- package/dist/openapi-tool.js.map +1 -1
- package/dist/openapi-types.d.ts +2 -1
- package/dist/openapi-types.d.ts.map +1 -1
- package/dist/openapi-types.js +240 -12
- package/dist/openapi-types.js.map +1 -1
- package/dist/pipeline-report.d.ts +3 -2
- package/dist/pipeline-report.d.ts.map +1 -1
- package/dist/pipeline-report.js +19 -16
- package/dist/pipeline-report.js.map +1 -1
- package/dist/port.d.ts +5 -4
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +46 -29
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +28 -4
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js +12 -2
- package/dist/project-facts.js.map +1 -1
- package/dist/recipes/4.0.0.d.ts +9 -0
- package/dist/recipes/4.0.0.d.ts.map +1 -0
- package/dist/recipes/4.0.0.js +34 -0
- package/dist/recipes/4.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts.map +1 -1
- package/dist/recipes/index.js +2 -0
- package/dist/recipes/index.js.map +1 -1
- package/dist/release-verification.d.ts +11 -1
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +47 -4
- package/dist/release-verification.js.map +1 -1
- package/dist/review.d.ts +21 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/review.js +823 -0
- package/dist/review.js.map +1 -0
- package/dist/route-types.d.ts +28 -0
- package/dist/route-types.d.ts.map +1 -0
- package/dist/route-types.js +106 -0
- package/dist/route-types.js.map +1 -0
- package/dist/rules/codes.d.ts +9 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +9 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/data-guard.d.ts +12 -0
- package/dist/rules/data-guard.d.ts.map +1 -0
- package/dist/rules/data-guard.js +213 -0
- package/dist/rules/data-guard.js.map +1 -0
- package/dist/rules/index.d.ts +3 -0
- package/dist/rules/index.d.ts.map +1 -1
- package/dist/rules/index.js +17 -5
- package/dist/rules/index.js.map +1 -1
- package/dist/rules/islands.d.ts.map +1 -1
- package/dist/rules/islands.js +7 -4
- package/dist/rules/islands.js.map +1 -1
- package/dist/rules/legacy.d.ts +3 -1
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +829 -34
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/nano.d.ts.map +1 -1
- package/dist/rules/nano.js +15 -6
- package/dist/rules/nano.js.map +1 -1
- package/dist/rules/pages.d.ts +15 -0
- package/dist/rules/pages.d.ts.map +1 -0
- package/dist/rules/pages.js +88 -0
- package/dist/rules/pages.js.map +1 -0
- package/dist/rules/routes.d.ts +11 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +180 -2
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.d.ts +10 -0
- package/dist/rules/secrets.d.ts.map +1 -0
- package/dist/rules/secrets.js +169 -0
- package/dist/rules/secrets.js.map +1 -0
- package/dist/rules/security.d.ts +7 -0
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +171 -65
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/typescript.d.ts +16 -0
- package/dist/rules/typescript.d.ts.map +1 -0
- package/dist/rules/typescript.js +59 -0
- package/dist/rules/typescript.js.map +1 -0
- package/dist/rules/zones.d.ts +17 -0
- package/dist/rules/zones.d.ts.map +1 -0
- package/dist/rules/zones.js +174 -0
- package/dist/rules/zones.js.map +1 -0
- package/dist/scaffold.d.ts +25 -12
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +154 -90
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts +17 -3
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +941 -153
- package/dist/sdk.js.map +1 -1
- package/dist/search-terms.d.ts +2 -0
- package/dist/search-terms.d.ts.map +1 -1
- package/dist/search-terms.js +23 -8
- package/dist/search-terms.js.map +1 -1
- package/dist/smoke.d.ts +79 -0
- package/dist/smoke.d.ts.map +1 -0
- package/dist/smoke.js +494 -0
- package/dist/smoke.js.map +1 -0
- package/dist/sync-manifest.d.ts.map +1 -1
- package/dist/sync-manifest.js +2 -1
- package/dist/sync-manifest.js.map +1 -1
- package/dist/sync-routes.d.ts +4 -3
- package/dist/sync-routes.d.ts.map +1 -1
- package/dist/sync-routes.js +4 -3
- package/dist/sync-routes.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +30 -11
- package/dist/test-tool.js.map +1 -1
- package/dist/types-search.d.ts.map +1 -1
- package/dist/types-search.js +11 -5
- package/dist/types-search.js.map +1 -1
- package/dist/upgrade.d.ts +36 -6
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +167 -29
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +8 -0
- package/dist/verification-plan.js.map +1 -1
- package/dist/verification.d.ts.map +1 -1
- package/dist/verification.js +45 -12
- package/dist/verification.js.map +1 -1
- package/dist/web-app-options.d.ts +19 -0
- package/dist/web-app-options.d.ts.map +1 -0
- package/dist/web-app-options.js +49 -0
- package/dist/web-app-options.js.map +1 -0
- package/dist/work-graph.d.ts +3 -0
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +33 -16
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +185 -29
- package/docs/llms-full.txt +4123 -548
- package/docs/types.json +14391 -10060
- package/package.json +19 -11
- package/src/app-files.ts +40 -0
- package/src/assure-hydration.ts +139 -46
- package/src/assure.ts +17 -2
- package/src/capabilities-tool.ts +35 -9
- package/src/cdn-check.ts +268 -0
- package/src/check-diagnostics.ts +199 -797
- package/src/check-scan.ts +394 -63
- package/src/check.ts +146 -58
- package/src/cli.ts +347 -92
- package/src/command-catalog.ts +407 -45
- package/src/config-target.ts +47 -0
- package/src/contracts.ts +66 -15
- package/src/db-audit.ts +128 -0
- package/src/db-child.ts +285 -0
- package/src/db-config.ts +224 -0
- package/src/db-tool.ts +690 -0
- package/src/dev-bun-config.ts +116 -40
- package/src/dev-feed-tool.ts +912 -0
- package/src/dev-server-client.ts +267 -0
- package/src/diagnostics.ts +179 -0
- package/src/diff-tool.ts +6 -5
- package/src/docs-search.ts +10 -3
- package/src/doctor.ts +98 -15
- package/src/env-file.ts +78 -1
- package/src/fix-recipes.ts +44 -31
- package/src/frontend-guidance.ts +5 -5
- package/src/i18n-check.ts +179 -0
- package/src/init-agents.ts +301 -38
- package/src/internal/code-unit-order.ts +5 -0
- package/src/internal/script-kind.ts +11 -0
- package/src/internal/source-facts.ts +96 -22
- package/src/internal/typescript-import.ts +663 -68
- package/src/introspect.ts +93 -82
- package/src/learn.ts +3 -3
- package/src/levels-tool.ts +47 -2
- package/src/load.ts +144 -32
- package/src/manifest-tool.ts +3 -2
- package/src/mcp-app-tool.ts +2 -0
- package/src/mcp-context.ts +5 -7
- package/src/mcp-delegate.ts +162 -0
- package/src/mcp-docs-tools.ts +23 -5
- package/src/mcp-exec.ts +301 -161
- package/src/mcp-http.ts +22 -5
- package/src/mcp-io.ts +130 -0
- package/src/mcp-isolate.ts +287 -0
- package/src/mcp-path.ts +50 -0
- package/src/mcp-project-child.ts +182 -0
- package/src/mcp-reflect.ts +3 -2
- package/src/mcp-render.ts +72 -42
- package/src/mcp-root.ts +130 -24
- package/src/mcp-run.ts +190 -56
- package/src/mcp-ws.ts +4 -1
- package/src/mcp.ts +193 -91
- package/src/migrate-layout.ts +1121 -0
- package/src/openapi-tool.ts +7 -3
- package/src/openapi-types.ts +315 -13
- package/src/pipeline-report.ts +22 -18
- package/src/port.ts +53 -30
- package/src/project-facts.ts +42 -8
- package/src/recipes/4.0.0.ts +35 -0
- package/src/recipes/index.ts +2 -0
- package/src/release-verification.ts +74 -5
- package/src/review.ts +988 -0
- package/src/route-types.ts +133 -0
- package/src/rules/codes.ts +9 -0
- package/src/rules/data-guard.ts +230 -0
- package/src/rules/index.ts +18 -5
- package/src/rules/islands.ts +12 -4
- package/src/rules/legacy.ts +947 -35
- package/src/rules/nano.ts +18 -6
- package/src/rules/pages.ts +99 -0
- package/src/rules/routes.ts +181 -2
- package/src/rules/secrets.ts +174 -0
- package/src/rules/security.ts +198 -70
- package/src/rules/typescript.ts +77 -0
- package/src/rules/zones.ts +195 -0
- package/src/scaffold.ts +175 -98
- package/src/sdk.ts +1215 -158
- package/src/search-terms.ts +24 -7
- package/src/smoke.ts +607 -0
- package/src/sync-manifest.ts +2 -1
- package/src/sync-routes.ts +4 -3
- package/src/test-tool.ts +32 -11
- package/src/types-search.ts +11 -4
- package/src/upgrade.ts +205 -25
- package/src/verification-plan.ts +27 -0
- package/src/verification.ts +50 -12
- package/src/web-app-options.ts +57 -0
- package/src/work-graph.ts +40 -16
|
@@ -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
|
+
}
|