@nifrajs/cli 4.0.2 → 5.1.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 +10 -2
- package/dist/agent-files.d.ts +107 -0
- package/dist/agent-files.d.ts.map +1 -0
- package/dist/agent-files.js +162 -0
- package/dist/agent-files.js.map +1 -0
- package/dist/app-files.d.ts +6 -0
- package/dist/app-files.d.ts.map +1 -1
- package/dist/app-files.js +27 -2
- package/dist/app-files.js.map +1 -1
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +9 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/assure.d.ts +12 -2
- package/dist/assure.d.ts.map +1 -1
- package/dist/assure.js +59 -8
- package/dist/assure.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +36 -9
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +22 -2
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js +54 -7
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +83 -24
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +1516 -318
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts +2 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +203 -70
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +215 -50
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +20 -0
- package/dist/client-module.d.ts.map +1 -0
- package/dist/client-module.js +73 -0
- package/dist/client-module.js.map +1 -0
- package/dist/command-catalog.d.ts +24 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +194 -45
- package/dist/command-catalog.js.map +1 -1
- package/dist/component-check.d.ts +13 -0
- package/dist/component-check.d.ts.map +1 -0
- package/dist/component-check.js +59 -0
- package/dist/component-check.js.map +1 -0
- package/dist/contract-proof.d.ts +1 -1
- package/dist/contract-proof.d.ts.map +1 -1
- package/dist/contract-proof.js +2 -24
- package/dist/contract-proof.js.map +1 -1
- package/dist/contracts.d.ts +19 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +50 -3
- package/dist/contracts.js.map +1 -1
- package/dist/db-tool.d.ts.map +1 -1
- package/dist/db-tool.js +4 -3
- package/dist/db-tool.js.map +1 -1
- package/dist/dev-bun-config.d.ts +5 -1
- package/dist/dev-bun-config.d.ts.map +1 -1
- package/dist/dev-bun-config.js +44 -11
- package/dist/dev-bun-config.js.map +1 -1
- package/dist/dev-feed-tool.d.ts +18 -1
- package/dist/dev-feed-tool.d.ts.map +1 -1
- package/dist/dev-feed-tool.js +93 -0
- package/dist/dev-feed-tool.js.map +1 -1
- package/dist/diagnostics.d.ts +3 -0
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js.map +1 -1
- package/dist/diff-tool.d.ts +9 -8
- package/dist/diff-tool.d.ts.map +1 -1
- package/dist/diff-tool.js +28 -24
- package/dist/diff-tool.js.map +1 -1
- package/dist/doctor.d.ts +13 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +23 -15
- package/dist/doctor.js.map +1 -1
- package/dist/env-file.d.ts +6 -0
- package/dist/env-file.d.ts.map +1 -1
- package/dist/env-file.js +6 -0
- package/dist/env-file.js.map +1 -1
- package/dist/fix-recipes.d.ts +2 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +102 -2
- package/dist/fix-recipes.js.map +1 -1
- package/dist/hand-built-manifest.d.ts +27 -0
- package/dist/hand-built-manifest.d.ts.map +1 -0
- package/dist/hand-built-manifest.js +124 -0
- package/dist/hand-built-manifest.js.map +1 -0
- package/dist/hooks-report.d.ts +23 -0
- package/dist/hooks-report.d.ts.map +1 -0
- package/dist/hooks-report.js +104 -0
- package/dist/hooks-report.js.map +1 -0
- package/dist/init-agents.d.ts +18 -2
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +20 -13
- package/dist/init-agents.js.map +1 -1
- package/dist/internal/config-paths.d.ts +16 -0
- package/dist/internal/config-paths.d.ts.map +1 -0
- package/dist/internal/config-paths.js +124 -0
- package/dist/internal/config-paths.js.map +1 -0
- package/dist/internal/installed-package.d.ts +4 -0
- package/dist/internal/installed-package.d.ts.map +1 -0
- package/dist/internal/installed-package.js +17 -0
- package/dist/internal/installed-package.js.map +1 -0
- package/dist/internal/nifra-project.d.ts +41 -0
- package/dist/internal/nifra-project.d.ts.map +1 -0
- package/dist/internal/nifra-project.js +183 -0
- package/dist/internal/nifra-project.js.map +1 -0
- package/dist/internal/project-specifiers.d.ts +29 -0
- package/dist/internal/project-specifiers.d.ts.map +1 -0
- package/dist/internal/project-specifiers.js +435 -0
- package/dist/internal/project-specifiers.js.map +1 -0
- package/dist/internal/script-kind.d.ts.map +1 -1
- package/dist/internal/script-kind.js +2 -1
- package/dist/internal/script-kind.js.map +1 -1
- package/dist/internal/source-facts.d.ts +8 -1
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +22 -5
- package/dist/internal/source-facts.js.map +1 -1
- package/dist/internal/typescript-import.d.ts +1 -1
- package/dist/internal/typescript-import.d.ts.map +1 -1
- package/dist/internal/typescript-import.js +10 -6
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +15 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +87 -25
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +98 -23
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +33 -10
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +60 -10
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts +5 -5
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +8 -23
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-delegate.d.ts +2 -2
- package/dist/mcp-delegate.d.ts.map +1 -1
- package/dist/mcp-delegate.js +19 -7
- package/dist/mcp-delegate.js.map +1 -1
- package/dist/mcp-exec.d.ts +13 -4
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +56 -14
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +30 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +63 -0
- package/dist/mcp-io.js.map +1 -1
- package/dist/mcp-isolate.d.ts.map +1 -1
- package/dist/mcp-isolate.js +3 -2
- package/dist/mcp-isolate.js.map +1 -1
- package/dist/mcp-path.d.ts +2 -1
- package/dist/mcp-path.d.ts.map +1 -1
- package/dist/mcp-path.js +9 -24
- package/dist/mcp-path.js.map +1 -1
- package/dist/mcp-project-child.js +1 -1
- package/dist/mcp-project-child.js.map +1 -1
- package/dist/mcp-root.d.ts.map +1 -1
- package/dist/mcp-root.js +50 -10
- package/dist/mcp-root.js.map +1 -1
- package/dist/mcp-ws.d.ts.map +1 -1
- package/dist/mcp-ws.js +3 -1
- package/dist/mcp-ws.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +48 -19
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +47 -1
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +1972 -170
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +128 -0
- package/dist/output-schemas.d.ts.map +1 -0
- package/dist/output-schemas.js +975 -0
- package/dist/output-schemas.js.map +1 -0
- package/dist/port.d.ts.map +1 -1
- package/dist/port.js +3 -2
- package/dist/port.js.map +1 -1
- package/dist/project-facts.d.ts +15 -4
- package/dist/project-facts.d.ts.map +1 -1
- package/dist/project-facts.js +1 -0
- package/dist/project-facts.js.map +1 -1
- package/dist/project-path.d.ts +6 -0
- package/dist/project-path.d.ts.map +1 -1
- package/dist/project-path.js +48 -14
- package/dist/project-path.js.map +1 -1
- package/dist/recipes/5.0.0.d.ts +11 -0
- package/dist/recipes/5.0.0.d.ts.map +1 -0
- package/dist/recipes/5.0.0.js +34 -0
- package/dist/recipes/5.0.0.js.map +1 -0
- package/dist/recipes/index.d.ts +7 -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 +5 -0
- package/dist/release-verification.d.ts.map +1 -1
- package/dist/release-verification.js +14 -0
- package/dist/release-verification.js.map +1 -1
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +12 -3
- package/dist/replay.js.map +1 -1
- package/dist/review.d.ts +3 -1
- package/dist/review.d.ts.map +1 -1
- package/dist/review.js +43 -7
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +12 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +12 -0
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/comment-markers.d.ts +16 -7
- package/dist/rules/comment-markers.d.ts.map +1 -1
- package/dist/rules/comment-markers.js +58 -19
- package/dist/rules/comment-markers.js.map +1 -1
- package/dist/rules/contract-visibility.d.ts +3 -0
- package/dist/rules/contract-visibility.d.ts.map +1 -0
- package/dist/rules/contract-visibility.js +337 -0
- package/dist/rules/contract-visibility.js.map +1 -0
- package/dist/rules/data-guard.d.ts +13 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +356 -37
- package/dist/rules/data-guard.js.map +1 -1
- package/dist/rules/layout.d.ts +4 -0
- package/dist/rules/layout.d.ts.map +1 -0
- package/dist/rules/layout.js +19 -0
- package/dist/rules/layout.js.map +1 -0
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +178 -44
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +50 -0
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +436 -4
- package/dist/rules/pages.js.map +1 -1
- package/dist/rules/routes.d.ts +6 -0
- package/dist/rules/routes.d.ts.map +1 -1
- package/dist/rules/routes.js +111 -16
- package/dist/rules/routes.js.map +1 -1
- package/dist/rules/secrets.js +2 -2
- package/dist/rules/secrets.js.map +1 -1
- package/dist/rules/security.d.ts +13 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +313 -69
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/zones.d.ts +13 -0
- package/dist/rules/zones.d.ts.map +1 -1
- package/dist/rules/zones.js +218 -44
- package/dist/rules/zones.js.map +1 -1
- package/dist/scaffold.d.ts.map +1 -1
- package/dist/scaffold.js +6 -4
- package/dist/scaffold.js.map +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +14 -5
- package/dist/sdk.js.map +1 -1
- package/dist/stylex-migrate.d.ts.map +1 -1
- package/dist/stylex-migrate.js +290 -24
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +6 -3
- package/dist/test-tool.js.map +1 -1
- package/dist/typecheck-coverage.d.ts +20 -0
- package/dist/typecheck-coverage.d.ts.map +1 -0
- package/dist/typecheck-coverage.js +129 -0
- package/dist/typecheck-coverage.js.map +1 -0
- package/dist/upgrade.d.ts +84 -0
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +498 -78
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +16 -2
- package/dist/verification-plan.js.map +1 -1
- package/dist/version-drift.d.ts +22 -0
- package/dist/version-drift.d.ts.map +1 -0
- package/dist/version-drift.js +94 -0
- package/dist/version-drift.js.map +1 -0
- package/dist/web-app-options.d.ts +1 -1
- package/dist/web-app-options.d.ts.map +1 -1
- package/dist/web-app-options.js +1 -1
- package/dist/web-app-options.js.map +1 -1
- package/dist/work-graph.d.ts.map +1 -1
- package/dist/work-graph.js +20 -8
- package/dist/work-graph.js.map +1 -1
- package/docs/examples.json +55 -7
- package/docs/llms-full.txt +2043 -222
- package/docs/types.json +1573 -246
- package/package.json +11 -12
- package/src/agent-files.ts +203 -0
- package/src/app-files.ts +34 -4
- package/src/assure-hydration.ts +18 -2
- package/src/assure.ts +88 -10
- package/src/capabilities-tool.ts +47 -9
- package/src/check-diagnostics.ts +75 -9
- package/src/check-scan.ts +1816 -377
- package/src/check.ts +258 -81
- package/src/cli.ts +242 -37
- package/src/client-module.ts +97 -0
- package/src/command-catalog.ts +237 -52
- package/src/component-check.ts +79 -0
- package/src/contract-proof.ts +3 -25
- package/src/contracts.ts +60 -3
- package/src/db-tool.ts +5 -2
- package/src/dev-bun-config.ts +51 -10
- package/src/dev-feed-tool.ts +112 -1
- package/src/diagnostics.ts +3 -0
- package/src/diff-tool.ts +33 -26
- package/src/doctor.ts +43 -21
- package/src/env-file.ts +7 -0
- package/src/fix-recipes.ts +116 -2
- package/src/hand-built-manifest.ts +144 -0
- package/src/hooks-report.ts +135 -0
- package/src/init-agents.ts +21 -14
- package/src/internal/config-paths.ts +154 -0
- package/src/internal/installed-package.ts +15 -0
- package/src/internal/nifra-project.ts +191 -0
- package/src/internal/project-specifiers.ts +477 -0
- package/src/internal/script-kind.ts +2 -1
- package/src/internal/source-facts.ts +48 -5
- package/src/internal/typescript-import.ts +15 -6
- package/src/introspect.ts +104 -28
- package/src/levels-tool.ts +124 -29
- package/src/load.ts +107 -17
- package/src/mcp-context.ts +12 -25
- package/src/mcp-delegate.ts +22 -10
- package/src/mcp-exec.ts +68 -18
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +74 -0
- package/src/mcp-isolate.ts +9 -2
- package/src/mcp-path.ts +8 -22
- package/src/mcp-project-child.ts +1 -1
- package/src/mcp-root.ts +58 -8
- package/src/mcp-ws.ts +4 -2
- package/src/mcp.ts +56 -22
- package/src/migrate-layout.ts +2257 -176
- package/src/output-schemas.ts +1240 -0
- package/src/port.ts +3 -2
- package/src/project-facts.ts +17 -3
- package/src/project-path.ts +44 -13
- package/src/recipes/5.0.0.ts +35 -0
- package/src/recipes/index.ts +10 -0
- package/src/release-verification.ts +20 -0
- package/src/replay.ts +14 -3
- package/src/review.ts +44 -10
- package/src/rules/codes.ts +12 -0
- package/src/rules/comment-markers.ts +57 -21
- package/src/rules/contract-visibility.ts +383 -0
- package/src/rules/data-guard.ts +391 -42
- package/src/rules/layout.ts +22 -0
- package/src/rules/legacy.ts +206 -52
- package/src/rules/pages.ts +481 -4
- package/src/rules/routes.ts +124 -16
- package/src/rules/secrets.ts +2 -2
- package/src/rules/security.ts +391 -72
- package/src/rules/zones.ts +232 -42
- package/src/scaffold.ts +6 -4
- package/src/sdk.ts +14 -5
- package/src/stylex-migrate.ts +251 -24
- package/src/test-tool.ts +12 -3
- package/src/typecheck-coverage.ts +134 -0
- package/src/upgrade.ts +576 -72
- package/src/verification-plan.ts +33 -2
- package/src/version-drift.ts +118 -0
- package/src/web-app-options.ts +2 -2
- package/src/work-graph.ts +17 -12
package/dist/migrate-layout.js
CHANGED
|
@@ -8,21 +8,37 @@
|
|
|
8
8
|
* 2. folds each `_middleware.ts` into its directory's `_layout.backend.ts` as `middleware`;
|
|
9
9
|
* 3. moves `backend.ts` to `backend/app.ts` and `framework.ts` to `backend/framework.ts`;
|
|
10
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
|
|
12
|
-
* `backend/`, both -> `shared
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* by who imports it at runtime: only route frontends and the `clientModule` -> `frontend/`, only
|
|
12
|
+
* the server side -> `backend/`, both -> `shared/`. Imports through tsconfig `paths`/`baseUrl` and
|
|
13
|
+
* package.json `imports` count as the bundler reads them;
|
|
14
|
+
* 5. rewrites every import in the app to the new locations (test-runner mocks included), an alias
|
|
15
|
+
* through the same alias when one of its targets covers the new place and as a relative path
|
|
16
|
+
* otherwise, each checked to resolve to its target after the moves; the config's `clientModule`
|
|
17
|
+
* and `componentPipeline.entries` paths; `@nifrajs/web/server-only` to
|
|
18
|
+
* `@nifrajs/web/backend-only`; and `ServerOnly` to `BackendOnly`;
|
|
19
|
+
* 6. adds the zone folders moved TypeScript lands in to tsconfig `include`, and `.nifra/` to
|
|
20
|
+
* `.gitignore`.
|
|
21
|
+
*
|
|
22
|
+
* Data files (JSON, YAML, CSV, ...) and folders the app zones in package.json stay where they are.
|
|
15
23
|
*
|
|
16
24
|
* 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
|
|
18
|
-
* guessing.
|
|
25
|
+
* a moved name at runtime, a helper both halves need, a loader with no output schema, an import no
|
|
26
|
+
* specifier reaches after the moves, a specifier that names nothing) instead of guessing.
|
|
19
27
|
*/
|
|
20
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
21
|
-
import {
|
|
28
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
29
|
+
import { builtinModules } from "node:module";
|
|
30
|
+
import { dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
22
31
|
import { BACKEND_ROUTE_EXPORTS } from "@nifrajs/web/route-manifest";
|
|
32
|
+
import { parseZoneConfig } from "@nifrajs/web/zones";
|
|
33
|
+
import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE, retiredRootFiles } from "./app-files.js";
|
|
23
34
|
import { codeUnitOrder } from "./internal/code-unit-order.js";
|
|
35
|
+
import { configPathReader } from "./internal/config-paths.js";
|
|
36
|
+
import { createKindProbe, hasWebConfig } from "./internal/nifra-project.js";
|
|
37
|
+
import { createSpecifierResolver, isFile } from "./internal/project-specifiers.js";
|
|
24
38
|
import { importTypeScript } from "./internal/typescript-import.js";
|
|
39
|
+
import { planOutputSchemas } from "./output-schemas.js";
|
|
25
40
|
import { missingOutputSchemas } from "./rules/data-guard.js";
|
|
41
|
+
import { apiPrefixFix } from "./rules/pages.js";
|
|
26
42
|
/** Exports only the server or the build reads: `@nifrajs/web`'s route-pair contract. */
|
|
27
43
|
const BACKEND_EXPORTS = BACKEND_ROUTE_EXPORTS;
|
|
28
44
|
const ROUTE_FILE = /\.(?:tsx|jsx|svelte|vue|mdx)$/;
|
|
@@ -49,7 +65,45 @@ const TOOLING_FILE = /(?:\.(?:json|jsonc|ya?ml|toml|sh|bash|zsh|ps1)|(?:^|\/)(?:
|
|
|
49
65
|
const SELF_LOCATION = /\bimport\.meta\.(?:dirname|dir|filename|url)\b|\b__(?:dirname|filename)\b/;
|
|
50
66
|
const SKIP_DIRS = new Set(["node_modules", "dist", "build", "coverage", ".git", ".vite", "public"]);
|
|
51
67
|
const ZONE_DIRS = new Set(["frontend", "backend", "shared", "routes", "public"]);
|
|
68
|
+
const FOLDER_SIDES = ["frontend", "backend", "shared"];
|
|
69
|
+
/** Data other tools may read by path too; reported, never moved. Images and fonts still move. */
|
|
70
|
+
const DATA_FILE = /\.(?:json|jsonc|json5|ndjson|ya?ml|toml|csv|tsv|txt|xml|sql)$/;
|
|
71
|
+
/** Test-runner calls that name a module by path, as an import does. */
|
|
72
|
+
const MOCK_CALLS = new Set([
|
|
73
|
+
"mock.module",
|
|
74
|
+
"vi.mock",
|
|
75
|
+
"vi.doMock",
|
|
76
|
+
"vi.importActual",
|
|
77
|
+
"vi.importMock",
|
|
78
|
+
"jest.mock",
|
|
79
|
+
"jest.doMock",
|
|
80
|
+
"jest.requireActual",
|
|
81
|
+
"jest.requireMock",
|
|
82
|
+
"jest.unstable_mockModule",
|
|
83
|
+
]);
|
|
84
|
+
const TAILWIND_CONFIG = /^tailwind\.config\.[cm]?[jt]s$/;
|
|
85
|
+
/** What a `x.test.ts` beside `x.<ext>` tests. */
|
|
86
|
+
const SUBJECT_EXTENSIONS = [
|
|
87
|
+
".ts",
|
|
88
|
+
".tsx",
|
|
89
|
+
".mts",
|
|
90
|
+
".cts",
|
|
91
|
+
".js",
|
|
92
|
+
".jsx",
|
|
93
|
+
".mjs",
|
|
94
|
+
".cjs",
|
|
95
|
+
".svelte",
|
|
96
|
+
".vue",
|
|
97
|
+
];
|
|
52
98
|
const toPosix = (path) => path.replaceAll("\\", "/");
|
|
99
|
+
/** A specifier without its `?query` or `#hash`; a leading `#` names a package.json import. Cut by
|
|
100
|
+
* index, since `/[?#].*$/` retries from every `#` and is quadratic on a long run of them. */
|
|
101
|
+
const withoutQuery = (specifier) => {
|
|
102
|
+
const query = specifier.indexOf("?", 1);
|
|
103
|
+
const hash = specifier.indexOf("#", 1);
|
|
104
|
+
const cut = query === -1 ? hash : hash === -1 ? query : Math.min(query, hash);
|
|
105
|
+
return cut === -1 ? specifier : specifier.slice(0, cut);
|
|
106
|
+
};
|
|
53
107
|
/** Every first-party file under `root`, app-relative and posix. */
|
|
54
108
|
function listFiles(root) {
|
|
55
109
|
const out = [];
|
|
@@ -76,15 +130,34 @@ function bindingNames(ts, name, into) {
|
|
|
76
130
|
bindingNames(ts, element.name, into);
|
|
77
131
|
}
|
|
78
132
|
const hasModifier = (ts, node, kind) => (ts.canHaveModifiers(node) ? (ts.getModifiers(node) ?? []) : []).some((m) => m.kind === kind);
|
|
79
|
-
/**
|
|
80
|
-
*
|
|
81
|
-
|
|
133
|
+
/**
|
|
134
|
+
* The bindings classic JSX compiles to calls of, which no identifier in the source names: the roots
|
|
135
|
+
* of the `@jsx`/`@jsxFrag` pragmas, else TypeScript's default `React`. A file that names its own
|
|
136
|
+
* `@jsxImportSource` imports the runtime itself.
|
|
137
|
+
*/
|
|
138
|
+
function jsxBindings(ts, source) {
|
|
139
|
+
const comments = (ts.getLeadingCommentRanges(source, 0) ?? [])
|
|
140
|
+
.map(({ pos, end }) => source.slice(pos, end))
|
|
141
|
+
.join("\n");
|
|
142
|
+
if (/@jsxImportSource\s/.test(comments))
|
|
143
|
+
return { element: [], fragment: [] };
|
|
144
|
+
const root = (pragma) => new RegExp(`@${pragma}\\s+([A-Za-z_$][\\w$]*)`).exec(comments)?.[1];
|
|
145
|
+
const element = root("jsx") ?? "React";
|
|
146
|
+
return { element: [element], fragment: [root("jsxFrag") ?? element] };
|
|
147
|
+
}
|
|
148
|
+
function references(ts, node, jsx) {
|
|
82
149
|
const value = new Set();
|
|
83
150
|
const type = new Set();
|
|
84
151
|
const visit = (current, inType) => {
|
|
152
|
+
if (ts.isJsxOpeningElement(current) || ts.isJsxSelfClosingElement(current))
|
|
153
|
+
for (const name of jsx.element)
|
|
154
|
+
value.add(name);
|
|
155
|
+
else if (ts.isJsxFragment(current))
|
|
156
|
+
for (const name of jsx.fragment)
|
|
157
|
+
value.add(name);
|
|
85
158
|
const typed = inType ||
|
|
86
159
|
ts.isTypeNode(current) ||
|
|
87
|
-
//
|
|
160
|
+
// nifra-expect NF-S002: `token` is the heritage clause's syntax kind, not a credential.
|
|
88
161
|
(ts.isHeritageClause(current) && current.token === ts.SyntaxKind.ImplementsKeyword);
|
|
89
162
|
if (ts.isIdentifier(current)) {
|
|
90
163
|
const parent = current.parent;
|
|
@@ -114,9 +187,11 @@ function references(ts, node) {
|
|
|
114
187
|
return { value, type };
|
|
115
188
|
}
|
|
116
189
|
function describeStatements(ts, sf) {
|
|
190
|
+
const jsx = jsxBindings(ts, sf.text);
|
|
117
191
|
return sf.statements.map((node) => {
|
|
118
192
|
const declares = new Set();
|
|
119
193
|
const exports = new Map();
|
|
194
|
+
const listed = [];
|
|
120
195
|
const exported = hasModifier(ts, node, ts.SyntaxKind.ExportKeyword);
|
|
121
196
|
const isDefault = hasModifier(ts, node, ts.SyntaxKind.DefaultKeyword);
|
|
122
197
|
const addExport = (local, as) => {
|
|
@@ -162,12 +237,15 @@ function describeStatements(ts, sf) {
|
|
|
162
237
|
node.exportClause &&
|
|
163
238
|
ts.isNamedExports(node.exportClause)) {
|
|
164
239
|
for (const el of node.exportClause.elements) {
|
|
165
|
-
|
|
240
|
+
const local = (el.propertyName ?? el.name).text;
|
|
241
|
+
addExport(node.moduleSpecifier ? `\0${el.name.text}` : local, el.name.text);
|
|
242
|
+
if (node.moduleSpecifier === undefined)
|
|
243
|
+
listed.push({ exported: el.name.text, local, type: node.isTypeOnly || el.isTypeOnly });
|
|
166
244
|
}
|
|
167
245
|
}
|
|
168
246
|
const refs = ts.isImportDeclaration(node)
|
|
169
247
|
? { value: new Set(), type: new Set() }
|
|
170
|
-
: references(ts, node);
|
|
248
|
+
: references(ts, node, jsx);
|
|
171
249
|
for (const name of declares) {
|
|
172
250
|
refs.value.delete(name);
|
|
173
251
|
refs.type.delete(name);
|
|
@@ -179,33 +257,61 @@ function describeStatements(ts, sf) {
|
|
|
179
257
|
exports,
|
|
180
258
|
valueRefs: refs.value,
|
|
181
259
|
typeRefs: refs.type,
|
|
260
|
+
listed,
|
|
182
261
|
};
|
|
183
262
|
});
|
|
184
263
|
}
|
|
185
|
-
/**
|
|
186
|
-
|
|
264
|
+
/**
|
|
265
|
+
* Rebuild an import keeping only the bindings `uses` names; `undefined` when none remain. Bindings
|
|
266
|
+
* read only in types become `import type`, so no bundler loads the module for them: always in a
|
|
267
|
+
* `fresh` module, and in the page only where the split narrowed the import.
|
|
268
|
+
*/
|
|
269
|
+
function filterImport(ts, sf, node, uses, fresh) {
|
|
187
270
|
const clause = node.importClause;
|
|
188
271
|
const spec = node.moduleSpecifier.getText(sf);
|
|
189
272
|
const attributes = node.attributes ? ` ${node.attributes.getText(sf)}` : "";
|
|
190
273
|
if (clause === undefined)
|
|
191
274
|
return node.getFullText(sf);
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
275
|
+
const heads = [];
|
|
276
|
+
const named = [];
|
|
277
|
+
let total = 0;
|
|
278
|
+
const add = (into, name, text, bare) => {
|
|
279
|
+
total++;
|
|
280
|
+
if (uses.value.has(name) || uses.type.has(name))
|
|
281
|
+
into.push({ text, bare, typeOnly: !uses.value.has(name) });
|
|
282
|
+
};
|
|
283
|
+
if (clause.name)
|
|
284
|
+
add(heads, clause.name.text, clause.name.text, clause.name.text);
|
|
195
285
|
const bindings = clause.namedBindings;
|
|
196
|
-
if (bindings && ts.isNamespaceImport(bindings)
|
|
197
|
-
|
|
286
|
+
if (bindings && ts.isNamespaceImport(bindings)) {
|
|
287
|
+
const text = `* as ${bindings.name.text}`;
|
|
288
|
+
add(heads, bindings.name.text, text, text);
|
|
289
|
+
}
|
|
198
290
|
if (bindings && ts.isNamedImports(bindings)) {
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
291
|
+
for (const el of bindings.elements) {
|
|
292
|
+
const renamed = el.propertyName ? `${el.propertyName.getText(sf)} as ` : "";
|
|
293
|
+
add(named, el.name.text, el.getText(sf), `${renamed}${el.name.text}`);
|
|
294
|
+
}
|
|
202
295
|
}
|
|
203
|
-
|
|
296
|
+
const kept = [...heads, ...named];
|
|
297
|
+
if (kept.length === 0)
|
|
204
298
|
return undefined;
|
|
205
|
-
const typeOnly = clause.isTypeOnly ? "type " : "";
|
|
206
299
|
const leading = node.getFullText(sf).slice(0, node.getStart(sf) - node.getFullStart());
|
|
207
|
-
|
|
300
|
+
const statement = (typeOnly, body) => `import ${typeOnly ? "type " : ""}${body} from ${spec}${attributes}${terminatorOf(sf, node)}`;
|
|
301
|
+
const list = (parts, text) => parts.length === 0 ? [] : [`{ ${parts.map(text).join(", ")} }`];
|
|
302
|
+
if (!clause.isTypeOnly &&
|
|
303
|
+
node.attributes === undefined &&
|
|
304
|
+
(fresh || kept.length < total) &&
|
|
305
|
+
kept.every((part) => part.typeOnly)) {
|
|
306
|
+
// `import type` takes one default, namespace or list, so each goes in its own statement.
|
|
307
|
+
const bodies = [...heads.map((part) => part.bare), ...list(named, (part) => part.bare)];
|
|
308
|
+
return `${leading}${bodies.map((body) => statement(true, body)).join("\n")}`;
|
|
309
|
+
}
|
|
310
|
+
const body = [...heads.map((part) => part.text), ...list(named, (part) => part.text)];
|
|
311
|
+
return `${leading}${statement(clause.isTypeOnly, body.join(", "))}`;
|
|
208
312
|
}
|
|
313
|
+
/** The `;` a statement was written with, so a rebuilt one keeps the file's style. */
|
|
314
|
+
const terminatorOf = (sf, node) => node.getText(sf).endsWith(";") ? ";" : "";
|
|
209
315
|
/** Rebuild `export { a, b as c }` keeping only the exported names `keep` accepts. */
|
|
210
316
|
function filterExportList(ts, sf, node, keep) {
|
|
211
317
|
const clause = node.exportClause;
|
|
@@ -216,18 +322,115 @@ function filterExportList(ts, sf, node, keep) {
|
|
|
216
322
|
return undefined;
|
|
217
323
|
const from = node.moduleSpecifier ? ` from ${node.moduleSpecifier.getText(sf)}` : "";
|
|
218
324
|
const leading = node.getFullText(sf).slice(0, node.getStart(sf) - node.getFullStart());
|
|
219
|
-
return `${leading}export ${node.isTypeOnly ? "type " : ""}{ ${kept.join(", ")} }${from}`;
|
|
325
|
+
return `${leading}export ${node.isTypeOnly ? "type " : ""}{ ${kept.join(", ")} }${from}${terminatorOf(sf, node)}`;
|
|
220
326
|
}
|
|
221
327
|
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
222
328
|
const joinStatements = (texts) => {
|
|
223
329
|
const body = texts.join("").replace(/^\s*\n/, "");
|
|
224
330
|
return body.endsWith("\n") ? body : `${body}\n`;
|
|
225
331
|
};
|
|
332
|
+
const PURE_OBJECT_CALLS = new Set(["keys", "values", "entries", "freeze", "fromEntries"]);
|
|
333
|
+
/**
|
|
334
|
+
* Whether `node` builds constant data: literals, arrays and objects of it, operators over it,
|
|
335
|
+
* `Object.keys`-style reads, and the file's own top-level names, which go into `reads`. `undefined`
|
|
336
|
+
* when it is data; else the import it reads, or `""` for anything else (a call, a function, a global).
|
|
337
|
+
*/
|
|
338
|
+
function dataBlocker(ts, node, isLocal, reads) {
|
|
339
|
+
const data = (n) => {
|
|
340
|
+
if (ts.isStringLiteral(n) ||
|
|
341
|
+
ts.isNumericLiteral(n) ||
|
|
342
|
+
ts.isBigIntLiteral(n) ||
|
|
343
|
+
ts.isNoSubstitutionTemplateLiteral(n) ||
|
|
344
|
+
n.kind === ts.SyntaxKind.TrueKeyword ||
|
|
345
|
+
n.kind === ts.SyntaxKind.FalseKeyword ||
|
|
346
|
+
n.kind === ts.SyntaxKind.NullKeyword)
|
|
347
|
+
return undefined;
|
|
348
|
+
if (ts.isIdentifier(n)) {
|
|
349
|
+
if (isLocal(n.text)) {
|
|
350
|
+
reads.add(n.text);
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
return n.text === "undefined" ? undefined : n.text;
|
|
354
|
+
}
|
|
355
|
+
const every = (nodes) => {
|
|
356
|
+
for (const child of nodes) {
|
|
357
|
+
const blocker = data(child);
|
|
358
|
+
if (blocker !== undefined)
|
|
359
|
+
return blocker;
|
|
360
|
+
}
|
|
361
|
+
return undefined;
|
|
362
|
+
};
|
|
363
|
+
if (ts.isTemplateExpression(n))
|
|
364
|
+
return every(n.templateSpans.map((span) => span.expression));
|
|
365
|
+
if (ts.isPrefixUnaryExpression(n))
|
|
366
|
+
return n.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
367
|
+
n.operator === ts.SyntaxKind.MinusMinusToken
|
|
368
|
+
? ""
|
|
369
|
+
: data(n.operand);
|
|
370
|
+
if (ts.isBinaryExpression(n)) {
|
|
371
|
+
const operator = n.operatorToken.kind;
|
|
372
|
+
const assigns = operator >= ts.SyntaxKind.FirstAssignment && operator <= ts.SyntaxKind.LastAssignment;
|
|
373
|
+
if (assigns ||
|
|
374
|
+
operator === ts.SyntaxKind.CommaToken ||
|
|
375
|
+
operator === ts.SyntaxKind.InKeyword ||
|
|
376
|
+
operator === ts.SyntaxKind.InstanceOfKeyword)
|
|
377
|
+
return "";
|
|
378
|
+
return every([n.left, n.right]);
|
|
379
|
+
}
|
|
380
|
+
if (ts.isConditionalExpression(n))
|
|
381
|
+
return every([n.condition, n.whenTrue, n.whenFalse]);
|
|
382
|
+
if (ts.isArrayLiteralExpression(n))
|
|
383
|
+
return every(n.elements.flatMap((element) => ts.isOmittedExpression(element)
|
|
384
|
+
? []
|
|
385
|
+
: [ts.isSpreadElement(element) ? element.expression : element]));
|
|
386
|
+
if (ts.isObjectLiteralExpression(n)) {
|
|
387
|
+
for (const property of n.properties) {
|
|
388
|
+
const blocker = ts.isPropertyAssignment(property)
|
|
389
|
+
? ((ts.isComputedPropertyName(property.name)
|
|
390
|
+
? data(property.name.expression)
|
|
391
|
+
: undefined) ?? data(property.initializer))
|
|
392
|
+
: ts.isShorthandPropertyAssignment(property)
|
|
393
|
+
? property.objectAssignmentInitializer === undefined
|
|
394
|
+
? data(property.name)
|
|
395
|
+
: ""
|
|
396
|
+
: ts.isSpreadAssignment(property)
|
|
397
|
+
? data(property.expression)
|
|
398
|
+
: "";
|
|
399
|
+
if (blocker !== undefined)
|
|
400
|
+
return blocker;
|
|
401
|
+
}
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
if (ts.isParenthesizedExpression(n) ||
|
|
405
|
+
ts.isAsExpression(n) ||
|
|
406
|
+
ts.isSatisfiesExpression(n) ||
|
|
407
|
+
ts.isNonNullExpression(n) ||
|
|
408
|
+
ts.isTypeAssertionExpression(n))
|
|
409
|
+
return data(n.expression);
|
|
410
|
+
if (ts.isPropertyAccessExpression(n))
|
|
411
|
+
return ts.isPrivateIdentifier(n.name) ? "" : data(n.expression);
|
|
412
|
+
if (ts.isElementAccessExpression(n))
|
|
413
|
+
return every([n.expression, n.argumentExpression]);
|
|
414
|
+
if (ts.isCallExpression(n)) {
|
|
415
|
+
const callee = n.expression;
|
|
416
|
+
const objectRead = ts.isPropertyAccessExpression(callee) &&
|
|
417
|
+
ts.isIdentifier(callee.expression) &&
|
|
418
|
+
callee.expression.text === "Object" &&
|
|
419
|
+
!isLocal("Object") &&
|
|
420
|
+
PURE_OBJECT_CALLS.has(callee.name.text) &&
|
|
421
|
+
n.questionDotToken === undefined &&
|
|
422
|
+
n.typeArguments === undefined;
|
|
423
|
+
return objectRead ? every(n.arguments) : "";
|
|
424
|
+
}
|
|
425
|
+
return "";
|
|
426
|
+
};
|
|
427
|
+
return data(node);
|
|
428
|
+
}
|
|
226
429
|
/**
|
|
227
430
|
* Split one route module's source into its frontend and backend halves. `backendSpecifier` is how the
|
|
228
431
|
* frontend refers to the backend half in an `import type` (`./index.backend.ts`).
|
|
229
432
|
*/
|
|
230
|
-
export function splitRouteSource(ts, source, fileName, backendSpecifier) {
|
|
433
|
+
export function splitRouteSource(ts, source, fileName, backendSpecifier, options = {}) {
|
|
231
434
|
const kind = fileName.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
232
435
|
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, kind);
|
|
233
436
|
const statements = describeStatements(ts, sf);
|
|
@@ -238,72 +441,175 @@ export function splitRouteSource(ts, source, fileName, backendSpecifier) {
|
|
|
238
441
|
if (moved.length === 0)
|
|
239
442
|
return { frontend: source, backend: undefined, moved: [], issues: [] };
|
|
240
443
|
const declaredBy = new Map();
|
|
444
|
+
const importedBy = new Map();
|
|
241
445
|
for (const info of statements) {
|
|
242
|
-
|
|
243
|
-
continue;
|
|
446
|
+
const into = ts.isImportDeclaration(info.node) ? importedBy : declaredBy;
|
|
244
447
|
for (const name of info.declares)
|
|
245
|
-
|
|
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
|
-
}
|
|
448
|
+
into.set(name, info);
|
|
269
449
|
}
|
|
270
|
-
|
|
271
|
-
|
|
450
|
+
const isTypeDeclaration = (info) => ts.isInterfaceDeclaration(info.node) || ts.isTypeAliasDeclaration(info.node);
|
|
451
|
+
/** What `info` reads for one half: an export list reads only the locals it exports there. */
|
|
452
|
+
const readsFor = (info, backend) => {
|
|
453
|
+
if (info.listed.length === 0)
|
|
454
|
+
return { value: info.valueRefs, type: info.typeRefs };
|
|
455
|
+
const mine = info.listed.filter((entry) => BACKEND_EXPORTS.has(entry.exported) === backend);
|
|
456
|
+
return {
|
|
457
|
+
value: new Set(mine.filter((entry) => !entry.type).map((entry) => entry.local)),
|
|
458
|
+
type: new Set(mine.filter((entry) => entry.type).map((entry) => entry.local)),
|
|
459
|
+
};
|
|
460
|
+
};
|
|
272
461
|
const movedLocals = new Set();
|
|
273
462
|
for (const info of statements) {
|
|
274
463
|
for (const [local, names] of info.exports)
|
|
275
464
|
if (names.some((name) => BACKEND_EXPORTS.has(name)))
|
|
276
465
|
movedLocals.add(local);
|
|
277
466
|
}
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
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;
|
|
467
|
+
/** Each half's statements. What `shared` holds, both import, so neither half keeps a copy. */
|
|
468
|
+
const partition = (shared) => {
|
|
469
|
+
const local = (name) => {
|
|
292
470
|
const target = declaredBy.get(name);
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
471
|
+
return target === undefined || shared.has(target) ? undefined : target;
|
|
472
|
+
};
|
|
473
|
+
// The backend half: its export statements plus every top-level declaration they reach.
|
|
474
|
+
const backendSet = new Set();
|
|
475
|
+
const queue = statements.filter(isBackendStatement);
|
|
476
|
+
for (const info of queue)
|
|
477
|
+
backendSet.add(info);
|
|
478
|
+
while (queue.length > 0) {
|
|
479
|
+
const info = queue.pop();
|
|
480
|
+
const reads = readsFor(info, true);
|
|
481
|
+
for (const name of [...reads.value, ...reads.type]) {
|
|
482
|
+
const target = local(name);
|
|
483
|
+
if (target !== undefined && !backendSet.has(target)) {
|
|
484
|
+
backendSet.add(target);
|
|
485
|
+
queue.push(target);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// The frontend half: everything that is not a backend export statement, minus declarations that
|
|
490
|
+
// only the backend half needs. An export list that also names backend exports keeps the rest.
|
|
491
|
+
const frontendKeep = new Set();
|
|
492
|
+
const seed = statements.filter((info) => !ts.isImportDeclaration(info.node) &&
|
|
493
|
+
!shared.has(info) &&
|
|
494
|
+
(!backendSet.has(info) ||
|
|
495
|
+
exportsOf(info).some((name) => !BACKEND_EXPORTS.has(name)) ||
|
|
496
|
+
// a statement with no declaration (a side effect) stays where it ran
|
|
497
|
+
(info.declares.size === 0 && info.exports.size === 0)));
|
|
498
|
+
const fqueue = [...seed];
|
|
499
|
+
for (const info of fqueue)
|
|
500
|
+
frontendKeep.add(info);
|
|
501
|
+
while (fqueue.length > 0) {
|
|
502
|
+
const info = fqueue.pop();
|
|
503
|
+
const reads = readsFor(info, false);
|
|
504
|
+
for (const name of reads.value) {
|
|
505
|
+
if (movedLocals.has(name) && !info.declares.has(name))
|
|
506
|
+
continue;
|
|
507
|
+
const target = local(name);
|
|
508
|
+
if (target !== undefined && !frontendKeep.has(target) && !isBackendStatement(target)) {
|
|
509
|
+
frontendKeep.add(target);
|
|
510
|
+
fqueue.push(target);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
for (const name of reads.type) {
|
|
514
|
+
if (movedLocals.has(name))
|
|
515
|
+
continue;
|
|
516
|
+
const target = local(name);
|
|
517
|
+
if (target !== undefined && !frontendKeep.has(target) && !isBackendStatement(target)) {
|
|
518
|
+
frontendKeep.add(target);
|
|
519
|
+
fqueue.push(target);
|
|
520
|
+
}
|
|
296
521
|
}
|
|
297
522
|
}
|
|
298
|
-
|
|
299
|
-
|
|
523
|
+
return { backendSet, frontendKeep };
|
|
524
|
+
};
|
|
525
|
+
let { backendSet, frontendKeep } = partition(new Set());
|
|
526
|
+
const copies = () => [...frontendKeep].filter((info) => backendSet.has(info) &&
|
|
527
|
+
!isBackendStatement(info) &&
|
|
528
|
+
info.declares.size > 0 &&
|
|
529
|
+
!isTypeDeclaration(info));
|
|
530
|
+
// Constant data both halves read moves to a module beside the route, with the declarations it
|
|
531
|
+
// needs: one definition, which neither half's bundle can drift from.
|
|
532
|
+
const shared = new Set();
|
|
533
|
+
const sharedTypes = new Set();
|
|
534
|
+
const sharedImports = new Set();
|
|
535
|
+
const unshared = new Map();
|
|
536
|
+
if (options.sharedFile !== undefined) {
|
|
537
|
+
/** Why `info` cannot move to the shared module, or `undefined` once it and what it needs are in `closure`. */
|
|
538
|
+
const gather = (info, closure) => {
|
|
539
|
+
if (closure.values.has(info) || shared.has(info))
|
|
540
|
+
return undefined;
|
|
541
|
+
const node = info.node;
|
|
542
|
+
const reads = new Set();
|
|
543
|
+
let blocker = "";
|
|
544
|
+
if (!isBackendStatement(info) &&
|
|
545
|
+
ts.isVariableStatement(node) &&
|
|
546
|
+
(node.declarationList.flags & ts.NodeFlags.Const) !== 0 &&
|
|
547
|
+
!hasModifier(ts, node, ts.SyntaxKind.DeclareKeyword)) {
|
|
548
|
+
blocker = undefined;
|
|
549
|
+
for (const declaration of node.declarationList.declarations) {
|
|
550
|
+
blocker =
|
|
551
|
+
ts.isIdentifier(declaration.name) && declaration.initializer !== undefined
|
|
552
|
+
? dataBlocker(ts, declaration.initializer, (name) => declaredBy.has(name), reads)
|
|
553
|
+
: "";
|
|
554
|
+
if (blocker !== undefined)
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (blocker !== undefined)
|
|
559
|
+
return blocker !== "" && importedBy.has(blocker)
|
|
560
|
+
? `it reads the import "${blocker}"`
|
|
561
|
+
: "it is not constant data";
|
|
562
|
+
closure.values.add(info);
|
|
563
|
+
for (const name of reads) {
|
|
564
|
+
const target = declaredBy.get(name);
|
|
565
|
+
const why = target === undefined ? undefined : gather(target, closure);
|
|
566
|
+
if (why !== undefined)
|
|
567
|
+
return `it reads "${name}", which ${why.slice(3)}`;
|
|
568
|
+
}
|
|
569
|
+
for (const name of info.typeRefs) {
|
|
570
|
+
const why = gatherType(name, closure);
|
|
571
|
+
if (why !== undefined)
|
|
572
|
+
return why;
|
|
573
|
+
}
|
|
574
|
+
return undefined;
|
|
575
|
+
};
|
|
576
|
+
const gatherType = (name, closure) => {
|
|
577
|
+
const local = declaredBy.get(name);
|
|
578
|
+
if (local === undefined) {
|
|
579
|
+
if (importedBy.has(name))
|
|
580
|
+
closure.imports.add(name);
|
|
581
|
+
return undefined;
|
|
582
|
+
}
|
|
583
|
+
if (!isTypeDeclaration(local)) {
|
|
584
|
+
const why = gather(local, closure);
|
|
585
|
+
return why === undefined ? undefined : `its type reads "${name}", which ${why.slice(3)}`;
|
|
586
|
+
}
|
|
587
|
+
if (closure.types.has(local) || sharedTypes.has(local))
|
|
588
|
+
return undefined;
|
|
589
|
+
closure.types.add(local);
|
|
590
|
+
for (const ref of [...local.typeRefs, ...local.valueRefs]) {
|
|
591
|
+
const why = gatherType(ref, closure);
|
|
592
|
+
if (why !== undefined)
|
|
593
|
+
return why;
|
|
594
|
+
}
|
|
595
|
+
return undefined;
|
|
596
|
+
};
|
|
597
|
+
for (const info of copies()) {
|
|
598
|
+
const closure = { values: new Set(), types: new Set(), imports: new Set() };
|
|
599
|
+
const why = gather(info, closure);
|
|
600
|
+
if (why !== undefined) {
|
|
601
|
+
unshared.set(info, why);
|
|
300
602
|
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
603
|
}
|
|
604
|
+
for (const value of closure.values)
|
|
605
|
+
shared.add(value);
|
|
606
|
+
for (const type of closure.types)
|
|
607
|
+
sharedTypes.add(type);
|
|
608
|
+
for (const name of closure.imports)
|
|
609
|
+
sharedImports.add(name);
|
|
306
610
|
}
|
|
611
|
+
if (shared.size > 0)
|
|
612
|
+
({ backendSet, frontendKeep } = partition(shared));
|
|
307
613
|
}
|
|
308
614
|
// A moved name the frontend still mentions: in a type it becomes `import type`; at runtime it is an
|
|
309
615
|
// error the author has to resolve.
|
|
@@ -311,44 +617,83 @@ export function splitRouteSource(ts, source, fileName, backendSpecifier) {
|
|
|
311
617
|
for (const info of frontendKeep) {
|
|
312
618
|
if (isBackendStatement(info))
|
|
313
619
|
continue;
|
|
620
|
+
const reads = readsFor(info, false);
|
|
314
621
|
for (const name of movedLocals) {
|
|
315
622
|
if (info.declares.has(name))
|
|
316
623
|
continue;
|
|
317
|
-
if (
|
|
624
|
+
if (reads.value.has(name))
|
|
318
625
|
issues.push(`the frontend uses "${name}" at runtime, but it runs on the server only`);
|
|
319
|
-
if (
|
|
626
|
+
if (reads.type.has(name) || reads.value.has(name))
|
|
320
627
|
typeImports.add(name);
|
|
321
628
|
}
|
|
322
629
|
}
|
|
323
|
-
for (const info of
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
630
|
+
for (const info of copies()) {
|
|
631
|
+
const why = unshared.get(info);
|
|
632
|
+
issues.push(`"${[...info.declares].join(", ")}" is needed by both halves and was copied into each${why === undefined ? "" : `, since ${why}`}; consider moving it to shared/`);
|
|
633
|
+
}
|
|
634
|
+
const uses = (infos, backend) => {
|
|
635
|
+
const value = new Set();
|
|
636
|
+
const type = new Set();
|
|
637
|
+
for (const info of infos) {
|
|
638
|
+
const reads = readsFor(info, backend);
|
|
639
|
+
for (const name of reads.value)
|
|
640
|
+
value.add(name);
|
|
641
|
+
for (const name of reads.type)
|
|
642
|
+
type.add(name);
|
|
643
|
+
}
|
|
644
|
+
return { value, type };
|
|
336
645
|
};
|
|
337
|
-
const backendUses =
|
|
338
|
-
const frontendUses =
|
|
646
|
+
const backendUses = uses(backendSet, true);
|
|
647
|
+
const frontendUses = uses(frontendKeep, false);
|
|
648
|
+
const semi = statements.some((info) => terminatorOf(sf, info.node) !== "") ? ";" : "";
|
|
649
|
+
// The imports the split writes quote the way the file's own imports do.
|
|
650
|
+
const firstImport = statements.find((info) => ts.isImportDeclaration(info.node))?.node;
|
|
651
|
+
const quote = firstImport !== undefined && ts.isImportDeclaration(firstImport)
|
|
652
|
+
? (firstImport.moduleSpecifier.getText(sf)[0] ?? '"')
|
|
653
|
+
: '"';
|
|
654
|
+
const sharedNames = [...shared].flatMap((info) => [...info.declares]);
|
|
655
|
+
const sharedSpecifier = (() => {
|
|
656
|
+
const file = options.sharedFile ?? "";
|
|
657
|
+
// The halves name the module the way the route names its other relative imports.
|
|
658
|
+
const relative = statements.flatMap((info) => ts.isImportDeclaration(info.node) &&
|
|
659
|
+
ts.isStringLiteral(info.node.moduleSpecifier) &&
|
|
660
|
+
info.node.moduleSpecifier.text.startsWith(".")
|
|
661
|
+
? [info.node.moduleSpecifier.text]
|
|
662
|
+
: []);
|
|
663
|
+
const extension = relative
|
|
664
|
+
.map((specifier) => /\.(ts|tsx|js|jsx)$/.exec(specifier)?.[1])
|
|
665
|
+
.find((found) => found !== undefined);
|
|
666
|
+
const suffix = extension === undefined ? "" : extension.startsWith("t") ? ".ts" : ".js";
|
|
667
|
+
return `./${file.replace(/\.ts$/, "")}${suffix}`;
|
|
668
|
+
})();
|
|
669
|
+
const sharedImport = (half) => {
|
|
670
|
+
const names = sharedNames
|
|
671
|
+
.filter((name) => half.value.has(name) || half.type.has(name))
|
|
672
|
+
.sort(codeUnitOrder);
|
|
673
|
+
if (names.length === 0)
|
|
674
|
+
return [];
|
|
675
|
+
const typeOnly = names.every((name) => !half.value.has(name));
|
|
676
|
+
return [
|
|
677
|
+
`\nimport ${typeOnly ? "type " : ""}{ ${names.join(", ")} } from ${quote}${sharedSpecifier}${quote}${semi}`,
|
|
678
|
+
];
|
|
679
|
+
};
|
|
680
|
+
// The page still exports what it exported, now from the shared module.
|
|
681
|
+
const reexported = [...shared].flatMap((info) => [...info.exports].flatMap(([local, names]) => names.map((name) => (name === local ? local : `${local} as ${name}`))));
|
|
339
682
|
const backendTexts = [];
|
|
340
683
|
const frontendTexts = [];
|
|
341
684
|
let afterImports = 0;
|
|
685
|
+
let backendAfterImports = 0;
|
|
342
686
|
for (const info of statements) {
|
|
343
687
|
const node = info.node;
|
|
344
688
|
if (ts.isImportDeclaration(node)) {
|
|
345
|
-
const back = filterImport(ts, sf, node,
|
|
689
|
+
const back = filterImport(ts, sf, node, backendUses, true);
|
|
346
690
|
if (back !== undefined)
|
|
347
691
|
backendTexts.push(node.importClause ? back : "");
|
|
348
|
-
const front = filterImport(ts, sf, node,
|
|
692
|
+
const front = filterImport(ts, sf, node, frontendUses, false);
|
|
349
693
|
if (front !== undefined)
|
|
350
694
|
frontendTexts.push(front);
|
|
351
695
|
afterImports = frontendTexts.length;
|
|
696
|
+
backendAfterImports = backendTexts.length;
|
|
352
697
|
continue;
|
|
353
698
|
}
|
|
354
699
|
if (isBackendStatement(info)) {
|
|
@@ -370,21 +715,49 @@ export function splitRouteSource(ts, source, fileName, backendSpecifier) {
|
|
|
370
715
|
frontendTexts.push(info.text);
|
|
371
716
|
}
|
|
372
717
|
// After the other imports: the nearest relative path sorts last.
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
718
|
+
const frontendImports = sharedImport(frontendUses);
|
|
719
|
+
if (reexported.length > 0)
|
|
720
|
+
frontendImports.push(`\nexport { ${reexported.join(", ")} } from ${quote}${sharedSpecifier}${quote}${semi}`);
|
|
721
|
+
if (typeImports.size > 0)
|
|
722
|
+
frontendImports.push(`\nimport type { ${[...typeImports].sort().join(", ")} } from ${quote}${backendSpecifier}${quote}${semi}`);
|
|
723
|
+
frontendTexts.splice(afterImports, 0, ...frontendImports);
|
|
724
|
+
backendTexts.splice(backendAfterImports, 0, ...sharedImport(backendUses));
|
|
376
725
|
// A JSX pragma counts only in the file's leading comments, and the backend half has no JSX.
|
|
377
726
|
const pragmas = (ts.getLeadingCommentRanges(source, 0) ?? [])
|
|
378
727
|
.map((range) => source.slice(range.pos, range.end))
|
|
379
728
|
.filter((comment) => /@jsx/.test(comment));
|
|
380
729
|
const lift = (text) => pragmas.reduce((out, pragma) => out.replace(new RegExp(`${escapeRegExp(pragma)}[ \\t]*\\n?`), ""), text);
|
|
381
730
|
const frontend = lift(joinStatements(frontendTexts));
|
|
382
|
-
|
|
731
|
+
let sharedText;
|
|
732
|
+
if (shared.size > 0) {
|
|
733
|
+
const texts = [];
|
|
734
|
+
const typeUses = { value: new Set(), type: sharedImports };
|
|
735
|
+
for (const info of statements) {
|
|
736
|
+
if (ts.isImportDeclaration(info.node)) {
|
|
737
|
+
const kept = filterImport(ts, sf, info.node, typeUses, true);
|
|
738
|
+
if (kept !== undefined && info.node.importClause !== undefined)
|
|
739
|
+
texts.push(kept);
|
|
740
|
+
}
|
|
741
|
+
else if (shared.has(info) || sharedTypes.has(info)) {
|
|
742
|
+
texts.push(withExport(ts, sf, info));
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
sharedText = lift(joinStatements(texts));
|
|
746
|
+
}
|
|
747
|
+
const result = {
|
|
383
748
|
frontend: pragmas.length > 0 ? `${pragmas.join("\n")}\n${frontend}` : frontend,
|
|
384
749
|
backend: lift(joinStatements(backendTexts)),
|
|
385
750
|
moved: [...new Set(moved)].sort(),
|
|
386
751
|
issues,
|
|
387
752
|
};
|
|
753
|
+
return sharedText === undefined ? result : { ...result, shared: sharedText };
|
|
754
|
+
}
|
|
755
|
+
/** A declaration the shared module holds is exported, so either half can import it. */
|
|
756
|
+
function withExport(ts, sf, info) {
|
|
757
|
+
if (hasModifier(ts, info.node, ts.SyntaxKind.ExportKeyword))
|
|
758
|
+
return info.text;
|
|
759
|
+
const at = info.node.getStart(sf) - info.node.getFullStart();
|
|
760
|
+
return `${info.text.slice(0, at)}export ${info.text.slice(at)}`;
|
|
388
761
|
}
|
|
389
762
|
/** A helper the backend half copies keeps its declaration; an `export` on it would add a backend export. */
|
|
390
763
|
function stripExport(ts, sf, info) {
|
|
@@ -405,7 +778,7 @@ export function middlewareSource(ts, source, fileName) {
|
|
|
405
778
|
// Edit from the end so earlier offsets stay valid.
|
|
406
779
|
for (const node of [...sf.statements].reverse()) {
|
|
407
780
|
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())}`;
|
|
781
|
+
out = `${out.slice(0, node.getStart(sf))}export const middleware = ${node.expression.getText(sf)}${terminatorOf(sf, node)}${out.slice(node.getEnd())}`;
|
|
409
782
|
}
|
|
410
783
|
else if (ts.isFunctionDeclaration(node) &&
|
|
411
784
|
hasModifier(ts, node, ts.SyntaxKind.DefaultKeyword)) {
|
|
@@ -422,7 +795,8 @@ export function middlewareSource(ts, source, fileName) {
|
|
|
422
795
|
}
|
|
423
796
|
else {
|
|
424
797
|
out = `${out.slice(0, first.getStart(sf))}${out.slice(last.getEnd()).replace(/^\s+/, "")}`;
|
|
425
|
-
|
|
798
|
+
const semi = sf.statements.some((s) => terminatorOf(sf, s) !== "") ? ";" : "";
|
|
799
|
+
out = `${out.trimEnd()}\nexport { ${node.name.text} as middleware }${semi}\n`;
|
|
426
800
|
}
|
|
427
801
|
}
|
|
428
802
|
}
|
|
@@ -470,6 +844,16 @@ function scriptBlocks(source) {
|
|
|
470
844
|
}
|
|
471
845
|
return blocks;
|
|
472
846
|
}
|
|
847
|
+
/** The path argument of `mock.module("./x", ...)` or a vitest/jest twin, when it is a literal. */
|
|
848
|
+
function mockedSpecifier(ts, node) {
|
|
849
|
+
const callee = node.expression;
|
|
850
|
+
if (!ts.isPropertyAccessExpression(callee) || !ts.isIdentifier(callee.expression))
|
|
851
|
+
return undefined;
|
|
852
|
+
if (!MOCK_CALLS.has(`${callee.expression.text}.${callee.name.text}`))
|
|
853
|
+
return undefined;
|
|
854
|
+
const arg = node.arguments[0];
|
|
855
|
+
return arg !== undefined && ts.isStringLiteralLike(arg) ? arg : undefined;
|
|
856
|
+
}
|
|
473
857
|
function importsOf(ts, file, text) {
|
|
474
858
|
if (/\.(?:svelte|vue)$/.test(file)) {
|
|
475
859
|
return scriptBlocks(text).flatMap((block) => importsOf(ts, "block.ts", text.slice(block.start, block.end)));
|
|
@@ -519,6 +903,10 @@ function importsOf(ts, file, text) {
|
|
|
519
903
|
const isRequire = ts.isIdentifier(node.expression) && node.expression.text === "require";
|
|
520
904
|
if ((isImport || isRequire) && arg !== undefined && ts.isStringLiteralLike(arg))
|
|
521
905
|
refs.push({ specifier: arg.text, typeOnly: false });
|
|
906
|
+
// A test's mock names the module without loading it into the app: no runtime edge.
|
|
907
|
+
const mocked = mockedSpecifier(ts, node);
|
|
908
|
+
if (mocked !== undefined)
|
|
909
|
+
refs.push({ specifier: mocked.text, typeOnly: true });
|
|
522
910
|
}
|
|
523
911
|
else if (ts.isImportEqualsDeclaration(node) &&
|
|
524
912
|
ts.isExternalModuleReference(node.moduleReference)) {
|
|
@@ -535,7 +923,7 @@ function importsOf(ts, file, text) {
|
|
|
535
923
|
function resolveIn(files, from, specifier) {
|
|
536
924
|
if (!specifier.startsWith("."))
|
|
537
925
|
return undefined;
|
|
538
|
-
const clean = specifier
|
|
926
|
+
const clean = withoutQuery(specifier);
|
|
539
927
|
const base = posix.normalize(posix.join(posix.dirname(from), clean));
|
|
540
928
|
const candidates = [
|
|
541
929
|
base,
|
|
@@ -582,15 +970,19 @@ function rebaseOwnPaths(ts, from, to, text, locate) {
|
|
|
582
970
|
const quote = node.getText(sf)[0] ?? '"';
|
|
583
971
|
return `${quote}${value}${quote}`;
|
|
584
972
|
};
|
|
585
|
-
// The path `relative` names from the module's old folder, named from its new folder
|
|
973
|
+
// The path `relative` names from the module's old folder, named from its new folder; `undefined`
|
|
974
|
+
// when neither moved, so a module that stays keeps its spelling.
|
|
586
975
|
const rebased = (relative) => {
|
|
587
|
-
const
|
|
976
|
+
const before = posix.normalize(posix.join(posix.dirname(from), relative));
|
|
977
|
+
const target = locate(before);
|
|
978
|
+
if (from === to && target === before)
|
|
979
|
+
return undefined;
|
|
588
980
|
const next = posix.relative(posix.dirname(to), target) || ".";
|
|
589
981
|
return relative.endsWith("/") && next !== "." ? `${next}/` : next;
|
|
590
982
|
};
|
|
591
983
|
const follow = (anchor, start, end, replacement) => {
|
|
592
984
|
followed.push([anchor.getStart(sf), anchor.getEnd()]);
|
|
593
|
-
if (text.slice(start, end) !== replacement)
|
|
985
|
+
if (replacement !== undefined && text.slice(start, end) !== replacement)
|
|
594
986
|
edits.push({ start, end, text: replacement });
|
|
595
987
|
};
|
|
596
988
|
const visit = (node) => {
|
|
@@ -606,8 +998,11 @@ function rebaseOwnPaths(ts, from, to, text, locate) {
|
|
|
606
998
|
parts.every(isLiteral) &&
|
|
607
999
|
!(callee.endsWith("resolve") && parts.some((part) => part.text.startsWith("/")))) {
|
|
608
1000
|
const next = rebased(posix.join(...parts.map((part) => part.text)));
|
|
609
|
-
const
|
|
610
|
-
|
|
1001
|
+
const replacement = next === undefined
|
|
1002
|
+
? undefined
|
|
1003
|
+
: (parts.length === 1 ? [next] : next.split("/").filter((s) => s !== ""))
|
|
1004
|
+
.map((segment) => quoted(first, segment))
|
|
1005
|
+
.join(", ");
|
|
611
1006
|
follow(dir, first.getStart(sf), last.getEnd(), replacement);
|
|
612
1007
|
return;
|
|
613
1008
|
}
|
|
@@ -618,14 +1013,14 @@ function rebaseOwnPaths(ts, from, to, text, locate) {
|
|
|
618
1013
|
isLiteral(node.right) &&
|
|
619
1014
|
node.right.text.startsWith("/")) {
|
|
620
1015
|
const next = rebased(node.right.text.slice(1) || ".");
|
|
621
|
-
follow(node.left, node.right.getStart(sf), node.right.getEnd(), quoted(node.right, `/${next}`));
|
|
1016
|
+
follow(node.left, node.right.getStart(sf), node.right.getEnd(), next === undefined ? undefined : quoted(node.right, `/${next}`));
|
|
622
1017
|
return;
|
|
623
1018
|
}
|
|
624
1019
|
if (ts.isTemplateExpression(node) && node.head.text === "" && node.templateSpans.length === 1) {
|
|
625
1020
|
const span = node.templateSpans[0];
|
|
626
1021
|
if (span !== undefined && isOwnDir(span.expression) && span.literal.text.startsWith("/")) {
|
|
627
1022
|
const next = rebased(span.literal.text.slice(1) || ".");
|
|
628
|
-
const replacement = `\`\${${span.expression.getText(sf)}}/${next}\``;
|
|
1023
|
+
const replacement = next === undefined ? undefined : `\`\${${span.expression.getText(sf)}}/${next}\``;
|
|
629
1024
|
follow(span.expression, node.getStart(sf), node.getEnd(), replacement);
|
|
630
1025
|
return;
|
|
631
1026
|
}
|
|
@@ -637,7 +1032,7 @@ function rebaseOwnPaths(ts, from, to, text, locate) {
|
|
|
637
1032
|
const path = node.arguments[0];
|
|
638
1033
|
if (isLiteral(path) && !/^(?:\/|[a-z][a-z\d+.-]*:)/i.test(path.text)) {
|
|
639
1034
|
const next = rebased(path.text);
|
|
640
|
-
const replacement = quoted(path, next.startsWith("..") ? next : `./${next}`);
|
|
1035
|
+
const replacement = next === undefined ? undefined : quoted(path, next.startsWith("..") ? next : `./${next}`);
|
|
641
1036
|
follow(node.arguments[1], path.getStart(sf), path.getEnd(), replacement);
|
|
642
1037
|
return;
|
|
643
1038
|
}
|
|
@@ -654,7 +1049,7 @@ function rebaseOwnPaths(ts, from, to, text, locate) {
|
|
|
654
1049
|
/** Rewrite one relative specifier from `from`'s new location to `target`'s new location, keeping the
|
|
655
1050
|
* author's style: extension kept when written, dropped when not, `/index` collapsed when it was. */
|
|
656
1051
|
function respecify(original, oldTarget, newFrom, newTarget) {
|
|
657
|
-
const query =
|
|
1052
|
+
const query = original.slice(withoutQuery(original).length);
|
|
658
1053
|
const clean = original.slice(0, original.length - query.length);
|
|
659
1054
|
let target = newTarget;
|
|
660
1055
|
const writtenExt = extname(clean);
|
|
@@ -682,6 +1077,306 @@ function rebase(specifier, from, newFrom) {
|
|
|
682
1077
|
const rel = posix.relative(posix.dirname(newFrom), target);
|
|
683
1078
|
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
684
1079
|
}
|
|
1080
|
+
/** `specifier` re-pointed from a split route to its backend half, in the author's extension style. */
|
|
1081
|
+
function backendSpecifier(specifier, route, backend, from) {
|
|
1082
|
+
const query = specifier.slice(withoutQuery(specifier).length);
|
|
1083
|
+
const written = extname(specifier.slice(0, specifier.length - query.length));
|
|
1084
|
+
const stem = backend.slice(0, -extname(backend).length);
|
|
1085
|
+
const target = written === ""
|
|
1086
|
+
? stem
|
|
1087
|
+
: written === extname(route)
|
|
1088
|
+
? backend
|
|
1089
|
+
: `${stem}${written === ".jsx" ? ".js" : written}`;
|
|
1090
|
+
const rel = posix.relative(posix.dirname(from), target);
|
|
1091
|
+
return (rel.startsWith(".") ? rel : `./${rel}`) + query;
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Point an importer's named imports of a split route's moved exports (`loader`, `action`, ...) at the
|
|
1095
|
+
* backend half, splitting a statement that also names what stayed. What cannot be rewritten (a
|
|
1096
|
+
* namespace or dynamic import that reads a moved name, `export *`) is returned as an issue.
|
|
1097
|
+
*/
|
|
1098
|
+
function redirectMovedImports(ts, file, text, splitOf) {
|
|
1099
|
+
const redirects = [];
|
|
1100
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
1101
|
+
const edits = [];
|
|
1102
|
+
const issues = [];
|
|
1103
|
+
const mentioned = (split, pattern) => [...split.moved].filter((name) => pattern(escapeRegExp(name)).test(text)).sort();
|
|
1104
|
+
const quoted = (names) => names.map((name) => `"${name}"`).join(", ");
|
|
1105
|
+
const visit = (node) => {
|
|
1106
|
+
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
|
|
1107
|
+
node.moduleSpecifier !== undefined &&
|
|
1108
|
+
ts.isStringLiteral(node.moduleSpecifier)) {
|
|
1109
|
+
const from = node.moduleSpecifier.text;
|
|
1110
|
+
const split = splitOf(from);
|
|
1111
|
+
if (split === undefined)
|
|
1112
|
+
return;
|
|
1113
|
+
const clause = ts.isImportDeclaration(node) ? node.importClause : undefined;
|
|
1114
|
+
const named = ts.isImportDeclaration(node) ? clause?.namedBindings : node.exportClause;
|
|
1115
|
+
if (named !== undefined && (ts.isNamespaceImport(named) || ts.isNamespaceExport(named))) {
|
|
1116
|
+
const reads = mentioned(split, (name) => new RegExp(`\\b${named.name.text}\\.${name}\\b`));
|
|
1117
|
+
if (reads.length > 0)
|
|
1118
|
+
issues.push(`reads ${quoted(reads)} through a namespace import of "${from}", but they moved to "${split.backend}"; import them from there by hand`);
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
if (named === undefined) {
|
|
1122
|
+
if (ts.isExportDeclaration(node))
|
|
1123
|
+
issues.push(`re-exports everything from "${from}", but ${quoted([...split.moved].sort())} moved to "${split.backend}"; re-export them from there by hand`);
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
const elements = named.elements;
|
|
1127
|
+
const isMoved = (el) => split.moved.has((el.propertyName ?? el.name).text);
|
|
1128
|
+
const moved = elements.filter(isMoved);
|
|
1129
|
+
if (moved.length === 0)
|
|
1130
|
+
return;
|
|
1131
|
+
redirects.push({
|
|
1132
|
+
names: moved.map((el) => (el.propertyName ?? el.name).text),
|
|
1133
|
+
from,
|
|
1134
|
+
to: split.specifier,
|
|
1135
|
+
});
|
|
1136
|
+
const kept = elements.filter((el) => !isMoved(el)).map((el) => el.getText(sf));
|
|
1137
|
+
const quote = node.moduleSpecifier.getText(sf)[0] ?? '"';
|
|
1138
|
+
const attributes = node.attributes ? ` ${node.attributes.getText(sf)}` : "";
|
|
1139
|
+
const tail = (specifier) => ` from ${quote}${specifier}${quote}${attributes}${terminatorOf(sf, node)}`;
|
|
1140
|
+
const keyword = ts.isImportDeclaration(node)
|
|
1141
|
+
? `import ${clause?.isTypeOnly ? "type " : ""}`
|
|
1142
|
+
: `export ${node.isTypeOnly ? "type " : ""}`;
|
|
1143
|
+
const list = (names) => `{ ${names.join(", ")} }`;
|
|
1144
|
+
const backend = `${keyword}${list(moved.map((el) => el.getText(sf)))}${tail(split.specifier)}`;
|
|
1145
|
+
const stays = [
|
|
1146
|
+
...(clause?.name === undefined ? [] : [clause.name.text]),
|
|
1147
|
+
...(kept.length === 0 ? [] : [list(kept)]),
|
|
1148
|
+
];
|
|
1149
|
+
edits.push({
|
|
1150
|
+
start: node.getStart(sf),
|
|
1151
|
+
end: node.getEnd(),
|
|
1152
|
+
text: stays.length === 0 ? backend : `${keyword}${stays.join(", ")}${tail(from)}\n${backend}`,
|
|
1153
|
+
});
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
if (ts.isCallExpression(node) &&
|
|
1157
|
+
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
|
1158
|
+
node.arguments[0] !== undefined &&
|
|
1159
|
+
ts.isStringLiteralLike(node.arguments[0])) {
|
|
1160
|
+
const literal = node.arguments[0];
|
|
1161
|
+
const from = literal.text;
|
|
1162
|
+
const split = splitOf(from);
|
|
1163
|
+
const reads = split === undefined ? [] : mentioned(split, (name) => new RegExp(`\\b${name}\\b`));
|
|
1164
|
+
if (split !== undefined && reads.length > 0) {
|
|
1165
|
+
const names = dynamicReads(ts, sf, node);
|
|
1166
|
+
if (names !== undefined &&
|
|
1167
|
+
names.length > 0 &&
|
|
1168
|
+
names.every((name) => split.moved.has(name))) {
|
|
1169
|
+
edits.push({
|
|
1170
|
+
start: literal.getStart(sf) + 1,
|
|
1171
|
+
end: literal.getEnd() - 1,
|
|
1172
|
+
text: split.specifier,
|
|
1173
|
+
});
|
|
1174
|
+
redirects.push({ names, from, to: split.specifier });
|
|
1175
|
+
}
|
|
1176
|
+
else
|
|
1177
|
+
issues.push(`loads "${from}" with import() and mentions ${quoted(reads)}, which moved to "${split.backend}"; load the backend half for them by hand`);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
ts.forEachChild(node, visit);
|
|
1181
|
+
};
|
|
1182
|
+
visit(sf);
|
|
1183
|
+
let out = text;
|
|
1184
|
+
for (const edit of edits.sort((a, b) => b.start - a.start))
|
|
1185
|
+
out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
|
|
1186
|
+
return { text: out, issues, redirects };
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* The exports code reads from what `import()` loads: off the awaited module (`(await import(x)).a`),
|
|
1190
|
+
* through a destructuring, or through a variable used only as `mod.a`. `undefined` when the module
|
|
1191
|
+
* goes anywhere else, so its reads cannot be listed.
|
|
1192
|
+
*/
|
|
1193
|
+
function dynamicReads(ts, sf, call) {
|
|
1194
|
+
let node = call;
|
|
1195
|
+
if (!ts.isAwaitExpression(node.parent))
|
|
1196
|
+
return undefined;
|
|
1197
|
+
node = node.parent;
|
|
1198
|
+
while (ts.isParenthesizedExpression(node.parent))
|
|
1199
|
+
node = node.parent;
|
|
1200
|
+
const parent = node.parent;
|
|
1201
|
+
if (ts.isPropertyAccessExpression(parent) && parent.expression === node)
|
|
1202
|
+
return [parent.name.text];
|
|
1203
|
+
if (!ts.isVariableDeclaration(parent) || parent.initializer !== node)
|
|
1204
|
+
return undefined;
|
|
1205
|
+
const binding = parent.name;
|
|
1206
|
+
if (ts.isObjectBindingPattern(binding)) {
|
|
1207
|
+
const names = [];
|
|
1208
|
+
for (const element of binding.elements) {
|
|
1209
|
+
const key = element.propertyName ?? element.name;
|
|
1210
|
+
if (element.dotDotDotToken !== undefined ||
|
|
1211
|
+
!(ts.isIdentifier(key) || ts.isStringLiteral(key)))
|
|
1212
|
+
return undefined;
|
|
1213
|
+
names.push(key.text);
|
|
1214
|
+
}
|
|
1215
|
+
return names;
|
|
1216
|
+
}
|
|
1217
|
+
if (!ts.isIdentifier(binding))
|
|
1218
|
+
return undefined;
|
|
1219
|
+
const names = new Set();
|
|
1220
|
+
let escapes = false;
|
|
1221
|
+
const visit = (current) => {
|
|
1222
|
+
if (ts.isIdentifier(current) && current.text === binding.text && current !== binding) {
|
|
1223
|
+
const owner = current.parent;
|
|
1224
|
+
if (ts.isPropertyAccessExpression(owner) && owner.expression === current)
|
|
1225
|
+
names.add(owner.name.text);
|
|
1226
|
+
else if (!((ts.isPropertyAccessExpression(owner) ||
|
|
1227
|
+
ts.isPropertyAssignment(owner) ||
|
|
1228
|
+
ts.isPropertyDeclaration(owner) ||
|
|
1229
|
+
ts.isMethodDeclaration(owner)) &&
|
|
1230
|
+
owner.name === current))
|
|
1231
|
+
escapes = true;
|
|
1232
|
+
}
|
|
1233
|
+
ts.forEachChild(current, visit);
|
|
1234
|
+
};
|
|
1235
|
+
visit(sf);
|
|
1236
|
+
return escapes ? undefined : [...names].sort(codeUnitOrder);
|
|
1237
|
+
}
|
|
1238
|
+
/** A `workspaces` glob as a matcher over package-relative folder paths. */
|
|
1239
|
+
function workspacePattern(glob) {
|
|
1240
|
+
const source = glob
|
|
1241
|
+
.replace(/^\.\//, "")
|
|
1242
|
+
.replace(/\/+$/, "")
|
|
1243
|
+
.split("/")
|
|
1244
|
+
.map((segment) => segment === "**"
|
|
1245
|
+
? "(?:[^/]+/)*?[^/]*"
|
|
1246
|
+
: segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*"))
|
|
1247
|
+
.join("/");
|
|
1248
|
+
return new RegExp(`^${source}$`);
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* The workspace an app belongs to: the nearest folder above it whose package.json declares
|
|
1252
|
+
* `workspaces` that include the app, and the package folders those globs (less any `!` exclusion)
|
|
1253
|
+
* name, other than the app and any folder that holds it. `undefined` outside one.
|
|
1254
|
+
*/
|
|
1255
|
+
function workspaceSiblings(root) {
|
|
1256
|
+
for (let dir = dirname(root), depth = 0; depth < 8; dir = dirname(dir), depth += 1) {
|
|
1257
|
+
let declared;
|
|
1258
|
+
try {
|
|
1259
|
+
declared = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).workspaces;
|
|
1260
|
+
}
|
|
1261
|
+
catch {
|
|
1262
|
+
declared = undefined;
|
|
1263
|
+
}
|
|
1264
|
+
const listed = Array.isArray(declared)
|
|
1265
|
+
? declared
|
|
1266
|
+
: typeof declared === "object" && declared !== null
|
|
1267
|
+
? Reflect.get(declared, "packages")
|
|
1268
|
+
: undefined;
|
|
1269
|
+
if (Array.isArray(listed)) {
|
|
1270
|
+
const globs = listed.filter((glob) => typeof glob === "string");
|
|
1271
|
+
const include = globs.filter((glob) => !glob.startsWith("!")).map(workspacePattern);
|
|
1272
|
+
const exclude = globs
|
|
1273
|
+
.filter((glob) => glob.startsWith("!"))
|
|
1274
|
+
.map((glob) => workspacePattern(glob.slice(1)));
|
|
1275
|
+
const member = (rel) => include.some((pattern) => pattern.test(rel)) &&
|
|
1276
|
+
!exclude.some((pattern) => pattern.test(rel));
|
|
1277
|
+
// The nearest declaration decides, as package managers read it.
|
|
1278
|
+
if (!member(toPosix(relative(dir, root))))
|
|
1279
|
+
return undefined;
|
|
1280
|
+
const packages = [];
|
|
1281
|
+
const walk = (folder, depth) => {
|
|
1282
|
+
for (const entry of readdirSync(folder, { withFileTypes: true })) {
|
|
1283
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || SKIP_DIRS.has(entry.name))
|
|
1284
|
+
continue;
|
|
1285
|
+
const full = join(folder, entry.name);
|
|
1286
|
+
const rel = toPosix(relative(dir, full));
|
|
1287
|
+
if (exclude.some((pattern) => pattern.test(rel)))
|
|
1288
|
+
continue;
|
|
1289
|
+
if (member(rel) && existsSync(join(full, "package.json"))) {
|
|
1290
|
+
const inside = toPosix(relative(full, root));
|
|
1291
|
+
if (inside.startsWith("../") || isAbsolute(inside))
|
|
1292
|
+
packages.push(full);
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
if (depth < 4)
|
|
1296
|
+
walk(full, depth + 1);
|
|
1297
|
+
}
|
|
1298
|
+
};
|
|
1299
|
+
walk(dir, 0);
|
|
1300
|
+
return packages.sort();
|
|
1301
|
+
}
|
|
1302
|
+
if (dirname(dir) === dir)
|
|
1303
|
+
return undefined;
|
|
1304
|
+
}
|
|
1305
|
+
return undefined;
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Where code in a workspace sibling names an app file that moves: a relative import into the app, or
|
|
1309
|
+
* the file's path in a string or command (`bun -e 'import("./src/x.ts")'` with the app as its working
|
|
1310
|
+
* directory). Only files that mention the app's folder or package name are read further, and a path
|
|
1311
|
+
* the sibling's own package holds is its own file. Report-only: the migration never edits a sibling.
|
|
1312
|
+
*/
|
|
1313
|
+
function siblingReferences(ts, root, pkg, file, text, moves, known, ownName) {
|
|
1314
|
+
const abs = join(pkg, file);
|
|
1315
|
+
// A process started in the app (`cwd: \`${repo}apps/web\``) reads its paths from the app root.
|
|
1316
|
+
const runsInApp = new RegExp(`\\bcwd\\s*:[^,\\n]*\\b${escapeRegExp(posix.basename(toPosix(root)))}\\b`).test(text);
|
|
1317
|
+
const found = new Map();
|
|
1318
|
+
const report = (from, index) => {
|
|
1319
|
+
const to = moves.get(from);
|
|
1320
|
+
if (to === undefined || found.has(from))
|
|
1321
|
+
return;
|
|
1322
|
+
found.set(from, { from, to, line: text.slice(0, index).split("\n").length });
|
|
1323
|
+
};
|
|
1324
|
+
if (/\.(?:[cm]?[jt]sx?)$/.test(file)) {
|
|
1325
|
+
for (const { specifier } of importsOf(ts, file, text)) {
|
|
1326
|
+
const clean = withoutQuery(specifier);
|
|
1327
|
+
const inApp = clean.startsWith(".")
|
|
1328
|
+
? toPosix(relative(root, resolve(dirname(abs), clean)))
|
|
1329
|
+
: ownName !== undefined && clean.startsWith(`${ownName}/`)
|
|
1330
|
+
? clean.slice(ownName.length + 1)
|
|
1331
|
+
: undefined;
|
|
1332
|
+
if (inApp === undefined || inApp.startsWith("../") || isAbsolute(inApp))
|
|
1333
|
+
continue;
|
|
1334
|
+
const target = resolveIn(known, "index.ts", `./${inApp}`);
|
|
1335
|
+
if (target !== undefined)
|
|
1336
|
+
report(target, text.indexOf(specifier));
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
for (const from of moves.keys()) {
|
|
1340
|
+
const stem = SOURCE_FILE.test(from)
|
|
1341
|
+
? from.slice(0, from.length - extname(from).length)
|
|
1342
|
+
: undefined;
|
|
1343
|
+
const pattern = new RegExp(`(?<![\\w.-])(?:\\./)?(?:${escapeRegExp(from)}(?![\\w])${stem === undefined ? "" : `|${escapeRegExp(stem)}(?=["'\`])`})`, "g");
|
|
1344
|
+
for (const match of text.matchAll(pattern)) {
|
|
1345
|
+
// `packages/x/src/a.ts` names another package's `src/a.ts`; only a prefix that reaches the
|
|
1346
|
+
// app counts, and a path the sibling holds itself is its own.
|
|
1347
|
+
const prefix = /(?:[\w.@-]+\/)+$/.exec(text.slice(0, match.index))?.[0] ?? "";
|
|
1348
|
+
const bases = prefix === "" ? [] : [dirname(abs), pkg, dirname(pkg), dirname(dirname(pkg))];
|
|
1349
|
+
if (prefix !== "" && !bases.some((base) => resolve(base, prefix) === root))
|
|
1350
|
+
continue;
|
|
1351
|
+
const own = existsSync(join(pkg, from)) || existsSync(join(dirname(abs), from));
|
|
1352
|
+
if (prefix === "" && own && !runsInApp)
|
|
1353
|
+
continue;
|
|
1354
|
+
report(from, match.index);
|
|
1355
|
+
break;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
return [...found.values()];
|
|
1359
|
+
}
|
|
1360
|
+
/** The package folder an app without its own package.json sits in (`--dir src`), at most two levels
|
|
1361
|
+
* up and never a workspace root, so the scan beside it stays inside one package. */
|
|
1362
|
+
function enclosingPackage(root) {
|
|
1363
|
+
if (existsSync(join(root, "package.json")))
|
|
1364
|
+
return undefined;
|
|
1365
|
+
for (const dir of [dirname(root), dirname(dirname(root))]) {
|
|
1366
|
+
const manifest = join(dir, "package.json");
|
|
1367
|
+
if (dir === root || !existsSync(manifest))
|
|
1368
|
+
continue;
|
|
1369
|
+
try {
|
|
1370
|
+
const parsed = JSON.parse(readFileSync(manifest, "utf8"));
|
|
1371
|
+
const workspace = typeof parsed === "object" && parsed !== null && "workspaces" in parsed;
|
|
1372
|
+
return workspace ? undefined : dir;
|
|
1373
|
+
}
|
|
1374
|
+
catch {
|
|
1375
|
+
return undefined;
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
return undefined;
|
|
1379
|
+
}
|
|
685
1380
|
function rewriteImports(ts, file, text, rewrite) {
|
|
686
1381
|
const replace = (code, offset, out, kind) => {
|
|
687
1382
|
const sf = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true, kind);
|
|
@@ -702,6 +1397,8 @@ function rewriteImports(ts, file, text, rewrite) {
|
|
|
702
1397
|
if (arg !== undefined && ts.isStringLiteralLike(arg))
|
|
703
1398
|
literal = arg;
|
|
704
1399
|
}
|
|
1400
|
+
else if (ts.isCallExpression(node))
|
|
1401
|
+
literal = mockedSpecifier(ts, node);
|
|
705
1402
|
if (literal !== undefined) {
|
|
706
1403
|
const next = rewrite(literal.text);
|
|
707
1404
|
if (next !== undefined && next !== literal.text) {
|
|
@@ -801,20 +1498,132 @@ function renameRetiredTypes(ts, file, text) {
|
|
|
801
1498
|
// ---------------------------------------------------------------------------------------------------
|
|
802
1499
|
// The migration
|
|
803
1500
|
// ---------------------------------------------------------------------------------------------------
|
|
804
|
-
const ROOT_MOVES = {
|
|
805
|
-
"backend.ts": "backend/app.ts",
|
|
806
|
-
"framework.ts": "backend/framework.ts",
|
|
807
|
-
};
|
|
808
1501
|
/** Root files that stay where they are: the CLI's config, and scripts no build loads. */
|
|
809
|
-
|
|
1502
|
+
/** The config files the loader reads, old layout included; a path field may live in any of them. */
|
|
1503
|
+
const CONFIG_FILES = ["nifra.config.ts", "framework.ts", "backend/framework.ts"];
|
|
1504
|
+
/**
|
|
1505
|
+
* The module paths a config exports, read statically: `clientModule` and the component pipeline's
|
|
1506
|
+
* `entries`. The browser loads `clientModule` and `entries.client`; `ssr` and `rsc` run on the server.
|
|
1507
|
+
*/
|
|
1508
|
+
function configEntries(ts, file, text) {
|
|
1509
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
1510
|
+
const bare = (node) => {
|
|
1511
|
+
let current = node;
|
|
1512
|
+
while (current !== undefined &&
|
|
1513
|
+
(ts.isAsExpression(current) ||
|
|
1514
|
+
ts.isSatisfiesExpression(current) ||
|
|
1515
|
+
ts.isParenthesizedExpression(current)))
|
|
1516
|
+
current = current.expression;
|
|
1517
|
+
return current;
|
|
1518
|
+
};
|
|
1519
|
+
const read = configPathReader(ts, sf);
|
|
1520
|
+
const out = [];
|
|
1521
|
+
const literal = (field, role, node) => {
|
|
1522
|
+
const path = read(node);
|
|
1523
|
+
if (path !== undefined)
|
|
1524
|
+
out.push({ ...path, field, role });
|
|
1525
|
+
};
|
|
1526
|
+
for (const statement of sf.statements) {
|
|
1527
|
+
if (!ts.isVariableStatement(statement) ||
|
|
1528
|
+
!hasModifier(ts, statement, ts.SyntaxKind.ExportKeyword))
|
|
1529
|
+
continue;
|
|
1530
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
1531
|
+
if (!ts.isIdentifier(declaration.name))
|
|
1532
|
+
continue;
|
|
1533
|
+
if (declaration.name.text === "clientModule")
|
|
1534
|
+
literal("clientModule", "frontend", declaration.initializer);
|
|
1535
|
+
if (declaration.name.text !== "componentPipeline")
|
|
1536
|
+
continue;
|
|
1537
|
+
const pipeline = bare(declaration.initializer);
|
|
1538
|
+
if (pipeline === undefined || !ts.isObjectLiteralExpression(pipeline))
|
|
1539
|
+
continue;
|
|
1540
|
+
for (const property of pipeline.properties) {
|
|
1541
|
+
if (!ts.isPropertyAssignment(property) || property.name.getText(sf) !== "entries")
|
|
1542
|
+
continue;
|
|
1543
|
+
const entries = bare(property.initializer);
|
|
1544
|
+
if (entries === undefined || !ts.isObjectLiteralExpression(entries))
|
|
1545
|
+
continue;
|
|
1546
|
+
for (const entry of entries.properties) {
|
|
1547
|
+
if (!ts.isPropertyAssignment(entry))
|
|
1548
|
+
continue;
|
|
1549
|
+
const name = entry.name.getText(sf).replace(/^["']|["']$/g, "");
|
|
1550
|
+
if (name === "client" || name === "ssr" || name === "rsc")
|
|
1551
|
+
literal(`componentPipeline.entries.${name}`, name === "client" ? "frontend" : "backend", entry.initializer);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
return out;
|
|
1557
|
+
}
|
|
1558
|
+
/** The package an import names: `@scope/pkg` or `pkg`, without a subpath. */
|
|
1559
|
+
const packageName = (specifier) => {
|
|
1560
|
+
const parts = specifier.split("/");
|
|
1561
|
+
return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? "");
|
|
1562
|
+
};
|
|
1563
|
+
const isRootScript = (file, rootMoves) => !file.includes("/") && SOURCE_FILE.test(file) && rootMoves[file] === undefined;
|
|
810
1564
|
const isTestFile = (file) => /(?:^|\/)(?:test|tests|__tests__)\//.test(file) || /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file);
|
|
1565
|
+
/** The web config an app has, old root `framework.ts` included, or `undefined`. */
|
|
1566
|
+
const webConfigFile = (root, rootMoves) => rootMoves["framework.ts"] !== undefined
|
|
1567
|
+
? "framework.ts"
|
|
1568
|
+
: existsSync(join(root, FRAMEWORK_FILE))
|
|
1569
|
+
? FRAMEWORK_FILE
|
|
1570
|
+
: hasWebConfig(root, ".")
|
|
1571
|
+
? CONFIG_FILE
|
|
1572
|
+
: undefined;
|
|
1573
|
+
/**
|
|
1574
|
+
* `"api"` when the app declares `"nifra": { "kind": "api" }`, or has no web config, no `routes/` and a
|
|
1575
|
+
* backend; `"web"` when it has `routes/`. Web config without `routes/` could be either, so it throws.
|
|
1576
|
+
*/
|
|
1577
|
+
function layoutMode(root, rootMoves) {
|
|
1578
|
+
const declared = createKindProbe(root)(root);
|
|
1579
|
+
if (declared.invalid !== undefined)
|
|
1580
|
+
throw new Error(declared.invalid);
|
|
1581
|
+
const config = webConfigFile(root, rootMoves);
|
|
1582
|
+
if (declared.kind === "api") {
|
|
1583
|
+
if (config === undefined)
|
|
1584
|
+
return "api";
|
|
1585
|
+
throw new Error(`[nifra] ${declared.manifest} declares "nifra": { "kind": "api" }, but ${root} has web config (${config}); remove "kind" to migrate it as a web app, or remove the web config`);
|
|
1586
|
+
}
|
|
1587
|
+
if (existsSync(join(root, "routes")))
|
|
1588
|
+
return "web";
|
|
1589
|
+
const nested = nestedAppHint(root);
|
|
1590
|
+
if (config !== undefined) {
|
|
1591
|
+
throw new Error(`[nifra] no routes/ directory in ${root}, but it has web config (${config}). A web app is file-routed under routes/; if this one serves no pages, remove the web config and declare "nifra": { "kind": "api" } in package.json${nested}`);
|
|
1592
|
+
}
|
|
1593
|
+
if (declared.kind === "web")
|
|
1594
|
+
throw new Error(`[nifra] no routes/ directory in ${root}${nested}`);
|
|
1595
|
+
if (rootMoves["backend.ts"] === undefined && !existsSync(join(root, BACKEND_APP_FILE))) {
|
|
1596
|
+
throw new Error(`[nifra] ${root} has no routes/, no web config and no backend (a backend.ts that exports \`backend\`, or ${BACKEND_APP_FILE}); there is nothing to migrate${nested}`);
|
|
1597
|
+
}
|
|
1598
|
+
return "api";
|
|
1599
|
+
}
|
|
1600
|
+
/** An app one folder down (`src/routes/`): the `--dir` that migrates it, as a sentence to append. */
|
|
1601
|
+
function nestedAppHint(root) {
|
|
1602
|
+
const found = readdirSync(root, { withFileTypes: true })
|
|
1603
|
+
.filter((entry) => entry.isDirectory() &&
|
|
1604
|
+
!entry.name.startsWith(".") &&
|
|
1605
|
+
!SKIP_DIRS.has(entry.name) &&
|
|
1606
|
+
existsSync(join(root, entry.name, "routes")))
|
|
1607
|
+
.map((entry) => entry.name)
|
|
1608
|
+
.sort(codeUnitOrder);
|
|
1609
|
+
const first = found[0];
|
|
1610
|
+
if (first === undefined)
|
|
1611
|
+
return "";
|
|
1612
|
+
return `. ${found.map((name) => `${name}/routes/`).join(", ")} exists: run \`nifra migrate layout --dir ${first}\` to migrate the app there`;
|
|
1613
|
+
}
|
|
811
1614
|
export async function migrateLayout(appRoot, options = {}) {
|
|
812
1615
|
const ts = options.typescript ?? (await importTypeScript());
|
|
813
1616
|
if (ts === undefined)
|
|
814
1617
|
throw new Error("[nifra] `nifra migrate layout` needs TypeScript: bun add -d typescript");
|
|
815
1618
|
const root = resolve(appRoot);
|
|
816
|
-
|
|
817
|
-
|
|
1619
|
+
// Only a root file that exports what the old layout read moves; any other backend.ts is a module.
|
|
1620
|
+
const rootMoves = Object.fromEntries(retiredRootFiles(root));
|
|
1621
|
+
const mode = layoutMode(root, rootMoves);
|
|
1622
|
+
// An API-only app has no browser: nothing to split, and every file is server code where it is.
|
|
1623
|
+
const api = mode === "api";
|
|
1624
|
+
const zones = appZones(root);
|
|
1625
|
+
const configured = [...(zones.backend ?? []), ...(zones.frontend ?? []), ...(zones.shared ?? [])];
|
|
1626
|
+
const inConfiguredFolder = (file) => configured.some((folder) => file === folder || file.startsWith(`${folder}/`));
|
|
818
1627
|
const original = new Map();
|
|
819
1628
|
const everyFile = listFiles(root);
|
|
820
1629
|
for (const file of everyFile) {
|
|
@@ -830,7 +1639,7 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
830
1639
|
// 1-2. Route halves and middleware.
|
|
831
1640
|
const backendHalves = new Map();
|
|
832
1641
|
for (const [file, text] of original) {
|
|
833
|
-
if (!file.startsWith("routes/") || file.includes(".backend."))
|
|
1642
|
+
if (api || !file.startsWith("routes/") || file.includes(".backend."))
|
|
834
1643
|
continue;
|
|
835
1644
|
if (/(?:^|\/)_middleware\.[cm]?[jt]sx?$/.test(file)) {
|
|
836
1645
|
const target = posix.join(posix.dirname(file), "_layout.backend.ts");
|
|
@@ -843,13 +1652,18 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
843
1652
|
continue;
|
|
844
1653
|
const backendFile = file.replace(ROUTE_FILE, ".backend.ts");
|
|
845
1654
|
const specifier = `./${posix.basename(backendFile)}`;
|
|
1655
|
+
const sharedFile = file.replace(ROUTE_FILE, ".shared.ts");
|
|
1656
|
+
// A taken name leaves data both halves read copied into each, as reported.
|
|
1657
|
+
const splitOptions = next.has(sharedFile) || existsSync(join(root, sharedFile))
|
|
1658
|
+
? {}
|
|
1659
|
+
: { sharedFile: posix.basename(sharedFile) };
|
|
846
1660
|
let result;
|
|
847
1661
|
if (/\.(?:svelte|vue)$/.test(file)) {
|
|
848
1662
|
const blocks = scriptBlocks(text);
|
|
849
1663
|
const block = blocks.find((b) => b.module) ?? blocks.find((b) => !b.setup && file.endsWith(".vue"));
|
|
850
1664
|
if (block === undefined)
|
|
851
1665
|
continue;
|
|
852
|
-
const inner = splitRouteSource(ts, text.slice(block.start, block.end), "block.ts", specifier);
|
|
1666
|
+
const inner = splitRouteSource(ts, text.slice(block.start, block.end), "block.ts", specifier, splitOptions);
|
|
853
1667
|
result = {
|
|
854
1668
|
...inner,
|
|
855
1669
|
frontend: `${text.slice(0, block.start)}\n${inner.frontend}${text.slice(block.end)}`,
|
|
@@ -865,13 +1679,18 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
865
1679
|
continue;
|
|
866
1680
|
}
|
|
867
1681
|
else {
|
|
868
|
-
result = splitRouteSource(ts, text, file, specifier);
|
|
1682
|
+
result = splitRouteSource(ts, text, file, specifier, splitOptions);
|
|
869
1683
|
}
|
|
870
1684
|
if (result.backend === undefined)
|
|
871
1685
|
continue;
|
|
872
1686
|
next.set(file, result.frontend);
|
|
873
1687
|
backendHalves.set(backendFile, mergeModules(ts, backendHalves.get(backendFile) ?? "", result.backend));
|
|
874
|
-
|
|
1688
|
+
if (result.shared === undefined)
|
|
1689
|
+
splits.push({ file, backend: backendFile, moved: [...result.moved] });
|
|
1690
|
+
else {
|
|
1691
|
+
next.set(sharedFile, result.shared);
|
|
1692
|
+
splits.push({ file, backend: backendFile, moved: [...result.moved], shared: sharedFile });
|
|
1693
|
+
}
|
|
875
1694
|
for (const reason of result.issues)
|
|
876
1695
|
issues.push({ file, reason });
|
|
877
1696
|
}
|
|
@@ -880,14 +1699,114 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
880
1699
|
next.set(file, existing === undefined ? text : mergeModules(ts, existing, text));
|
|
881
1700
|
}
|
|
882
1701
|
// 3-4. Zoning by runtime reachability, on the post-split graph.
|
|
883
|
-
for (const [from, to] of Object.entries(
|
|
1702
|
+
for (const [from, to] of Object.entries(rootMoves))
|
|
884
1703
|
if (next.has(from))
|
|
885
1704
|
moveMap.set(from, to);
|
|
886
1705
|
// Assets (images, fonts) resolve too, so they move with the modules that import them.
|
|
887
1706
|
const known = new Set([...next.keys(), ...everyFile]);
|
|
1707
|
+
const knownPaths = new Set([...known].map((file) => join(root, file)));
|
|
1708
|
+
// Route halves the split creates resolve before they are written.
|
|
1709
|
+
const resolver = createSpecifierResolver({
|
|
1710
|
+
moduleExists: (path) => knownPaths.has(path) || isFile(path),
|
|
1711
|
+
});
|
|
1712
|
+
const realRoot = (() => {
|
|
1713
|
+
try {
|
|
1714
|
+
return realpathSync(root);
|
|
1715
|
+
}
|
|
1716
|
+
catch {
|
|
1717
|
+
return root;
|
|
1718
|
+
}
|
|
1719
|
+
})();
|
|
1720
|
+
/** An app file as the migration names it, from a path the resolver found (real, or as planned). */
|
|
1721
|
+
const appFile = (path) => {
|
|
1722
|
+
if (path === undefined)
|
|
1723
|
+
return undefined;
|
|
1724
|
+
for (const base of [realRoot, root]) {
|
|
1725
|
+
const rel = toPosix(relative(base, path));
|
|
1726
|
+
if (!rel.startsWith("../") && !isAbsolute(rel))
|
|
1727
|
+
return rel;
|
|
1728
|
+
}
|
|
1729
|
+
return undefined;
|
|
1730
|
+
};
|
|
1731
|
+
/** A non-relative specifier the app's tsconfig `paths`/`baseUrl` or package.json `imports` maps to
|
|
1732
|
+
* one of its files, as the bundler reads it. */
|
|
1733
|
+
const viaAlias = (file, specifier) => {
|
|
1734
|
+
if (specifier.startsWith(".") || isAbsolute(specifier))
|
|
1735
|
+
return undefined;
|
|
1736
|
+
const rel = appFile(resolver(join(root, file), withoutQuery(specifier)));
|
|
1737
|
+
return rel === undefined || !known.has(rel) ? undefined : rel;
|
|
1738
|
+
};
|
|
1739
|
+
const resolveAny = (file, specifier) => resolveIn(known, file, specifier) ?? viaAlias(file, specifier);
|
|
1740
|
+
const ownName = (() => {
|
|
1741
|
+
try {
|
|
1742
|
+
const name = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name;
|
|
1743
|
+
return typeof name === "string" ? name : undefined;
|
|
1744
|
+
}
|
|
1745
|
+
catch {
|
|
1746
|
+
return undefined;
|
|
1747
|
+
}
|
|
1748
|
+
})();
|
|
1749
|
+
const installed = new Map();
|
|
1750
|
+
/** Whether a bare specifier names something outside the app that exists: a built-in, a URL or a
|
|
1751
|
+
* scheme the bundler owns, the app's own package name, or a package installed at or above it. */
|
|
1752
|
+
const external = (specifier) => {
|
|
1753
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(specifier) || specifier === "bun")
|
|
1754
|
+
return true;
|
|
1755
|
+
const name = packageName(withoutQuery(specifier));
|
|
1756
|
+
if (builtinModules.includes(name) || name === ownName)
|
|
1757
|
+
return true;
|
|
1758
|
+
let found = installed.get(name);
|
|
1759
|
+
if (found === undefined) {
|
|
1760
|
+
const types = `@types/${name.startsWith("@") ? name.slice(1).replace("/", "__") : name}`;
|
|
1761
|
+
found = false;
|
|
1762
|
+
for (let dir = root;; dir = dirname(dir)) {
|
|
1763
|
+
if (existsSync(join(dir, "node_modules", name)) ||
|
|
1764
|
+
existsSync(join(dir, "node_modules", types))) {
|
|
1765
|
+
found = true;
|
|
1766
|
+
break;
|
|
1767
|
+
}
|
|
1768
|
+
if (dirname(dir) === dir)
|
|
1769
|
+
break;
|
|
1770
|
+
}
|
|
1771
|
+
installed.set(name, found);
|
|
1772
|
+
}
|
|
1773
|
+
return found;
|
|
1774
|
+
};
|
|
1775
|
+
// A test (or any module) that imports `loader` from a page would import a name the page no longer
|
|
1776
|
+
// exports: it reads it from the backend half instead.
|
|
1777
|
+
const splitRoutes = new Map();
|
|
1778
|
+
for (const split of splits)
|
|
1779
|
+
if (next.has(split.file))
|
|
1780
|
+
splitRoutes.set(split.file, { ...split, moved: new Set(split.moved) });
|
|
1781
|
+
/** `base` is where `file` is relative to: the app root, or the package folder around it. */
|
|
1782
|
+
const splitTarget = (base, file, resolve) => (specifier) => {
|
|
1783
|
+
const route = resolve(specifier);
|
|
1784
|
+
const split = route === undefined ? undefined : splitRoutes.get(route);
|
|
1785
|
+
if (route === undefined || split === undefined)
|
|
1786
|
+
return undefined;
|
|
1787
|
+
const inBase = (path) => toPosix(relative(base, join(root, path)));
|
|
1788
|
+
const relativePath = backendSpecifier(specifier, inBase(route), inBase(split.backend), file);
|
|
1789
|
+
const respelled = specifier.startsWith(".")
|
|
1790
|
+
? relativePath
|
|
1791
|
+
: (resolver.respell(join(base, file), specifier, join(root, split.backend)) ?? relativePath);
|
|
1792
|
+
return { backend: split.backend, moved: split.moved, specifier: respelled };
|
|
1793
|
+
};
|
|
1794
|
+
const redirectedFiles = new Set();
|
|
1795
|
+
for (const [file, text] of splitRoutes.size === 0 ? [] : next) {
|
|
1796
|
+
if (splitRoutes.has(file) || !/\.[cm]?[jt]sx?$/.test(file))
|
|
1797
|
+
continue;
|
|
1798
|
+
const redirected = redirectMovedImports(ts, file, text, splitTarget(root, file, (specifier) => resolveAny(file, specifier)));
|
|
1799
|
+
if (redirected.text !== text) {
|
|
1800
|
+
next.set(file, redirected.text);
|
|
1801
|
+
redirectedFiles.add(file);
|
|
1802
|
+
}
|
|
1803
|
+
for (const reason of redirected.issues)
|
|
1804
|
+
issues.push({ file, reason });
|
|
1805
|
+
}
|
|
888
1806
|
const edges = new Map();
|
|
889
|
-
for (const [file, text] of next) {
|
|
1807
|
+
for (const [file, text] of api ? [] : next) {
|
|
890
1808
|
const refs = [];
|
|
1809
|
+
const reported = new Set();
|
|
891
1810
|
for (const ref of importsOf(ts, file, text)) {
|
|
892
1811
|
if (ref.specifier === "@nifrajs/web/plugins/vite-server-only") {
|
|
893
1812
|
issues.push({
|
|
@@ -895,22 +1814,45 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
895
1814
|
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
1815
|
});
|
|
897
1816
|
}
|
|
898
|
-
const target =
|
|
1817
|
+
const target = resolveAny(file, ref.specifier);
|
|
899
1818
|
if (target !== undefined)
|
|
900
1819
|
refs.push({ target, typeOnly: ref.typeOnly });
|
|
901
|
-
else if (
|
|
1820
|
+
else if (!ref.specifier.startsWith(".") &&
|
|
1821
|
+
!isAbsolute(ref.specifier) &&
|
|
1822
|
+
RETIRED_SPECIFIERS[ref.specifier] === undefined &&
|
|
1823
|
+
resolver(join(root, file), ref.specifier) === undefined &&
|
|
1824
|
+
!external(ref.specifier) &&
|
|
1825
|
+
!reported.has(ref.specifier)) {
|
|
1826
|
+
reported.add(ref.specifier);
|
|
902
1827
|
issues.push({
|
|
903
1828
|
file,
|
|
904
|
-
reason: `
|
|
1829
|
+
reason: `"${ref.specifier}" names no installed package and no file through the app's tsconfig paths or package.json imports, so the migration cannot tell where it belongs; check it by hand`,
|
|
905
1830
|
});
|
|
1831
|
+
}
|
|
906
1832
|
}
|
|
907
1833
|
edges.set(file, refs);
|
|
908
1834
|
}
|
|
1835
|
+
// The config's module paths are entries too: the browser loads `clientModule`.
|
|
1836
|
+
const entries = CONFIG_FILES.filter((file) => next.has(file)).flatMap((file) => configEntries(ts, file, next.get(file) ?? "").map((entry) => ({
|
|
1837
|
+
...entry,
|
|
1838
|
+
file,
|
|
1839
|
+
// A string is read from the app root, whichever config file holds it; a joined path from the
|
|
1840
|
+
// config's own folder.
|
|
1841
|
+
target: entry.base === "app"
|
|
1842
|
+
? resolveAny("nifra.config.ts", entry.path)
|
|
1843
|
+
: resolveIn(known, file, entry.path.startsWith("../") ? entry.path : `./${entry.path}`),
|
|
1844
|
+
})));
|
|
1845
|
+
const entryTargets = (role) => new Set(entries.flatMap((entry) => entry.role === role && entry.target !== undefined ? [entry.target] : []));
|
|
1846
|
+
const browserEntries = entryTargets("frontend");
|
|
1847
|
+
const serverEntries = entryTargets("backend");
|
|
909
1848
|
const files = [...new Set([...next.keys(), ...everyFile.filter((f) => !original.has(f))])];
|
|
910
|
-
const frontendRoots =
|
|
1849
|
+
const frontendRoots = [
|
|
1850
|
+
...files.filter((f) => f.startsWith("routes/") && ROUTE_FILE.test(f) && !f.includes(".backend.") && !isTestFile(f)),
|
|
1851
|
+
...browserEntries,
|
|
1852
|
+
];
|
|
911
1853
|
// A route page the server imports - a generated `server-manifest.ts` imports them all - is rendered
|
|
912
1854
|
// 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);
|
|
1855
|
+
const pages = new Set(frontendRoots.filter((f) => !browserEntries.has(f)));
|
|
914
1856
|
const reach = (roots, stopAt) => {
|
|
915
1857
|
const seen = new Set();
|
|
916
1858
|
const queue = [...roots];
|
|
@@ -927,17 +1869,23 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
927
1869
|
}
|
|
928
1870
|
return seen;
|
|
929
1871
|
};
|
|
930
|
-
const backendRoots =
|
|
931
|
-
f.startsWith("
|
|
932
|
-
|
|
933
|
-
|
|
1872
|
+
const backendRoots = [
|
|
1873
|
+
...files.filter((f) => (f.startsWith("routes/") && f.includes(".backend.")) ||
|
|
1874
|
+
f.startsWith("backend/") ||
|
|
1875
|
+
rootMoves[f] !== undefined ||
|
|
1876
|
+
(isRootScript(f, rootMoves) &&
|
|
1877
|
+
f !== "nifra.config.ts" &&
|
|
1878
|
+
!isTestFile(f) &&
|
|
1879
|
+
!browserEntries.has(f))),
|
|
1880
|
+
...serverEntries,
|
|
1881
|
+
];
|
|
934
1882
|
const browser = reach(frontendRoots);
|
|
935
1883
|
const server = reach(backendRoots, pages);
|
|
936
1884
|
// What the app's own server modules reach, without the root scripts: a root file in here is a module
|
|
937
1885
|
// the app imports, not an entry, so it moves into a zone.
|
|
938
|
-
const appServer = reach(backendRoots.filter((f) => f.includes("/") ||
|
|
1886
|
+
const appServer = reach(backendRoots.filter((f) => f.includes("/") || rootMoves[f] !== undefined || serverEntries.has(f)), pages);
|
|
939
1887
|
// A `.server` module ran empty in the browser, so only server code used it: it moves under backend/.
|
|
940
|
-
for (const file of files) {
|
|
1888
|
+
for (const file of api ? [] : files) {
|
|
941
1889
|
if (!LEGACY_SERVER.test(file) || isTestFile(file))
|
|
942
1890
|
continue;
|
|
943
1891
|
const parts = file.replace(LEGACY_SERVER, "$1").split("/");
|
|
@@ -952,12 +1900,15 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
952
1900
|
});
|
|
953
1901
|
}
|
|
954
1902
|
}
|
|
1903
|
+
/** With `zones`, the files that stay where they are and the side package.json gives them. */
|
|
1904
|
+
const inPlace = new Map();
|
|
955
1905
|
const zoneOf = (file) => browser.has(file) && server.has(file) ? "shared" : browser.has(file) ? "frontend" : "backend";
|
|
956
|
-
for (const file of files) {
|
|
1906
|
+
for (const file of api ? [] : files) {
|
|
957
1907
|
if (moveMap.has(file) ||
|
|
958
1908
|
isTestFile(file) ||
|
|
959
1909
|
file.endsWith(".d.ts") ||
|
|
960
|
-
file === "nifra.config.ts"
|
|
1910
|
+
file === "nifra.config.ts" ||
|
|
1911
|
+
inConfiguredFolder(file))
|
|
961
1912
|
continue;
|
|
962
1913
|
if (/\.(?:frontend|backend|shared|fn)\.[cm]?[jt]sx?$/.test(file))
|
|
963
1914
|
continue;
|
|
@@ -968,8 +1919,67 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
968
1919
|
}
|
|
969
1920
|
else if (!browser.has(file) && !appServer.has(file))
|
|
970
1921
|
continue;
|
|
971
|
-
|
|
1922
|
+
const zone = zoneOf(file);
|
|
1923
|
+
if (DATA_FILE.test(file) && options.zones === true) {
|
|
1924
|
+
inPlace.set(file, zone);
|
|
1925
|
+
continue;
|
|
1926
|
+
}
|
|
1927
|
+
if (DATA_FILE.test(file)) {
|
|
1928
|
+
const folder = file.includes("/") ? posix.dirname(file) : undefined;
|
|
1929
|
+
issues.push({
|
|
1930
|
+
file,
|
|
1931
|
+
reason: `is data ${zone === "shared" ? "both sides import" : `${zone} code imports`}, and a build only loads files in a zone; it stays here because other tools may read it by path. Move it to "${zone}/${file}" by hand, or ${folder === undefined ? "into a folder listed" : `list "${folder}"`} under "nifra": { "zones": { "${zone}": [...] } } in package.json`,
|
|
1932
|
+
});
|
|
1933
|
+
continue;
|
|
1934
|
+
}
|
|
1935
|
+
moveMap.set(file, `${zone}/${file}`);
|
|
1936
|
+
}
|
|
1937
|
+
// A folder whose reached files all move into one zone takes its other files along: a file nothing
|
|
1938
|
+
// imports at runtime (a component not used yet, an asset) has no zone of its own, and leaving it
|
|
1939
|
+
// behind splits the folder for every tool that lists it. Server built-ins never go to the browser.
|
|
1940
|
+
const zonesUnder = new Map();
|
|
1941
|
+
for (const [from, to] of moveMap) {
|
|
1942
|
+
if (isTestFile(from))
|
|
1943
|
+
continue;
|
|
1944
|
+
for (let dir = posix.dirname(from); dir !== "."; dir = posix.dirname(dir)) {
|
|
1945
|
+
zonesUnder.set(dir, (zonesUnder.get(dir) ?? new Set()).add(to.split("/")[0] ?? ""));
|
|
1946
|
+
}
|
|
972
1947
|
}
|
|
1948
|
+
for (const file of api ? [] : files) {
|
|
1949
|
+
if (moveMap.has(file) || browser.has(file) || server.has(file))
|
|
1950
|
+
continue;
|
|
1951
|
+
if (ZONE_DIRS.has(file.split("/")[0] ?? "") || inConfiguredFolder(file))
|
|
1952
|
+
continue;
|
|
1953
|
+
// A test and its snapshot follow their subject below.
|
|
1954
|
+
if (isTestFile(file) ||
|
|
1955
|
+
file.endsWith(".snap") ||
|
|
1956
|
+
file.endsWith(".d.ts") ||
|
|
1957
|
+
DATA_FILE.test(file))
|
|
1958
|
+
continue;
|
|
1959
|
+
let zones;
|
|
1960
|
+
for (let dir = posix.dirname(file); dir !== "." && zones === undefined; dir = posix.dirname(dir)) {
|
|
1961
|
+
// A nested package is its own install, never part of the app's folder.
|
|
1962
|
+
if (existsSync(join(root, dir, "package.json")))
|
|
1963
|
+
break;
|
|
1964
|
+
zones = zonesUnder.get(dir);
|
|
1965
|
+
}
|
|
1966
|
+
const [zone] = zones?.size === 1 ? zones : [];
|
|
1967
|
+
if (zone === undefined)
|
|
1968
|
+
continue;
|
|
1969
|
+
if (zone !== "backend" && /from\s+["'](?:node:|bun:)/.test(next.get(file) ?? ""))
|
|
1970
|
+
continue;
|
|
1971
|
+
moveMap.set(file, `${zone}/${file}`);
|
|
1972
|
+
}
|
|
1973
|
+
if (options.zones === true) {
|
|
1974
|
+
for (const [from, to] of moveMap) {
|
|
1975
|
+
const side = FOLDER_SIDES.find((side) => to === `${side}/${from}`);
|
|
1976
|
+
if (side === undefined)
|
|
1977
|
+
continue;
|
|
1978
|
+
inPlace.set(from, side);
|
|
1979
|
+
moveMap.delete(from);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
const declared = zoneDeclarations(inPlace, files, (file) => next.get(file), inConfiguredFolder);
|
|
973
1983
|
const claimed = new Map();
|
|
974
1984
|
for (const [from, to] of moveMap) {
|
|
975
1985
|
const holder = claimed.get(to) ?? (known.has(to) && !moveMap.has(to) ? to : undefined);
|
|
@@ -983,36 +1993,165 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
983
1993
|
});
|
|
984
1994
|
moveMap.delete(from);
|
|
985
1995
|
}
|
|
986
|
-
|
|
987
|
-
|
|
1996
|
+
const newPath = (file) => moveMap.get(file) ?? file;
|
|
1997
|
+
// A test beside its subject (`x.test.ts` by `x.ts`, or in a `__tests__/` under its folder) moves
|
|
1998
|
+
// with it, snapshots included, once the subject's place is settled; a test never decides a zone.
|
|
1999
|
+
const present = new Set(files);
|
|
2000
|
+
for (const test of files) {
|
|
2001
|
+
const stem = /^(.+?)\.(?:test|spec)\.[cm]?[jt]sx?$/.exec(posix.basename(test))?.[1];
|
|
2002
|
+
if (stem === undefined || moveMap.has(test) || !next.has(test))
|
|
2003
|
+
continue;
|
|
2004
|
+
const dir = posix.dirname(test);
|
|
2005
|
+
const nested = posix.basename(dir) === "__tests__";
|
|
2006
|
+
const home = nested ? posix.dirname(dir) : dir;
|
|
2007
|
+
const subjects = SUBJECT_EXTENSIONS.map((ext) => posix.join(home, `${stem}${ext}`)).filter((file) => present.has(file) && !isTestFile(file));
|
|
2008
|
+
const subject = subjects.find((file) => moveMap.has(file));
|
|
2009
|
+
if (subject === undefined)
|
|
2010
|
+
continue;
|
|
2011
|
+
if (new Set(subjects.map((file) => posix.dirname(newPath(file)))).size > 1) {
|
|
2012
|
+
issues.push({
|
|
2013
|
+
file: test,
|
|
2014
|
+
reason: `tests ${subjects.map((file) => `"${file}"`).join(" and ")}, which end up in different folders, so it stays here; move it by hand`,
|
|
2015
|
+
});
|
|
2016
|
+
continue;
|
|
2017
|
+
}
|
|
2018
|
+
const to = posix.join(posix.dirname(newPath(subject)), nested ? "__tests__" : "", posix.basename(test));
|
|
2019
|
+
const pairs = [[test, to]];
|
|
2020
|
+
const snapshot = posix.join(dir, "__snapshots__", `${posix.basename(test)}.snap`);
|
|
2021
|
+
if (present.has(snapshot))
|
|
2022
|
+
pairs.push([
|
|
2023
|
+
snapshot,
|
|
2024
|
+
posix.join(posix.dirname(to), "__snapshots__", posix.basename(snapshot)),
|
|
2025
|
+
]);
|
|
2026
|
+
const taken = pairs.find(([, to]) => claimed.has(to) || (known.has(to) && !moveMap.has(to)));
|
|
2027
|
+
if (taken !== undefined) {
|
|
2028
|
+
issues.push({
|
|
2029
|
+
file: test,
|
|
2030
|
+
reason: `would follow "${subject}" to "${taken[1]}", which already exists or another file moves to; move it by hand`,
|
|
2031
|
+
});
|
|
2032
|
+
continue;
|
|
2033
|
+
}
|
|
2034
|
+
for (const [from, to] of pairs) {
|
|
2035
|
+
moveMap.set(from, to);
|
|
2036
|
+
claimed.set(to, from);
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
for (const [from, to] of [
|
|
2040
|
+
...moveMap,
|
|
2041
|
+
...[...inPlace].map(([file, side]) => [file, side]),
|
|
2042
|
+
]) {
|
|
2043
|
+
// A test is never bundled, so its own server imports decide nothing.
|
|
2044
|
+
if (isTestFile(from))
|
|
2045
|
+
continue;
|
|
2046
|
+
if ((to === "shared" || to.startsWith("shared/")) &&
|
|
2047
|
+
/from\s+["'](?:node:|bun:)/.test(next.get(from) ?? "")) {
|
|
988
2048
|
issues.push({
|
|
989
2049
|
file: from,
|
|
990
|
-
reason: `both halves import it at runtime, but it uses a server built-in; split it before moving to ${to}`,
|
|
2050
|
+
reason: `both halves import it at runtime, but it uses a server built-in; split it before ${to === "shared" ? "zoning it shared" : `moving to ${to}`}`,
|
|
991
2051
|
});
|
|
992
2052
|
}
|
|
993
2053
|
}
|
|
994
2054
|
// 5. Rewrite imports to the new locations.
|
|
995
|
-
const newPath = (file) => moveMap.get(file) ?? file;
|
|
996
2055
|
const final = new Map();
|
|
997
2056
|
const rewritten = [];
|
|
2057
|
+
// The app as the migration leaves it, where every rewritten specifier must reach its target.
|
|
2058
|
+
const planned = new Set([...known].filter((file) => next.has(file) || !original.has(file)).map(newPath));
|
|
2059
|
+
const plannedResolver = createSpecifierResolver({
|
|
2060
|
+
moduleExists: (path) => {
|
|
2061
|
+
const file = appFile(path);
|
|
2062
|
+
if (file !== undefined && (planned.has(file) || known.has(file)))
|
|
2063
|
+
return planned.has(file);
|
|
2064
|
+
return isFile(path);
|
|
2065
|
+
},
|
|
2066
|
+
});
|
|
2067
|
+
/** Whether `specifier`, written in `from` after the moves, loads `target` there. */
|
|
2068
|
+
const reaches = (from, specifier, target) => specifier.startsWith(".")
|
|
2069
|
+
? resolveIn(planned, from, specifier) === target
|
|
2070
|
+
: appFile(plannedResolver(join(root, from), withoutQuery(specifier))) === target;
|
|
2071
|
+
const unreached = new Set();
|
|
2072
|
+
const unreachable = (file, specifier, target, from) => {
|
|
2073
|
+
if (unreached.has(`${file}\0${specifier}`))
|
|
2074
|
+
return;
|
|
2075
|
+
unreached.add(`${file}\0${specifier}`);
|
|
2076
|
+
issues.push({
|
|
2077
|
+
file,
|
|
2078
|
+
reason: `"${specifier}" names "${target}", which moves to "${newPath(target)}", and no specifier the migration can write from "${from}" reaches it there; point it there by hand`,
|
|
2079
|
+
});
|
|
2080
|
+
};
|
|
2081
|
+
/**
|
|
2082
|
+
* How `from` names `target` after the moves, for the alias `specifier` that `file` writes: as written
|
|
2083
|
+
* while it still reaches it, through the same alias when one of its targets covers the new place,
|
|
2084
|
+
* else as a relative path. `undefined` leaves it as written.
|
|
2085
|
+
*/
|
|
2086
|
+
const aliasTo = (file, specifier, target, from = newPath(file)) => {
|
|
2087
|
+
const to = newPath(target);
|
|
2088
|
+
if (to === target && from === file)
|
|
2089
|
+
return undefined;
|
|
2090
|
+
const written = withoutQuery(specifier);
|
|
2091
|
+
if (reaches(from, written, to))
|
|
2092
|
+
return undefined;
|
|
2093
|
+
const respelled = resolver.respell(join(root, file), written, join(root, to));
|
|
2094
|
+
if (respelled !== undefined && reaches(from, respelled, to))
|
|
2095
|
+
return `${respelled}${specifier.slice(written.length)}`;
|
|
2096
|
+
const relativePath = respecify(specifier, target, from, to);
|
|
2097
|
+
if (reaches(from, relativePath, to))
|
|
2098
|
+
return relativePath;
|
|
2099
|
+
unreachable(file, specifier, target, from);
|
|
2100
|
+
return undefined;
|
|
2101
|
+
};
|
|
998
2102
|
for (const [file, text] of next) {
|
|
999
2103
|
const imports = rewriteImports(ts, file, text, (specifier) => {
|
|
1000
2104
|
const retired = RETIRED_SPECIFIERS[specifier];
|
|
1001
2105
|
if (retired !== undefined)
|
|
1002
2106
|
return retired;
|
|
1003
|
-
const target =
|
|
2107
|
+
const target = resolveAny(file, specifier);
|
|
1004
2108
|
// Outside the app (or a skipped dir): the target stays put, so only a moved importer re-bases it.
|
|
1005
2109
|
if (target === undefined)
|
|
1006
2110
|
return rebase(specifier, file, newPath(file));
|
|
2111
|
+
if (!specifier.startsWith("."))
|
|
2112
|
+
return aliasTo(file, specifier, target);
|
|
1007
2113
|
if (newPath(target) === target && newPath(file) === file)
|
|
1008
2114
|
return undefined;
|
|
1009
|
-
|
|
2115
|
+
const relativePath = respecify(specifier, target, newPath(file), newPath(target));
|
|
2116
|
+
if (!reaches(newPath(file), relativePath, newPath(target)))
|
|
2117
|
+
unreachable(file, specifier, target, newPath(file));
|
|
2118
|
+
return relativePath;
|
|
1010
2119
|
});
|
|
1011
2120
|
const updated = renameRetiredTypes(ts, file, imports);
|
|
1012
2121
|
final.set(newPath(file), updated);
|
|
1013
|
-
if (
|
|
2122
|
+
if (!moveMap.has(file) &&
|
|
2123
|
+
(redirectedFiles.has(file) || (updated !== text && original.get(file) === text)))
|
|
1014
2124
|
rewritten.push(file);
|
|
1015
2125
|
}
|
|
2126
|
+
// A config string names its module from the app root, so it follows the module, never the config
|
|
2127
|
+
// file; a path joined onto the config's folder is re-pointed with the module's own paths below.
|
|
2128
|
+
for (const configFile of CONFIG_FILES) {
|
|
2129
|
+
const at = newPath(configFile);
|
|
2130
|
+
const text = final.get(at);
|
|
2131
|
+
if (text === undefined || !next.has(configFile))
|
|
2132
|
+
continue;
|
|
2133
|
+
const edits = [];
|
|
2134
|
+
for (const entry of configEntries(ts, at, text)) {
|
|
2135
|
+
if (entry.span === undefined)
|
|
2136
|
+
continue;
|
|
2137
|
+
const target = resolveAny("nifra.config.ts", entry.path);
|
|
2138
|
+
if (target === undefined || newPath(target) === target)
|
|
2139
|
+
continue;
|
|
2140
|
+
const value = entry.path.startsWith(".")
|
|
2141
|
+
? respecify(entry.path, target, "nifra.config.ts", newPath(target))
|
|
2142
|
+
: aliasTo(configFile, entry.path, target, "nifra.config.ts");
|
|
2143
|
+
if (value !== undefined)
|
|
2144
|
+
edits.push({ start: entry.span[0], end: entry.span[1], text: value });
|
|
2145
|
+
}
|
|
2146
|
+
if (edits.length === 0)
|
|
2147
|
+
continue;
|
|
2148
|
+
let updated = text;
|
|
2149
|
+
for (const edit of edits.sort((a, b) => b.start - a.start))
|
|
2150
|
+
updated = updated.slice(0, edit.start) + edit.text + updated.slice(edit.end);
|
|
2151
|
+
final.set(at, updated);
|
|
2152
|
+
if (!moveMap.has(configFile) && !rewritten.includes(configFile))
|
|
2153
|
+
rewritten.push(configFile);
|
|
2154
|
+
}
|
|
1016
2155
|
const moves = [...moveMap]
|
|
1017
2156
|
.map(([from, to]) => ({ from, to }))
|
|
1018
2157
|
.sort((a, b) => codeUnitOrder(a.from, b.from));
|
|
@@ -1032,16 +2171,40 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1032
2171
|
if (result.unfollowed !== undefined)
|
|
1033
2172
|
unfollowed.set(to, result.unfollowed);
|
|
1034
2173
|
}
|
|
2174
|
+
// A module that stays still names files from its own folder (a config's `clientModule` joined onto
|
|
2175
|
+
// `import.meta.dir`): each that moved is re-pointed.
|
|
2176
|
+
const landed = new Set(moveMap.values());
|
|
2177
|
+
for (const [file, text] of final) {
|
|
2178
|
+
if (landed.has(file) || !/\.[cm]?[jt]sx?$/.test(file) || !SELF_LOCATION.test(text))
|
|
2179
|
+
continue;
|
|
2180
|
+
const result = rebaseOwnPaths(ts, file, file, text, (target) => known.has(target) ? newPath(target) : target);
|
|
2181
|
+
if (result.text === text)
|
|
2182
|
+
continue;
|
|
2183
|
+
final.set(file, result.text);
|
|
2184
|
+
if (original.has(file) && !rewritten.includes(file))
|
|
2185
|
+
rewritten.push(file);
|
|
2186
|
+
}
|
|
1035
2187
|
// A path in a string or comment is not an import, so nothing above rewrote it: a build script that
|
|
1036
2188
|
// writes `data/x.json` would keep writing to the old place, and so would a deploy script or
|
|
1037
2189
|
// `package.json` beside the code.
|
|
1038
2190
|
const settled = new Set([...known].map(newPath));
|
|
2191
|
+
// A new backend half starts where its route was, so it keeps that route's place in each program.
|
|
2192
|
+
const splitFrom = new Map(splits
|
|
2193
|
+
.filter((split) => !original.has(split.backend))
|
|
2194
|
+
.map((split) => [split.backend, split.file]));
|
|
2195
|
+
const layout = [...known]
|
|
2196
|
+
.filter((file) => next.has(file) || !original.has(file))
|
|
2197
|
+
.map((file) => ({ from: splitFrom.get(file) ?? file, to: newPath(file) }));
|
|
2198
|
+
const tsconfigs = followTsconfigs(ts, root, moveMap, layout);
|
|
1039
2199
|
const scanned = [...final];
|
|
1040
2200
|
for (const file of everyFile) {
|
|
1041
2201
|
if (!original.has(file) &&
|
|
1042
2202
|
TOOLING_FILE.test(file) &&
|
|
1043
2203
|
statSync(join(root, file)).size <= 256_000)
|
|
1044
|
-
scanned.push([
|
|
2204
|
+
scanned.push([
|
|
2205
|
+
newPath(file),
|
|
2206
|
+
tsconfigs.files.get(file)?.text ?? readFileSync(join(root, file), "utf8"),
|
|
2207
|
+
]);
|
|
1045
2208
|
}
|
|
1046
2209
|
for (const [file, text] of scanned) {
|
|
1047
2210
|
let live;
|
|
@@ -1063,6 +2226,61 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1063
2226
|
issues.push({ file, reason: `mentions "${from}" by path; it moved to "${to}"` });
|
|
1064
2227
|
}
|
|
1065
2228
|
}
|
|
2229
|
+
// Code beside an app one folder down (a package's test/ when the app is its src/) is not the
|
|
2230
|
+
// migration's to edit, so each import there that stops resolving to what it named is reported.
|
|
2231
|
+
const pkg = enclosingPackage(root);
|
|
2232
|
+
if (pkg !== undefined) {
|
|
2233
|
+
const prefix = toPosix(relative(pkg, root));
|
|
2234
|
+
const inApp = new Set([...known].map((file) => `${prefix}/${file}`));
|
|
2235
|
+
const appPath = (file, specifier) => resolveIn(inApp, file, specifier)?.slice(prefix.length + 1);
|
|
2236
|
+
const outside = ` - it is outside ${prefix}/, so the migration does not rewrite it`;
|
|
2237
|
+
for (const file of listFiles(pkg)) {
|
|
2238
|
+
if (file.startsWith(`${prefix}/`) || !/\.[cm]?[jt]sx?$/.test(file))
|
|
2239
|
+
continue;
|
|
2240
|
+
const text = readFileSync(join(pkg, file), "utf8");
|
|
2241
|
+
const at = toPosix(relative(root, join(pkg, file)));
|
|
2242
|
+
for (const { specifier } of importsOf(ts, file, text)) {
|
|
2243
|
+
const target = appPath(file, specifier);
|
|
2244
|
+
const to = target === undefined ? undefined : moveMap.get(target);
|
|
2245
|
+
if (to !== undefined)
|
|
2246
|
+
issues.push({
|
|
2247
|
+
file: at,
|
|
2248
|
+
reason: `imports "${specifier}", which moves to "${to}"${outside}`,
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
const redirected = redirectMovedImports(ts, file, text, splitTarget(pkg, file, (specifier) => appPath(file, specifier)));
|
|
2252
|
+
for (const { names, from, to } of redirected.redirects) {
|
|
2253
|
+
issues.push({
|
|
2254
|
+
file: at,
|
|
2255
|
+
reason: `imports ${names.map((name) => `"${name}"`).join(", ")} from "${from}"; import it from "${to}"${outside}`,
|
|
2256
|
+
});
|
|
2257
|
+
}
|
|
2258
|
+
for (const reason of redirected.issues)
|
|
2259
|
+
issues.push({ file: at, reason });
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
// A workspace sibling is not the migration's to edit either: each file that names a moved app file,
|
|
2263
|
+
// by import or by path, is reported with the line and both paths.
|
|
2264
|
+
const appName = posix.basename(toPosix(root));
|
|
2265
|
+
const mentionsApp = new RegExp(`(?:^|[/'"\`])${escapeRegExp(appName)}(?:[/'"\`]|$)`, "m");
|
|
2266
|
+
for (const sibling of moveMap.size === 0 ? [] : (workspaceSiblings(root) ?? [])) {
|
|
2267
|
+
for (const file of listFiles(sibling)) {
|
|
2268
|
+
if (!/\.[cm]?[jt]sx?$/.test(file) && !TOOLING_FILE.test(file))
|
|
2269
|
+
continue;
|
|
2270
|
+
if (statSync(join(sibling, file)).size > 256_000)
|
|
2271
|
+
continue;
|
|
2272
|
+
const text = readFileSync(join(sibling, file), "utf8");
|
|
2273
|
+
if (!mentionsApp.test(text) && (ownName === undefined || !text.includes(`${ownName}/`)))
|
|
2274
|
+
continue;
|
|
2275
|
+
const at = toPosix(relative(root, join(sibling, file)));
|
|
2276
|
+
for (const { from, to, line } of siblingReferences(ts, root, sibling, file, text, moveMap, known, ownName)) {
|
|
2277
|
+
issues.push({
|
|
2278
|
+
file: at,
|
|
2279
|
+
reason: `names "${from}" on line ${line}, and it moves to "${to}" - it is outside the app, so the migration does not rewrite it`,
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
1066
2284
|
// Paths a module finds at run time are not imports either: one built from the module's own location
|
|
1067
2285
|
// now starts from its new folder, and a folder listed at run time loses whatever moved out of it.
|
|
1068
2286
|
const leaving = new Map();
|
|
@@ -1075,7 +2293,7 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1075
2293
|
});
|
|
1076
2294
|
}
|
|
1077
2295
|
const dir = posix.dirname(move.from);
|
|
1078
|
-
if (dir === "." || ZONE_DIRS.has(dir.split("/")[0] ?? ""))
|
|
2296
|
+
if (dir === "." || ZONE_DIRS.has(dir.split("/")[0] ?? "") || inConfiguredFolder(move.from))
|
|
1079
2297
|
continue;
|
|
1080
2298
|
leaving.set(dir, [...(leaving.get(dir) ?? []), move]);
|
|
1081
2299
|
}
|
|
@@ -1093,19 +2311,108 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1093
2311
|
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
2312
|
});
|
|
1095
2313
|
}
|
|
1096
|
-
// The server refuses data a loader or action returns without an output schema
|
|
1097
|
-
//
|
|
2314
|
+
// The server refuses data a loader or action returns without an output schema. Each half the split
|
|
2315
|
+
// wrote gets a draft from its return type, read from the layout as planned so the dry run shows
|
|
2316
|
+
// what --write adds; only the author knows which fields the browser may see, so it is a draft.
|
|
2317
|
+
const halves = [...new Set(splits.map((split) => split.backend))].filter((file) => final.has(file));
|
|
2318
|
+
const unwritten = new Map();
|
|
2319
|
+
for (const [file, text] of final)
|
|
2320
|
+
if (original.get(file) !== text)
|
|
2321
|
+
unwritten.set(file, text);
|
|
2322
|
+
const schemaPlan = halves.length === 0
|
|
2323
|
+
? undefined
|
|
2324
|
+
: await planOutputSchemas(root, halves, (file) => final.get(file), {
|
|
2325
|
+
overlay: unwritten,
|
|
2326
|
+
// What NF-C030 would flag: a schema for data the function returns, not for `return null`.
|
|
2327
|
+
draft: (file, schema) => missingOutputSchemas(final.get(file) ?? "").some((found) => found.schema === schema),
|
|
2328
|
+
});
|
|
2329
|
+
for (const [file, text] of schemaPlan?.edits ?? [])
|
|
2330
|
+
final.set(file, text);
|
|
2331
|
+
const schemas = (schemaPlan?.entries ?? []).filter((entry) => entry.status === "missing");
|
|
2332
|
+
const followUps = [];
|
|
1098
2333
|
for (const [file, text] of final) {
|
|
1099
2334
|
if (!file.startsWith("routes/") || !/\.backend\.[cm]?[jt]s$/.test(file))
|
|
1100
2335
|
continue;
|
|
1101
2336
|
for (const { name, schema } of missingOutputSchemas(text)) {
|
|
2337
|
+
const why = schemaPlan?.entries.find((entry) => entry.file === file && entry.schema === schema && entry.reason !== undefined)?.reason;
|
|
2338
|
+
const missing = `exports ${name === "action" ? "an" : "a"} ${name} but no ${schema}, so data it returns fails the request`;
|
|
2339
|
+
// A half this run did not write keeps the schema it had: drafting one is the next command, not a
|
|
2340
|
+
// decision the migration left open.
|
|
2341
|
+
if (why === undefined && (schemaPlan === undefined || schemaPlan.warnings.length === 0)) {
|
|
2342
|
+
followUps.push({
|
|
2343
|
+
file,
|
|
2344
|
+
reason: `${missing}; draft it from the ${name}'s return type`,
|
|
2345
|
+
command: "nifra schemas --write",
|
|
2346
|
+
});
|
|
2347
|
+
continue;
|
|
2348
|
+
}
|
|
1102
2349
|
issues.push({
|
|
1103
2350
|
file,
|
|
1104
|
-
reason:
|
|
2351
|
+
reason: `${missing}. Declare what the browser may see: export const ${schema} = t.object({ ... }); no draft was written: ${why ?? schemaPlan?.warnings.join("; ")}`,
|
|
1105
2352
|
});
|
|
1106
2353
|
}
|
|
1107
2354
|
}
|
|
2355
|
+
// Tooling that lists folders or globs, not files: the import rewrite above never sees it.
|
|
2356
|
+
const tooling = [];
|
|
2357
|
+
const toolingWrites = new Map();
|
|
2358
|
+
for (const [file, { text, changes }] of tsconfigs.files) {
|
|
2359
|
+
toolingWrites.set(file, text);
|
|
2360
|
+
for (const change of changes)
|
|
2361
|
+
tooling.push({ file, change });
|
|
2362
|
+
}
|
|
2363
|
+
issues.push(...tsconfigs.issues);
|
|
2364
|
+
for (const file of everyFile) {
|
|
2365
|
+
if (!TAILWIND_CONFIG.test(file))
|
|
2366
|
+
continue;
|
|
2367
|
+
const reason = staleContentGlobs(ts, readFileSync(join(root, file), "utf8"), moves);
|
|
2368
|
+
if (reason !== undefined)
|
|
2369
|
+
issues.push({ file, reason });
|
|
2370
|
+
}
|
|
2371
|
+
const gitignore = ignoreNifraState(root);
|
|
2372
|
+
if (gitignore !== undefined) {
|
|
2373
|
+
toolingWrites.set(".gitignore", gitignore);
|
|
2374
|
+
tooling.push({ file: ".gitignore", change: "ignore .nifra/ (dev and build state)" });
|
|
2375
|
+
}
|
|
2376
|
+
// A root backend.ts was served at /api by default until 5.0, which mounts it only where apiPrefix says.
|
|
2377
|
+
if (!api && rootMoves["backend.ts"] !== undefined) {
|
|
2378
|
+
const mount = apiPrefixFix({
|
|
2379
|
+
framework: final.get(FRAMEWORK_FILE),
|
|
2380
|
+
config: final.get(CONFIG_FILE),
|
|
2381
|
+
});
|
|
2382
|
+
for (const [file, text] of [
|
|
2383
|
+
[FRAMEWORK_FILE, mount.framework],
|
|
2384
|
+
[CONFIG_FILE, mount.config],
|
|
2385
|
+
]) {
|
|
2386
|
+
if (text === undefined)
|
|
2387
|
+
continue;
|
|
2388
|
+
final.set(file, text);
|
|
2389
|
+
tooling.push({
|
|
2390
|
+
file,
|
|
2391
|
+
change: 'export const apiPrefix = "/api", the mount 4.x made by default',
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
if (declared.size > 0) {
|
|
2396
|
+
const zoned = declareZones(ts, root, declared);
|
|
2397
|
+
if (zoned.issue !== undefined)
|
|
2398
|
+
issues.push({ file: "package.json", reason: zoned.issue });
|
|
2399
|
+
if (zoned.text !== undefined) {
|
|
2400
|
+
toolingWrites.set("package.json", zoned.text);
|
|
2401
|
+
for (const [path, side] of declared)
|
|
2402
|
+
tooling.push({ file: "package.json", change: `"nifra.zones" lists "${path}" as ${side}` });
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
// `nifra check` reads the declaration statically; without it, a later routes/ or config would zone the app.
|
|
2406
|
+
const kind = api && createKindProbe(root)(root).kind === undefined ? declareApiKind(ts, root) : {};
|
|
2407
|
+
if (kind.issue !== undefined)
|
|
2408
|
+
issues.push({ file: "package.json", reason: kind.issue });
|
|
2409
|
+
if (kind.text !== undefined) {
|
|
2410
|
+
toolingWrites.set("package.json", kind.text);
|
|
2411
|
+
tooling.push({ file: "package.json", change: '"nifra": { "kind": "api" }' });
|
|
2412
|
+
}
|
|
1108
2413
|
if (options.write === true) {
|
|
2414
|
+
for (const [file, text] of toolingWrites)
|
|
2415
|
+
writeFileSync(join(root, file), text);
|
|
1109
2416
|
for (const [file, text] of final) {
|
|
1110
2417
|
if (original.get(file) === text)
|
|
1111
2418
|
continue;
|
|
@@ -1113,43 +2420,538 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1113
2420
|
mkdirSync(dirname(path), { recursive: true });
|
|
1114
2421
|
writeFileSync(path, text);
|
|
1115
2422
|
}
|
|
2423
|
+
const vacated = [];
|
|
1116
2424
|
for (const file of original.keys()) {
|
|
1117
|
-
if (
|
|
1118
|
-
|
|
2425
|
+
if (final.has(file))
|
|
2426
|
+
continue;
|
|
2427
|
+
rmSync(join(root, file), { force: true });
|
|
2428
|
+
vacated.push(file);
|
|
1119
2429
|
}
|
|
1120
2430
|
for (const { from, to } of moves) {
|
|
1121
2431
|
if (original.has(from) || !existsSync(join(root, from)))
|
|
1122
2432
|
continue;
|
|
1123
2433
|
mkdirSync(dirname(join(root, to)), { recursive: true });
|
|
1124
2434
|
renameSync(join(root, from), join(root, to));
|
|
2435
|
+
vacated.push(from);
|
|
1125
2436
|
}
|
|
1126
|
-
|
|
2437
|
+
pruneVacatedDirs(root, vacated);
|
|
1127
2438
|
}
|
|
1128
2439
|
return {
|
|
1129
2440
|
ok: issues.length === 0,
|
|
1130
2441
|
write: options.write === true,
|
|
2442
|
+
mode,
|
|
2443
|
+
followUps,
|
|
1131
2444
|
splits: splits.sort((a, b) => codeUnitOrder(a.file, b.file)),
|
|
1132
2445
|
moves,
|
|
1133
2446
|
rewritten: rewritten.sort(),
|
|
2447
|
+
tooling,
|
|
2448
|
+
schemas: schemas.map((entry) => options.write === true && entry.reason === undefined
|
|
2449
|
+
? { ...entry, status: "generated" }
|
|
2450
|
+
: entry),
|
|
1134
2451
|
issues,
|
|
1135
2452
|
};
|
|
1136
2453
|
}
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
2454
|
+
/**
|
|
2455
|
+
* Split one route file in place: its server-only exports, and the code only they use, move into its
|
|
2456
|
+
* `x.backend.ts`, created or merged into. Returns the files written, app-relative; nothing for a file
|
|
2457
|
+
* that exports nothing for the server. Refuses, naming why, a split that would need a decision.
|
|
2458
|
+
*/
|
|
2459
|
+
export async function splitRouteFile(appRoot, file, options = {}) {
|
|
2460
|
+
if (!/\.[jt]sx$/.test(file))
|
|
2461
|
+
return [];
|
|
2462
|
+
const ts = options.typescript ?? (await importTypeScript());
|
|
2463
|
+
if (ts === undefined)
|
|
2464
|
+
throw new Error("[nifra] splitting a route needs TypeScript: bun add -d typescript");
|
|
2465
|
+
const root = resolve(appRoot);
|
|
2466
|
+
const backendFile = file.replace(ROUTE_FILE, ".backend.ts");
|
|
2467
|
+
const sharedFile = file.replace(ROUTE_FILE, ".shared.ts");
|
|
2468
|
+
const result = splitRouteSource(ts, readFileSync(join(root, file), "utf8"), file, `./${posix.basename(backendFile)}`, existsSync(join(root, sharedFile)) ? {} : { sharedFile: posix.basename(sharedFile) });
|
|
2469
|
+
if (result.backend === undefined)
|
|
2470
|
+
return [];
|
|
2471
|
+
if (result.issues.length > 0) {
|
|
2472
|
+
throw new Error(`[nifra] ${file} was not split: ${result.issues.join("; ")}`);
|
|
2473
|
+
}
|
|
2474
|
+
const backendPath = join(root, backendFile);
|
|
2475
|
+
const existing = existsSync(backendPath) ? readFileSync(backendPath, "utf8") : "";
|
|
2476
|
+
writeFileSync(join(root, file), result.frontend);
|
|
2477
|
+
writeFileSync(backendPath, mergeModules(ts, existing, result.backend));
|
|
2478
|
+
if (result.shared === undefined)
|
|
2479
|
+
return [file, backendFile];
|
|
2480
|
+
writeFileSync(join(root, sharedFile), result.shared);
|
|
2481
|
+
return [file, backendFile, sharedFile];
|
|
2482
|
+
}
|
|
2483
|
+
/** The app's `"nifra": { "zones": ... }`, so folders it already zones are left where they are. */
|
|
2484
|
+
function appZones(root) {
|
|
2485
|
+
const manifest = join(root, "package.json");
|
|
2486
|
+
if (!existsSync(manifest))
|
|
2487
|
+
return {};
|
|
2488
|
+
let parsed;
|
|
2489
|
+
try {
|
|
2490
|
+
parsed = JSON.parse(readFileSync(manifest, "utf8"));
|
|
2491
|
+
}
|
|
2492
|
+
catch {
|
|
2493
|
+
return {};
|
|
2494
|
+
}
|
|
2495
|
+
const nifra = typeof parsed === "object" && parsed !== null ? Reflect.get(parsed, "nifra") : undefined;
|
|
2496
|
+
const zones = typeof nifra === "object" && nifra !== null ? Reflect.get(nifra, "zones") : undefined;
|
|
2497
|
+
return zones === undefined ? {} : parseZoneConfig(zones, manifest);
|
|
2498
|
+
}
|
|
2499
|
+
const matchers = new Map();
|
|
2500
|
+
/** Whether a tsconfig `include` or `exclude` entry takes in `file` (app-relative), the way tsc reads it. */
|
|
2501
|
+
function matches(pattern, file) {
|
|
2502
|
+
let match = matchers.get(pattern);
|
|
2503
|
+
if (match === undefined) {
|
|
2504
|
+
let spec = pattern.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
2505
|
+
if (spec === "" || spec === ".")
|
|
2506
|
+
match = () => true;
|
|
2507
|
+
else if (!/[*?]/.test(spec)) {
|
|
2508
|
+
const folder = `${spec}/`;
|
|
2509
|
+
match = (path) => path === spec || path.startsWith(folder);
|
|
2510
|
+
}
|
|
2511
|
+
else {
|
|
2512
|
+
if (spec.endsWith("**"))
|
|
2513
|
+
spec = `${spec}/*`;
|
|
2514
|
+
else if (!/[*?.][^/]*$/.test(spec.slice(spec.lastIndexOf("/") + 1)))
|
|
2515
|
+
spec = `${spec}/**/*`;
|
|
2516
|
+
const glob = new Bun.Glob(spec);
|
|
2517
|
+
match = (path) => glob.match(path);
|
|
2518
|
+
}
|
|
2519
|
+
matchers.set(pattern, match);
|
|
2520
|
+
}
|
|
2521
|
+
return match(file);
|
|
2522
|
+
}
|
|
2523
|
+
const TSCONFIG = /^tsconfig(?:\.[\w-]+)?\.json$/;
|
|
2524
|
+
const TS_FILE = /\.(?:[cm]?ts|tsx)$/;
|
|
2525
|
+
const inProgram = (lists, file) => lists.files.includes(file) ||
|
|
2526
|
+
(lists.include.some((pattern) => matches(pattern, file)) &&
|
|
2527
|
+
!lists.exclude.some((pattern) => matches(pattern, file)));
|
|
2528
|
+
/** `pattern` under the zone folder `to` landed in, when that reaches `to`; else `to` itself. */
|
|
2529
|
+
function twinEntry(pattern, to) {
|
|
2530
|
+
const slash = to.indexOf("/");
|
|
2531
|
+
if (pattern === undefined || slash === -1)
|
|
2532
|
+
return to;
|
|
2533
|
+
const bare = pattern.replace(/^\.\//, "");
|
|
2534
|
+
const twin = `${to.slice(0, slash)}/${bare}`;
|
|
2535
|
+
if (!matches(twin, to))
|
|
2536
|
+
return to;
|
|
2537
|
+
return bare === pattern ? twin : `./${twin}`;
|
|
2538
|
+
}
|
|
2539
|
+
/**
|
|
2540
|
+
* Keep every TypeScript file in, or out of, each app-root `tsconfig*.json` program as it was. An entry
|
|
2541
|
+
* naming a moved file follows it; a folder or glob that took a moved file in gains its twin under the
|
|
2542
|
+
* zone folder (`src` -> `frontend/src`), and one that kept a file out does the same in `exclude`. So a
|
|
2543
|
+
* test run under its own config's types, or a folder left out on purpose, stays out after the move,
|
|
2544
|
+
* and code the config checked stays checked. An `include` or `exclude` a config inherits is not edited.
|
|
2545
|
+
*/
|
|
2546
|
+
function followTsconfigs(ts, root, moves, layout) {
|
|
2547
|
+
const files = new Map();
|
|
2548
|
+
const issues = [];
|
|
2549
|
+
const typescript = layout.filter(({ to }) => TS_FILE.test(to));
|
|
2550
|
+
const names = readdirSync(root)
|
|
2551
|
+
.filter((name) => TSCONFIG.test(name) && isFile(join(root, name)))
|
|
2552
|
+
.sort(codeUnitOrder);
|
|
2553
|
+
// Each config's lists before and after the move, for the configs that extend it.
|
|
2554
|
+
const settled = new Map();
|
|
2555
|
+
const pending = [...names];
|
|
2556
|
+
for (let progress = true; progress && pending.length > 0;) {
|
|
2557
|
+
progress = false;
|
|
2558
|
+
for (const name of [...pending]) {
|
|
2559
|
+
const text = readFileSync(join(root, name), "utf8");
|
|
2560
|
+
const source = ts.parseJsonText(name, text);
|
|
2561
|
+
const top = source.statements[0]?.expression;
|
|
2562
|
+
if (top === undefined || !ts.isObjectLiteralExpression(top)) {
|
|
2563
|
+
pending.splice(pending.indexOf(name), 1);
|
|
2564
|
+
progress = true;
|
|
2565
|
+
if (moves.size > 0)
|
|
2566
|
+
issues.push({
|
|
2567
|
+
file: name,
|
|
2568
|
+
reason: "is not a JSON object; keep the moved files in its include and exclude by hand",
|
|
2569
|
+
});
|
|
1143
2570
|
continue;
|
|
1144
2571
|
}
|
|
1145
|
-
const
|
|
1146
|
-
|
|
1147
|
-
|
|
2572
|
+
const base = jsonProperty(ts, top, "extends")?.initializer;
|
|
2573
|
+
let parent;
|
|
2574
|
+
if (base !== undefined && ts.isStringLiteral(base) && base.text.startsWith(".")) {
|
|
2575
|
+
const path = posix.normalize(base.text);
|
|
2576
|
+
const file = path.endsWith(".json") ? path : `${path}.json`;
|
|
2577
|
+
if (names.includes(file) && file !== name)
|
|
2578
|
+
parent = file;
|
|
2579
|
+
}
|
|
2580
|
+
if (parent !== undefined && pending.includes(parent))
|
|
2581
|
+
continue;
|
|
2582
|
+
pending.splice(pending.indexOf(name), 1);
|
|
2583
|
+
progress = true;
|
|
2584
|
+
const inherited = parent === undefined ? undefined : settled.get(parent);
|
|
2585
|
+
const extendsOther = base !== undefined && inherited === undefined;
|
|
2586
|
+
const edits = [];
|
|
2587
|
+
const own = new Map();
|
|
2588
|
+
let malformed;
|
|
2589
|
+
for (const key of ["include", "exclude", "files"]) {
|
|
2590
|
+
const array = jsonProperty(ts, top, key)?.initializer;
|
|
2591
|
+
if (array === undefined)
|
|
2592
|
+
continue;
|
|
2593
|
+
if (!ts.isArrayLiteralExpression(array)) {
|
|
2594
|
+
malformed = key;
|
|
2595
|
+
continue;
|
|
2596
|
+
}
|
|
2597
|
+
const lists = { before: [], after: [] };
|
|
2598
|
+
for (const element of array.elements) {
|
|
2599
|
+
if (!ts.isStringLiteral(element))
|
|
2600
|
+
continue;
|
|
2601
|
+
const to = moves.get(posix.normalize(element.text));
|
|
2602
|
+
lists.before.push(element.text);
|
|
2603
|
+
if (to === undefined) {
|
|
2604
|
+
lists.after.push(element.text);
|
|
2605
|
+
continue;
|
|
2606
|
+
}
|
|
2607
|
+
const spelled = element.text.startsWith("./") ? `./${to}` : to;
|
|
2608
|
+
lists.after.push(spelled);
|
|
2609
|
+
edits.push({
|
|
2610
|
+
start: element.getStart(source) + 1,
|
|
2611
|
+
end: element.getEnd() - 1,
|
|
2612
|
+
text: spelled,
|
|
2613
|
+
});
|
|
2614
|
+
}
|
|
2615
|
+
own.set(key, lists);
|
|
2616
|
+
}
|
|
2617
|
+
let updated = text;
|
|
2618
|
+
for (const edit of edits.sort((a, b) => b.start - a.start))
|
|
2619
|
+
updated = updated.slice(0, edit.start) + edit.text + updated.slice(edit.end);
|
|
2620
|
+
const changes = [];
|
|
2621
|
+
if (edits.length > 0)
|
|
2622
|
+
changes.push(`${edits.length} file path(s) follow their moved files`);
|
|
2623
|
+
const list = (key, when) => {
|
|
2624
|
+
const mine = own.get(key);
|
|
2625
|
+
if (mine !== undefined)
|
|
2626
|
+
return mine[when];
|
|
2627
|
+
if (inherited !== undefined)
|
|
2628
|
+
return [...inherited[when][key]];
|
|
2629
|
+
if (extendsOther)
|
|
2630
|
+
return undefined;
|
|
2631
|
+
// tsc's default: everything below the config, unless `files` lists the program.
|
|
2632
|
+
return key === "exclude" ? [] : own.has("files") ? [] : ["**/*"];
|
|
2633
|
+
};
|
|
2634
|
+
const filesOf = (when) => (own.get("files")?.[when] ?? []).map((entry) => posix.normalize(entry));
|
|
2635
|
+
const beforeInclude = list("include", "before");
|
|
2636
|
+
const beforeExclude = list("exclude", "before");
|
|
2637
|
+
const afterInclude = list("include", "after");
|
|
2638
|
+
const afterExclude = list("exclude", "after");
|
|
2639
|
+
if (malformed !== undefined ||
|
|
2640
|
+
beforeInclude === undefined ||
|
|
2641
|
+
beforeExclude === undefined ||
|
|
2642
|
+
afterInclude === undefined ||
|
|
2643
|
+
afterExclude === undefined) {
|
|
2644
|
+
if (malformed !== undefined && moves.size > 0)
|
|
2645
|
+
issues.push({
|
|
2646
|
+
file: name,
|
|
2647
|
+
reason: `"${malformed}" is not an array; keep the moved files in its program by hand`,
|
|
2648
|
+
});
|
|
2649
|
+
if (edits.length > 0)
|
|
2650
|
+
files.set(name, { text: updated, changes });
|
|
2651
|
+
continue;
|
|
2652
|
+
}
|
|
2653
|
+
const before = {
|
|
2654
|
+
include: beforeInclude,
|
|
2655
|
+
exclude: beforeExclude,
|
|
2656
|
+
files: filesOf("before"),
|
|
2657
|
+
};
|
|
2658
|
+
const after = { include: afterInclude, exclude: afterExclude, files: filesOf("after") };
|
|
2659
|
+
const added = { include: [], exclude: [] };
|
|
2660
|
+
let stranded;
|
|
2661
|
+
let strandedCount = 0;
|
|
2662
|
+
const quiet = new Map();
|
|
2663
|
+
// The whole zone folder is the entry that keeps covering code added there later, but only when
|
|
2664
|
+
// it takes in no file the config left out.
|
|
2665
|
+
const takesInOnlyChecked = (folder) => {
|
|
2666
|
+
let safe = quiet.get(folder);
|
|
2667
|
+
if (safe === undefined) {
|
|
2668
|
+
const wider = { ...after, include: [...after.include, folder] };
|
|
2669
|
+
safe = typescript.every((file) => !matches(folder, file.to) ||
|
|
2670
|
+
inProgram(before, file.from) ||
|
|
2671
|
+
!inProgram(wider, file.to));
|
|
2672
|
+
quiet.set(folder, safe);
|
|
2673
|
+
}
|
|
2674
|
+
return safe;
|
|
2675
|
+
};
|
|
2676
|
+
for (const { from, to } of typescript) {
|
|
2677
|
+
if (!inProgram(before, from) || inProgram(after, to))
|
|
2678
|
+
continue;
|
|
2679
|
+
if (after.include.some((pattern) => matches(pattern, to))) {
|
|
2680
|
+
// Its new place sits under an `exclude` that did not hold the old one.
|
|
2681
|
+
stranded ??= { from, to, key: "exclude" };
|
|
2682
|
+
strandedCount++;
|
|
2683
|
+
continue;
|
|
2684
|
+
}
|
|
2685
|
+
const zone = to.includes("/") ? to.slice(0, to.indexOf("/")) : undefined;
|
|
2686
|
+
const entry = zone !== undefined && takesInOnlyChecked(zone)
|
|
2687
|
+
? zone
|
|
2688
|
+
: twinEntry(before.include.find((pattern) => matches(pattern, from)), to);
|
|
2689
|
+
added.include.push(entry);
|
|
2690
|
+
after.include.push(entry);
|
|
2691
|
+
}
|
|
2692
|
+
for (const { from, to } of typescript) {
|
|
2693
|
+
if (inProgram(before, from) || !inProgram(after, to))
|
|
2694
|
+
continue;
|
|
2695
|
+
let entry = twinEntry(before.exclude.find((pattern) => matches(pattern, from)), to);
|
|
2696
|
+
// A twin that would also drop a file the config checked is too wide: name the file.
|
|
2697
|
+
if (entry !== to &&
|
|
2698
|
+
typescript.some((other) => matches(entry, other.to) && inProgram(before, other.from)))
|
|
2699
|
+
entry = to;
|
|
2700
|
+
added.exclude.push(entry);
|
|
2701
|
+
after.exclude.push(entry);
|
|
2702
|
+
}
|
|
2703
|
+
for (const key of ["include", "exclude"]) {
|
|
2704
|
+
const entries = [...new Set(added[key])].sort(codeUnitOrder);
|
|
2705
|
+
if (entries.length === 0)
|
|
2706
|
+
continue;
|
|
2707
|
+
const reparsed = ts.parseJsonText(name, updated);
|
|
2708
|
+
const object = reparsed.statements[0]?.expression;
|
|
2709
|
+
const array = object !== undefined && ts.isObjectLiteralExpression(object)
|
|
2710
|
+
? jsonProperty(ts, object, key)?.initializer
|
|
2711
|
+
: undefined;
|
|
2712
|
+
if (array === undefined || !ts.isArrayLiteralExpression(array)) {
|
|
2713
|
+
stranded ??= { from: "", to: entries[0] ?? "", key };
|
|
2714
|
+
strandedCount += entries.length;
|
|
2715
|
+
continue;
|
|
2716
|
+
}
|
|
2717
|
+
updated = appendToArray(reparsed, updated, array, entries);
|
|
2718
|
+
changes.push(`${key} += ${entries.join(", ")}`);
|
|
2719
|
+
}
|
|
2720
|
+
if (stranded !== undefined)
|
|
2721
|
+
issues.push({
|
|
2722
|
+
file: name,
|
|
2723
|
+
reason: `cannot keep ${strandedCount} moved file(s) in or out of its program as before (first: "${stranded.to}"); its "${stranded.key}" is inherited or would hide them, so set it by hand`,
|
|
2724
|
+
});
|
|
2725
|
+
settled.set(name, { before, after });
|
|
2726
|
+
if (changes.length > 0)
|
|
2727
|
+
files.set(name, { text: updated, changes });
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
return { files, issues };
|
|
2731
|
+
}
|
|
2732
|
+
/** `text` with `entries` appended to `array`: one per line in a multi-line array, else inline. */
|
|
2733
|
+
function appendToArray(source, text, array, entries) {
|
|
2734
|
+
const quoted = entries.map((entry) => JSON.stringify(entry));
|
|
2735
|
+
const last = array.elements[array.elements.length - 1];
|
|
2736
|
+
if (last === undefined) {
|
|
2737
|
+
const at = array.getStart(source) + 1;
|
|
2738
|
+
return `${text.slice(0, at)}${quoted.join(", ")}${text.slice(at)}`;
|
|
2739
|
+
}
|
|
2740
|
+
let at = last.end;
|
|
2741
|
+
const trailing = /^\s*,/.exec(text.slice(at));
|
|
2742
|
+
if (trailing !== null)
|
|
2743
|
+
at += trailing[0].length;
|
|
2744
|
+
// A multi-line array gets one entry per line at the last element's indent, below any comment that
|
|
2745
|
+
// closes the last element's line; anything else gets them inline.
|
|
2746
|
+
if (text.slice(array.getStart(source), last.getStart(source)).includes("\n")) {
|
|
2747
|
+
const lineStart = text.lastIndexOf("\n", last.getStart(source)) + 1;
|
|
2748
|
+
const indent = text.slice(lineStart, last.getStart(source)).match(/^\s*/)?.[0] ?? "";
|
|
2749
|
+
const newline = text.indexOf("\n", at);
|
|
2750
|
+
const eol = newline === -1 ? text.length : newline;
|
|
2751
|
+
if (/^\s*(?:\/\/.*|\/\*.*\*\/\s*)?$/.test(text.slice(at, eol))) {
|
|
2752
|
+
const head = trailing !== null
|
|
2753
|
+
? text.slice(0, eol)
|
|
2754
|
+
: `${text.slice(0, last.end)},${text.slice(last.end, eol)}`;
|
|
2755
|
+
const lines = quoted.join(`,\n${indent}`);
|
|
2756
|
+
return `${head}\n${indent}${lines}${trailing !== null ? "," : ""}${text.slice(eol)}`;
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
const insert = trailing !== null ? ` ${quoted.join(", ")},` : `, ${quoted.join(", ")}`;
|
|
2760
|
+
return `${text.slice(0, at)}${insert}${text.slice(at)}`;
|
|
2761
|
+
}
|
|
2762
|
+
const jsonProperty = (ts, object, key) => object.properties.find((property) => ts.isPropertyAssignment(property) &&
|
|
2763
|
+
ts.isStringLiteral(property.name) &&
|
|
2764
|
+
property.name.text === key);
|
|
2765
|
+
/** `text` with `entry` as the last property of `object`, on its own line when the object spans lines. */
|
|
2766
|
+
function insertProperty(source, text, object, entry) {
|
|
2767
|
+
const open = object.getStart(source);
|
|
2768
|
+
const last = object.properties[object.properties.length - 1];
|
|
2769
|
+
if (last === undefined)
|
|
2770
|
+
return `${text.slice(0, open)}{ ${entry} }${text.slice(object.end)}`;
|
|
2771
|
+
const start = last.getStart(source);
|
|
2772
|
+
if (!text.slice(open, start).includes("\n")) {
|
|
2773
|
+
return `${text.slice(0, last.end)}, ${entry}${text.slice(last.end)}`;
|
|
2774
|
+
}
|
|
2775
|
+
const indent = /^[ \t]*/.exec(text.slice(text.lastIndexOf("\n", start) + 1, start))?.[0] ?? "";
|
|
2776
|
+
return `${text.slice(0, last.end)},\n${indent}${entry}${text.slice(last.end)}`;
|
|
2777
|
+
}
|
|
2778
|
+
/**
|
|
2779
|
+
* The `nifra.zones` entries that keep `inPlace` files where they are: for each, the widest folder
|
|
2780
|
+
* above it whose zoned files all take its side (a nested package stops the climb), else the file
|
|
2781
|
+
* itself. A file another side's suffix or a server built-in claims counts against a folder.
|
|
2782
|
+
*/
|
|
2783
|
+
function zoneDeclarations(inPlace, files, text, configured) {
|
|
2784
|
+
const sides = new Map();
|
|
2785
|
+
const packages = new Set();
|
|
2786
|
+
for (const file of files) {
|
|
2787
|
+
const top = file.split("/")[0] ?? "";
|
|
2788
|
+
if (!file.includes("/") || ZONE_DIRS.has(top))
|
|
2789
|
+
continue;
|
|
2790
|
+
if (posix.basename(file) === "package.json") {
|
|
2791
|
+
for (let dir = posix.dirname(file); dir !== "."; dir = posix.dirname(dir))
|
|
2792
|
+
packages.add(dir);
|
|
2793
|
+
continue;
|
|
1148
2794
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
2795
|
+
// A configured path is more specific than any folder above it, so it never counts against one.
|
|
2796
|
+
if (isTestFile(file) || file.endsWith(".d.ts") || configured(file))
|
|
2797
|
+
continue;
|
|
2798
|
+
const suffix = /\.(frontend|backend|shared|fn)\.[cm]?[jt]sx?$/.exec(file)?.[1];
|
|
2799
|
+
const side = inPlace.get(file) ??
|
|
2800
|
+
(suffix === "fn" ? "backend" : suffix) ??
|
|
2801
|
+
(SOURCE_FILE.test(file) && /from\s+["'](?:node:|bun:)/.test(text(file) ?? "")
|
|
2802
|
+
? "backend"
|
|
2803
|
+
: undefined);
|
|
2804
|
+
if (side === undefined)
|
|
2805
|
+
continue;
|
|
2806
|
+
for (let dir = posix.dirname(file); dir !== "."; dir = posix.dirname(dir))
|
|
2807
|
+
sides.set(dir, (sides.get(dir) ?? new Set()).add(side));
|
|
2808
|
+
}
|
|
2809
|
+
const declared = new Map();
|
|
2810
|
+
for (const [file, side] of [...inPlace].sort(([a], [b]) => codeUnitOrder(a, b))) {
|
|
2811
|
+
const parts = file.split("/");
|
|
2812
|
+
let path = file;
|
|
2813
|
+
for (let depth = 1; depth < parts.length; depth++) {
|
|
2814
|
+
const dir = parts.slice(0, depth).join("/");
|
|
2815
|
+
const found = sides.get(dir);
|
|
2816
|
+
if (!packages.has(dir) && found?.size === 1 && found.has(side)) {
|
|
2817
|
+
path = dir;
|
|
2818
|
+
break;
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
declared.set(path, side);
|
|
2822
|
+
}
|
|
2823
|
+
return declared;
|
|
2824
|
+
}
|
|
2825
|
+
/** The app's package.json with `paths` added to `"nifra": { "zones": ... }`, editing only where they go. */
|
|
2826
|
+
function declareZones(ts, root, paths) {
|
|
2827
|
+
const listed = (side) => [...paths].flatMap(([path, of]) => (of === side ? [path] : []));
|
|
2828
|
+
const setting = `{ ${FOLDER_SIDES.filter((side) => listed(side).length > 0)
|
|
2829
|
+
.map((side) => `"${side}": ${JSON.stringify(listed(side))}`)
|
|
2830
|
+
.join(", ")} }`;
|
|
2831
|
+
const path = join(root, "package.json");
|
|
2832
|
+
if (!existsSync(path))
|
|
2833
|
+
return { issue: `there is none in the app; declare "nifra": { "zones": ${setting} } in it` };
|
|
2834
|
+
let text = readFileSync(path, "utf8");
|
|
2835
|
+
// Parsed again for each edit: an edit moves every offset after it.
|
|
2836
|
+
const find = (keys) => {
|
|
2837
|
+
const source = ts.parseJsonText(path, text);
|
|
2838
|
+
let node = source.statements[0]?.expression;
|
|
2839
|
+
for (const key of keys)
|
|
2840
|
+
node =
|
|
2841
|
+
node !== undefined && ts.isObjectLiteralExpression(node)
|
|
2842
|
+
? jsonProperty(ts, node, key)?.initializer
|
|
2843
|
+
: undefined;
|
|
2844
|
+
return { source, node };
|
|
2845
|
+
};
|
|
2846
|
+
const top = find([]);
|
|
2847
|
+
if (top.node === undefined || !ts.isObjectLiteralExpression(top.node))
|
|
2848
|
+
return { issue: `is not a JSON object; add "nifra": { "zones": ${setting} } to it by hand` };
|
|
2849
|
+
const nifra = find(["nifra"]);
|
|
2850
|
+
if (nifra.node === undefined)
|
|
2851
|
+
return { text: insertProperty(top.source, text, top.node, `"nifra": { "zones": ${setting} }`) };
|
|
2852
|
+
if (!ts.isObjectLiteralExpression(nifra.node))
|
|
2853
|
+
return { issue: `"nifra" is not an object; add "zones": ${setting} to it by hand` };
|
|
2854
|
+
const zones = find(["nifra", "zones"]);
|
|
2855
|
+
if (zones.node === undefined)
|
|
2856
|
+
return { text: insertProperty(nifra.source, text, nifra.node, `"zones": ${setting}`) };
|
|
2857
|
+
for (const side of FOLDER_SIDES) {
|
|
2858
|
+
const add = listed(side);
|
|
2859
|
+
if (add.length === 0)
|
|
2860
|
+
continue;
|
|
2861
|
+
const { source, node } = find(["nifra", "zones"]);
|
|
2862
|
+
if (node === undefined || !ts.isObjectLiteralExpression(node))
|
|
2863
|
+
return { issue: `"nifra.zones" is not an object; add ${setting} to it by hand` };
|
|
2864
|
+
const array = jsonProperty(ts, node, side)?.initializer;
|
|
2865
|
+
if (array === undefined)
|
|
2866
|
+
text = insertProperty(source, text, node, `"${side}": ${JSON.stringify(add)}`);
|
|
2867
|
+
else if (ts.isArrayLiteralExpression(array))
|
|
2868
|
+
text = appendToArray(source, text, array, add);
|
|
2869
|
+
else
|
|
2870
|
+
return {
|
|
2871
|
+
issue: `"nifra.zones.${side}" is not an array; add ${JSON.stringify(add)} to it by hand`,
|
|
2872
|
+
};
|
|
2873
|
+
}
|
|
2874
|
+
return { text };
|
|
2875
|
+
}
|
|
2876
|
+
/** The app's package.json with `"nifra": { "kind": "api" }` added, editing only where it goes. */
|
|
2877
|
+
function declareApiKind(ts, root) {
|
|
2878
|
+
const path = join(root, "package.json");
|
|
2879
|
+
if (!existsSync(path)) {
|
|
2880
|
+
return {
|
|
2881
|
+
issue: 'there is none in the app; declare "nifra": { "kind": "api" } in the package.json that governs it',
|
|
2882
|
+
};
|
|
2883
|
+
}
|
|
2884
|
+
const text = readFileSync(path, "utf8");
|
|
2885
|
+
const source = ts.parseJsonText(path, text);
|
|
2886
|
+
const top = source.statements[0]?.expression;
|
|
2887
|
+
if (top === undefined || !ts.isObjectLiteralExpression(top))
|
|
2888
|
+
return { issue: 'is not a JSON object; add "nifra": { "kind": "api" } to it by hand' };
|
|
2889
|
+
const nifra = jsonProperty(ts, top, "nifra");
|
|
2890
|
+
if (nifra === undefined) {
|
|
2891
|
+
return { text: insertProperty(source, text, top, '"nifra": { "kind": "api" }') };
|
|
2892
|
+
}
|
|
2893
|
+
if (!ts.isObjectLiteralExpression(nifra.initializer))
|
|
2894
|
+
return { issue: '"nifra" is not an object; add "kind": "api" to it by hand' };
|
|
2895
|
+
return { text: insertProperty(source, text, nifra.initializer, '"kind": "api"') };
|
|
2896
|
+
}
|
|
2897
|
+
/** Tailwind `content` globs that matched a moved file at its old path and match nothing at its new one. */
|
|
2898
|
+
function staleContentGlobs(ts, text, moves) {
|
|
2899
|
+
const globs = [];
|
|
2900
|
+
const source = ts.createSourceFile("tailwind.config.ts", text, ts.ScriptTarget.Latest, true);
|
|
2901
|
+
const visit = (node) => {
|
|
2902
|
+
if (ts.isStringLiteralLike(node) && /[*/]/.test(node.text) && !node.text.startsWith("!"))
|
|
2903
|
+
globs.push(node.text.replace(/^\.\//, ""));
|
|
2904
|
+
ts.forEachChild(node, visit);
|
|
1152
2905
|
};
|
|
1153
|
-
|
|
2906
|
+
visit(source);
|
|
2907
|
+
const matchers = globs.map((glob) => ({ glob, matcher: new Bun.Glob(glob) }));
|
|
2908
|
+
const lost = moves.filter(({ from, to }) => matchers.some(({ matcher }) => matcher.match(from)) &&
|
|
2909
|
+
!matchers.some(({ matcher }) => matcher.match(to)));
|
|
2910
|
+
const first = lost[0];
|
|
2911
|
+
if (first === undefined)
|
|
2912
|
+
return undefined;
|
|
2913
|
+
const folders = [...new Set(lost.map(({ to }) => to.split("/")[0] ?? to))].sort(codeUnitOrder);
|
|
2914
|
+
return `its content globs matched ${lost.length} file(s) that move, but none of their new paths (such as "${first.from}" -> "${first.to}"), so Tailwind would stop seeing their classes. Add globs for ${folders.map((folder) => `"./${folder}/**/*"`).join(", ")} (with the extensions you use)`;
|
|
2915
|
+
}
|
|
2916
|
+
/**
|
|
2917
|
+
* `.nifra/` holds generated types, dev state and the dev server's launch token; none of it belongs in
|
|
2918
|
+
* git. The new `.gitignore` text when it is not ignored yet, else `undefined`.
|
|
2919
|
+
*/
|
|
2920
|
+
function ignoreNifraState(root) {
|
|
2921
|
+
let exitCode = null;
|
|
2922
|
+
try {
|
|
2923
|
+
exitCode = Bun.spawnSync(["git", "check-ignore", "-q", ".nifra/types"], {
|
|
2924
|
+
cwd: root,
|
|
2925
|
+
stdout: "ignore",
|
|
2926
|
+
stderr: "ignore",
|
|
2927
|
+
}).exitCode;
|
|
2928
|
+
}
|
|
2929
|
+
catch {
|
|
2930
|
+
// No git installed: the same as no checkout.
|
|
2931
|
+
}
|
|
2932
|
+
if (exitCode === 0)
|
|
2933
|
+
return undefined;
|
|
2934
|
+
const path = join(root, ".gitignore");
|
|
2935
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
2936
|
+
// Outside a git checkout `check-ignore` cannot answer; the app's own file decides.
|
|
2937
|
+
if (exitCode !== 1 && /^\/?\.nifra(?:\/|\*|\/\*\*)?\s*$/m.test(current))
|
|
2938
|
+
return undefined;
|
|
2939
|
+
const separator = current === "" || current.endsWith("\n") ? "" : "\n";
|
|
2940
|
+
return `${current}${separator}.nifra/\n`;
|
|
2941
|
+
}
|
|
2942
|
+
/** Remove each directory the run emptied, then each parent that leaves empty. A directory that was
|
|
2943
|
+
* empty before the run is the app's own, not the migration's to remove. */
|
|
2944
|
+
function pruneVacatedDirs(root, vacated) {
|
|
2945
|
+
const dirs = new Set();
|
|
2946
|
+
for (const file of vacated)
|
|
2947
|
+
dirs.add(dirname(join(root, file)));
|
|
2948
|
+
// Deepest first, so a parent is judged after its emptied children are gone.
|
|
2949
|
+
for (const start of [...dirs].sort((a, b) => b.length - a.length)) {
|
|
2950
|
+
for (let dir = start; dir !== root && dir.startsWith(`${root}${sep}`); dir = dirname(dir)) {
|
|
2951
|
+
if (!existsSync(dir) || readdirSync(dir).length > 0)
|
|
2952
|
+
break;
|
|
2953
|
+
rmdirSync(dir);
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
1154
2956
|
}
|
|
1155
2957
|
//# sourceMappingURL=migrate-layout.js.map
|