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