@nifrajs/cli 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- 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 +7 -2
- package/dist/assure-hydration.js.map +1 -1
- package/dist/capabilities-tool.d.ts.map +1 -1
- package/dist/capabilities-tool.js +13 -3
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +20 -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 +75 -22
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +1475 -310
- 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 +192 -65
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +112 -5
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +18 -0
- package/dist/client-module.d.ts.map +1 -0
- package/dist/client-module.js +49 -0
- package/dist/client-module.js.map +1 -0
- package/dist/command-catalog.d.ts +21 -5
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +138 -38
- 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/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.map +1 -1
- package/dist/fix-recipes.js +28 -3
- 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 +2 -2
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +17 -11
- package/dist/init-agents.js.map +1 -1
- 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 +16 -0
- package/dist/internal/project-specifiers.d.ts.map +1 -0
- package/dist/internal/project-specifiers.js +396 -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 +2 -0
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +11 -0
- 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 +5 -3
- package/dist/internal/typescript-import.js.map +1 -1
- package/dist/introspect.d.ts +6 -6
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +55 -24
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +93 -15
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +31 -8
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +37 -7
- 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 +2 -2
- package/dist/mcp-context.js.map +1 -1
- package/dist/mcp-exec.d.ts +12 -4
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +50 -13
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +17 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +40 -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-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 +8 -2
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +7 -0
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +958 -71
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +94 -0
- package/dist/output-schemas.d.ts.map +1 -0
- package/dist/output-schemas.js +864 -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/recipes/4.0.0.d.ts.map +1 -1
- package/dist/recipes/4.0.0.js +2 -0
- package/dist/recipes/4.0.0.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/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 +38 -7
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +10 -0
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +10 -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 +7 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +319 -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 +158 -43
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +24 -0
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +221 -3
- 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 +12 -1
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +281 -68
- 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 +192 -40
- 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 +163 -7
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/sync-manifest.d.ts +10 -2
- package/dist/sync-manifest.d.ts.map +1 -1
- package/dist/sync-manifest.js +42 -18
- package/dist/sync-manifest.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +12 -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 +69 -0
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +420 -77
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +11 -0
- 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 +42 -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 +54 -6
- package/docs/llms-full.txt +1539 -138
- package/docs/types.json +1456 -206
- package/package.json +11 -12
- package/src/agent-files.ts +203 -0
- package/src/app-files.ts +34 -4
- package/src/assure-hydration.ts +15 -2
- package/src/capabilities-tool.ts +15 -3
- package/src/check-diagnostics.ts +73 -9
- package/src/check-scan.ts +1773 -381
- package/src/check.ts +237 -76
- package/src/cli.ts +141 -5
- package/src/client-module.ts +61 -0
- package/src/command-catalog.ts +181 -47
- 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/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 +34 -3
- package/src/hand-built-manifest.ts +144 -0
- package/src/hooks-report.ts +135 -0
- package/src/init-agents.ts +17 -11
- package/src/internal/nifra-project.ts +191 -0
- package/src/internal/project-specifiers.ts +424 -0
- package/src/internal/script-kind.ts +2 -1
- package/src/internal/source-facts.ts +25 -0
- package/src/internal/typescript-import.ts +10 -3
- package/src/introspect.ts +65 -27
- package/src/levels-tool.ts +117 -20
- package/src/load.ts +78 -14
- package/src/mcp-context.ts +7 -7
- package/src/mcp-exec.ts +60 -17
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +41 -0
- package/src/mcp-isolate.ts +9 -2
- 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 +8 -3
- package/src/migrate-layout.ts +1109 -70
- package/src/output-schemas.ts +1084 -0
- package/src/port.ts +3 -2
- package/src/project-facts.ts +17 -3
- package/src/recipes/4.0.0.ts +2 -0
- package/src/recipes/5.0.0.ts +35 -0
- package/src/recipes/index.ts +10 -0
- package/src/replay.ts +14 -3
- package/src/review.ts +39 -10
- package/src/rules/codes.ts +10 -0
- package/src/rules/comment-markers.ts +57 -21
- package/src/rules/contract-visibility.ts +383 -0
- package/src/rules/data-guard.ts +351 -42
- package/src/rules/layout.ts +22 -0
- package/src/rules/legacy.ts +184 -51
- package/src/rules/pages.ts +238 -3
- package/src/rules/routes.ts +124 -16
- package/src/rules/secrets.ts +2 -2
- package/src/rules/security.ts +352 -71
- package/src/rules/zones.ts +205 -38
- package/src/scaffold.ts +6 -4
- package/src/sdk.ts +14 -5
- package/src/stylex-migrate.ts +134 -7
- package/src/sync-manifest.ts +48 -18
- package/src/test-tool.ts +18 -3
- package/src/typecheck-coverage.ts +134 -0
- package/src/upgrade.ts +487 -71
- package/src/verification-plan.ts +24 -0
- package/src/version-drift.ts +67 -0
- package/src/web-app-options.ts +2 -2
- package/src/work-graph.ts +17 -12
package/dist/migrate-layout.js
CHANGED
|
@@ -8,19 +8,30 @@
|
|
|
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, the config's `clientModule` and `componentPipeline.entries` paths,
|
|
16
|
+
* `@nifrajs/web/server-only` to `@nifrajs/web/backend-only`, and `ServerOnly` to `BackendOnly`;
|
|
17
|
+
* 6. adds the zone folders moved TypeScript lands in to tsconfig `include`, and `.nifra/` to
|
|
18
|
+
* `.gitignore`.
|
|
19
|
+
*
|
|
20
|
+
* Data files (JSON, YAML, CSV, ...) and folders the app zones in package.json stay where they are.
|
|
15
21
|
*
|
|
16
22
|
* 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.
|
|
23
|
+
* a moved name at runtime, a helper both halves need, a loader with no output schema, an alias no
|
|
24
|
+
* target covers after a move, a specifier that names nothing) instead of guessing.
|
|
19
25
|
*/
|
|
20
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
21
|
-
import {
|
|
26
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
27
|
+
import { builtinModules } from "node:module";
|
|
28
|
+
import { dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
22
29
|
import { BACKEND_ROUTE_EXPORTS } from "@nifrajs/web/route-manifest";
|
|
30
|
+
import { parseZoneConfig } from "@nifrajs/web/zones";
|
|
31
|
+
import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE, retiredRootFiles } from "./app-files.js";
|
|
23
32
|
import { codeUnitOrder } from "./internal/code-unit-order.js";
|
|
33
|
+
import { createKindProbe, hasWebConfig } from "./internal/nifra-project.js";
|
|
34
|
+
import { createSpecifierResolver } from "./internal/project-specifiers.js";
|
|
24
35
|
import { importTypeScript } from "./internal/typescript-import.js";
|
|
25
36
|
import { missingOutputSchemas } from "./rules/data-guard.js";
|
|
26
37
|
/** Exports only the server or the build reads: `@nifrajs/web`'s route-pair contract. */
|
|
@@ -44,8 +55,27 @@ const LEGACY_SERVER = /\.server(\.(?:[cm]?[jt]sx?|svelte|vue|mdx))$/;
|
|
|
44
55
|
const RETIRED_SPECIFIERS = {
|
|
45
56
|
"@nifrajs/web/server-only": "@nifrajs/web/backend-only",
|
|
46
57
|
};
|
|
58
|
+
/** Scripts and config that name app files by path: scanned for paths that moved, never rewritten. */
|
|
59
|
+
const TOOLING_FILE = /(?:\.(?:json|jsonc|ya?ml|toml|sh|bash|zsh|ps1)|(?:^|\/)(?:Dockerfile|Makefile|Procfile))$/;
|
|
60
|
+
const SELF_LOCATION = /\bimport\.meta\.(?:dirname|dir|filename|url)\b|\b__(?:dirname|filename)\b/;
|
|
47
61
|
const SKIP_DIRS = new Set(["node_modules", "dist", "build", "coverage", ".git", ".vite", "public"]);
|
|
48
62
|
const ZONE_DIRS = new Set(["frontend", "backend", "shared", "routes", "public"]);
|
|
63
|
+
/** Data other tools may read by path too; reported, never moved. Images and fonts still move. */
|
|
64
|
+
const DATA_FILE = /\.(?:json|jsonc|json5|ndjson|ya?ml|toml|csv|tsv|txt|xml|sql)$/;
|
|
65
|
+
/** Test-runner calls that name a module by path, as an import does. */
|
|
66
|
+
const MOCK_CALLS = new Set([
|
|
67
|
+
"mock.module",
|
|
68
|
+
"vi.mock",
|
|
69
|
+
"vi.doMock",
|
|
70
|
+
"vi.importActual",
|
|
71
|
+
"vi.importMock",
|
|
72
|
+
"jest.mock",
|
|
73
|
+
"jest.doMock",
|
|
74
|
+
"jest.requireActual",
|
|
75
|
+
"jest.requireMock",
|
|
76
|
+
"jest.unstable_mockModule",
|
|
77
|
+
]);
|
|
78
|
+
const TAILWIND_CONFIG = /^tailwind\.config\.[cm]?[jt]s$/;
|
|
49
79
|
const toPosix = (path) => path.replaceAll("\\", "/");
|
|
50
80
|
/** Every first-party file under `root`, app-relative and posix. */
|
|
51
81
|
function listFiles(root) {
|
|
@@ -73,15 +103,34 @@ function bindingNames(ts, name, into) {
|
|
|
73
103
|
bindingNames(ts, element.name, into);
|
|
74
104
|
}
|
|
75
105
|
const hasModifier = (ts, node, kind) => (ts.canHaveModifiers(node) ? (ts.getModifiers(node) ?? []) : []).some((m) => m.kind === kind);
|
|
76
|
-
/**
|
|
77
|
-
*
|
|
78
|
-
|
|
106
|
+
/**
|
|
107
|
+
* The bindings classic JSX compiles to calls of, which no identifier in the source names: the roots
|
|
108
|
+
* of the `@jsx`/`@jsxFrag` pragmas, else TypeScript's default `React`. A file that names its own
|
|
109
|
+
* `@jsxImportSource` imports the runtime itself.
|
|
110
|
+
*/
|
|
111
|
+
function jsxBindings(ts, source) {
|
|
112
|
+
const comments = (ts.getLeadingCommentRanges(source, 0) ?? [])
|
|
113
|
+
.map(({ pos, end }) => source.slice(pos, end))
|
|
114
|
+
.join("\n");
|
|
115
|
+
if (/@jsxImportSource\s/.test(comments))
|
|
116
|
+
return { element: [], fragment: [] };
|
|
117
|
+
const root = (pragma) => new RegExp(`@${pragma}\\s+([A-Za-z_$][\\w$]*)`).exec(comments)?.[1];
|
|
118
|
+
const element = root("jsx") ?? "React";
|
|
119
|
+
return { element: [element], fragment: [root("jsxFrag") ?? element] };
|
|
120
|
+
}
|
|
121
|
+
function references(ts, node, jsx) {
|
|
79
122
|
const value = new Set();
|
|
80
123
|
const type = new Set();
|
|
81
124
|
const visit = (current, inType) => {
|
|
125
|
+
if (ts.isJsxOpeningElement(current) || ts.isJsxSelfClosingElement(current))
|
|
126
|
+
for (const name of jsx.element)
|
|
127
|
+
value.add(name);
|
|
128
|
+
else if (ts.isJsxFragment(current))
|
|
129
|
+
for (const name of jsx.fragment)
|
|
130
|
+
value.add(name);
|
|
82
131
|
const typed = inType ||
|
|
83
132
|
ts.isTypeNode(current) ||
|
|
84
|
-
//
|
|
133
|
+
// nifra-expect NF-S002: `token` is the heritage clause's syntax kind, not a credential.
|
|
85
134
|
(ts.isHeritageClause(current) && current.token === ts.SyntaxKind.ImplementsKeyword);
|
|
86
135
|
if (ts.isIdentifier(current)) {
|
|
87
136
|
const parent = current.parent;
|
|
@@ -111,6 +160,7 @@ function references(ts, node) {
|
|
|
111
160
|
return { value, type };
|
|
112
161
|
}
|
|
113
162
|
function describeStatements(ts, sf) {
|
|
163
|
+
const jsx = jsxBindings(ts, sf.text);
|
|
114
164
|
return sf.statements.map((node) => {
|
|
115
165
|
const declares = new Set();
|
|
116
166
|
const exports = new Map();
|
|
@@ -164,7 +214,7 @@ function describeStatements(ts, sf) {
|
|
|
164
214
|
}
|
|
165
215
|
const refs = ts.isImportDeclaration(node)
|
|
166
216
|
? { value: new Set(), type: new Set() }
|
|
167
|
-
: references(ts, node);
|
|
217
|
+
: references(ts, node, jsx);
|
|
168
218
|
for (const name of declares) {
|
|
169
219
|
refs.value.delete(name);
|
|
170
220
|
refs.type.delete(name);
|
|
@@ -201,8 +251,10 @@ function filterImport(ts, sf, node, keep) {
|
|
|
201
251
|
return undefined;
|
|
202
252
|
const typeOnly = clause.isTypeOnly ? "type " : "";
|
|
203
253
|
const leading = node.getFullText(sf).slice(0, node.getStart(sf) - node.getFullStart());
|
|
204
|
-
return `${leading}import ${typeOnly}${parts.join(", ")} from ${spec}${attributes}`;
|
|
254
|
+
return `${leading}import ${typeOnly}${parts.join(", ")} from ${spec}${attributes}${terminatorOf(sf, node)}`;
|
|
205
255
|
}
|
|
256
|
+
/** The `;` a statement was written with, so a rebuilt one keeps the file's style. */
|
|
257
|
+
const terminatorOf = (sf, node) => node.getText(sf).endsWith(";") ? ";" : "";
|
|
206
258
|
/** Rebuild `export { a, b as c }` keeping only the exported names `keep` accepts. */
|
|
207
259
|
function filterExportList(ts, sf, node, keep) {
|
|
208
260
|
const clause = node.exportClause;
|
|
@@ -213,7 +265,7 @@ function filterExportList(ts, sf, node, keep) {
|
|
|
213
265
|
return undefined;
|
|
214
266
|
const from = node.moduleSpecifier ? ` from ${node.moduleSpecifier.getText(sf)}` : "";
|
|
215
267
|
const leading = node.getFullText(sf).slice(0, node.getStart(sf) - node.getFullStart());
|
|
216
|
-
return `${leading}export ${node.isTypeOnly ? "type " : ""}{ ${kept.join(", ")} }${from}`;
|
|
268
|
+
return `${leading}export ${node.isTypeOnly ? "type " : ""}{ ${kept.join(", ")} }${from}${terminatorOf(sf, node)}`;
|
|
217
269
|
}
|
|
218
270
|
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
219
271
|
const joinStatements = (texts) => {
|
|
@@ -368,7 +420,8 @@ export function splitRouteSource(ts, source, fileName, backendSpecifier) {
|
|
|
368
420
|
}
|
|
369
421
|
// After the other imports: the nearest relative path sorts last.
|
|
370
422
|
if (typeImports.size > 0) {
|
|
371
|
-
|
|
423
|
+
const semi = statements.some((info) => terminatorOf(sf, info.node) !== "") ? ";" : "";
|
|
424
|
+
frontendTexts.splice(afterImports, 0, `\nimport type { ${[...typeImports].sort().join(", ")} } from "${backendSpecifier}"${semi}`);
|
|
372
425
|
}
|
|
373
426
|
// A JSX pragma counts only in the file's leading comments, and the backend half has no JSX.
|
|
374
427
|
const pragmas = (ts.getLeadingCommentRanges(source, 0) ?? [])
|
|
@@ -402,7 +455,7 @@ export function middlewareSource(ts, source, fileName) {
|
|
|
402
455
|
// Edit from the end so earlier offsets stay valid.
|
|
403
456
|
for (const node of [...sf.statements].reverse()) {
|
|
404
457
|
if (ts.isExportAssignment(node) && !node.isExportEquals) {
|
|
405
|
-
out = `${out.slice(0, node.getStart(sf))}export const middleware = ${node.expression.getText(sf)}${out.slice(node.getEnd())}`;
|
|
458
|
+
out = `${out.slice(0, node.getStart(sf))}export const middleware = ${node.expression.getText(sf)}${terminatorOf(sf, node)}${out.slice(node.getEnd())}`;
|
|
406
459
|
}
|
|
407
460
|
else if (ts.isFunctionDeclaration(node) &&
|
|
408
461
|
hasModifier(ts, node, ts.SyntaxKind.DefaultKeyword)) {
|
|
@@ -419,7 +472,8 @@ export function middlewareSource(ts, source, fileName) {
|
|
|
419
472
|
}
|
|
420
473
|
else {
|
|
421
474
|
out = `${out.slice(0, first.getStart(sf))}${out.slice(last.getEnd()).replace(/^\s+/, "")}`;
|
|
422
|
-
|
|
475
|
+
const semi = sf.statements.some((s) => terminatorOf(sf, s) !== "") ? ";" : "";
|
|
476
|
+
out = `${out.trimEnd()}\nexport { ${node.name.text} as middleware }${semi}\n`;
|
|
423
477
|
}
|
|
424
478
|
}
|
|
425
479
|
}
|
|
@@ -467,6 +521,16 @@ function scriptBlocks(source) {
|
|
|
467
521
|
}
|
|
468
522
|
return blocks;
|
|
469
523
|
}
|
|
524
|
+
/** The path argument of `mock.module("./x", ...)` or a vitest/jest twin, when it is a literal. */
|
|
525
|
+
function mockedSpecifier(ts, node) {
|
|
526
|
+
const callee = node.expression;
|
|
527
|
+
if (!ts.isPropertyAccessExpression(callee) || !ts.isIdentifier(callee.expression))
|
|
528
|
+
return undefined;
|
|
529
|
+
if (!MOCK_CALLS.has(`${callee.expression.text}.${callee.name.text}`))
|
|
530
|
+
return undefined;
|
|
531
|
+
const arg = node.arguments[0];
|
|
532
|
+
return arg !== undefined && ts.isStringLiteralLike(arg) ? arg : undefined;
|
|
533
|
+
}
|
|
470
534
|
function importsOf(ts, file, text) {
|
|
471
535
|
if (/\.(?:svelte|vue)$/.test(file)) {
|
|
472
536
|
return scriptBlocks(text).flatMap((block) => importsOf(ts, "block.ts", text.slice(block.start, block.end)));
|
|
@@ -516,6 +580,10 @@ function importsOf(ts, file, text) {
|
|
|
516
580
|
const isRequire = ts.isIdentifier(node.expression) && node.expression.text === "require";
|
|
517
581
|
if ((isImport || isRequire) && arg !== undefined && ts.isStringLiteralLike(arg))
|
|
518
582
|
refs.push({ specifier: arg.text, typeOnly: false });
|
|
583
|
+
// A test's mock names the module without loading it into the app: no runtime edge.
|
|
584
|
+
const mocked = mockedSpecifier(ts, node);
|
|
585
|
+
if (mocked !== undefined)
|
|
586
|
+
refs.push({ specifier: mocked.text, typeOnly: true });
|
|
519
587
|
}
|
|
520
588
|
else if (ts.isImportEqualsDeclaration(node) &&
|
|
521
589
|
ts.isExternalModuleReference(node.moduleReference)) {
|
|
@@ -547,6 +615,107 @@ function resolveIn(files, from, specifier) {
|
|
|
547
615
|
}
|
|
548
616
|
return candidates.find((candidate) => files.has(candidate));
|
|
549
617
|
}
|
|
618
|
+
/** Where `text` spells, inside quotes, an import specifier that resolves among `files`: [start, end). */
|
|
619
|
+
function liveSpecifierSpans(ts, file, text, files) {
|
|
620
|
+
const spans = [];
|
|
621
|
+
for (const { specifier } of importsOf(ts, file, text)) {
|
|
622
|
+
if (resolveIn(files, file, specifier) === undefined)
|
|
623
|
+
continue;
|
|
624
|
+
for (const quote of ['"', "'", "`"]) {
|
|
625
|
+
const quoted = `${quote}${specifier}${quote}`;
|
|
626
|
+
for (let at = text.indexOf(quoted); at !== -1; at = text.indexOf(quoted, at + 1))
|
|
627
|
+
spans.push([at + 1, at + 1 + specifier.length]);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return spans;
|
|
631
|
+
}
|
|
632
|
+
const OWN_DIR = new Set(["import.meta.dir", "import.meta.dirname", "__dirname"]);
|
|
633
|
+
/**
|
|
634
|
+
* Re-point the paths a module moving from `from` to `to` builds from its own location, so each still
|
|
635
|
+
* reaches what it did: `join(import.meta.dir, "..", "x")` (or `resolve`), `import.meta.dir + "/../x"`,
|
|
636
|
+
* `` `${__dirname}/../x` `` and `new URL("../x", import.meta.url)`, each with literal segments. `locate`
|
|
637
|
+
* maps an app-relative target to where it ends up. Returns the new text and the first use of the
|
|
638
|
+
* module's location it could not follow.
|
|
639
|
+
*/
|
|
640
|
+
function rebaseOwnPaths(ts, from, to, text, locate) {
|
|
641
|
+
const sf = ts.createSourceFile(to, text, ts.ScriptTarget.Latest, true, to.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
642
|
+
const edits = [];
|
|
643
|
+
const followed = [];
|
|
644
|
+
const isOwnDir = (node) => node !== undefined && OWN_DIR.has(node.getText(sf));
|
|
645
|
+
const isLiteral = (node) => node !== undefined && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node));
|
|
646
|
+
const quoted = (node, value) => {
|
|
647
|
+
const quote = node.getText(sf)[0] ?? '"';
|
|
648
|
+
return `${quote}${value}${quote}`;
|
|
649
|
+
};
|
|
650
|
+
// The path `relative` names from the module's old folder, named from its new folder.
|
|
651
|
+
const rebased = (relative) => {
|
|
652
|
+
const target = locate(posix.normalize(posix.join(posix.dirname(from), relative)));
|
|
653
|
+
const next = posix.relative(posix.dirname(to), target) || ".";
|
|
654
|
+
return relative.endsWith("/") && next !== "." ? `${next}/` : next;
|
|
655
|
+
};
|
|
656
|
+
const follow = (anchor, start, end, replacement) => {
|
|
657
|
+
followed.push([anchor.getStart(sf), anchor.getEnd()]);
|
|
658
|
+
if (text.slice(start, end) !== replacement)
|
|
659
|
+
edits.push({ start, end, text: replacement });
|
|
660
|
+
};
|
|
661
|
+
const visit = (node) => {
|
|
662
|
+
if (ts.isCallExpression(node)) {
|
|
663
|
+
const [dir, ...parts] = node.arguments;
|
|
664
|
+
const callee = node.expression.getText(sf);
|
|
665
|
+
const first = parts[0];
|
|
666
|
+
const last = parts[parts.length - 1];
|
|
667
|
+
if (isOwnDir(dir) &&
|
|
668
|
+
/(?:^|\.)(?:join|resolve)$/.test(callee) &&
|
|
669
|
+
first !== undefined &&
|
|
670
|
+
last !== undefined &&
|
|
671
|
+
parts.every(isLiteral) &&
|
|
672
|
+
!(callee.endsWith("resolve") && parts.some((part) => part.text.startsWith("/")))) {
|
|
673
|
+
const next = rebased(posix.join(...parts.map((part) => part.text)));
|
|
674
|
+
const segments = parts.length === 1 ? [next] : next.split("/").filter((s) => s !== "");
|
|
675
|
+
const replacement = segments.map((segment) => quoted(first, segment)).join(", ");
|
|
676
|
+
follow(dir, first.getStart(sf), last.getEnd(), replacement);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (ts.isBinaryExpression(node) &&
|
|
681
|
+
node.operatorToken.kind === ts.SyntaxKind.PlusToken &&
|
|
682
|
+
isOwnDir(node.left) &&
|
|
683
|
+
isLiteral(node.right) &&
|
|
684
|
+
node.right.text.startsWith("/")) {
|
|
685
|
+
const next = rebased(node.right.text.slice(1) || ".");
|
|
686
|
+
follow(node.left, node.right.getStart(sf), node.right.getEnd(), quoted(node.right, `/${next}`));
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (ts.isTemplateExpression(node) && node.head.text === "" && node.templateSpans.length === 1) {
|
|
690
|
+
const span = node.templateSpans[0];
|
|
691
|
+
if (span !== undefined && isOwnDir(span.expression) && span.literal.text.startsWith("/")) {
|
|
692
|
+
const next = rebased(span.literal.text.slice(1) || ".");
|
|
693
|
+
const replacement = `\`\${${span.expression.getText(sf)}}/${next}\``;
|
|
694
|
+
follow(span.expression, node.getStart(sf), node.getEnd(), replacement);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (ts.isNewExpression(node) &&
|
|
699
|
+
node.expression.getText(sf) === "URL" &&
|
|
700
|
+
node.arguments?.length === 2 &&
|
|
701
|
+
node.arguments[1]?.getText(sf) === "import.meta.url") {
|
|
702
|
+
const path = node.arguments[0];
|
|
703
|
+
if (isLiteral(path) && !/^(?:\/|[a-z][a-z\d+.-]*:)/i.test(path.text)) {
|
|
704
|
+
const next = rebased(path.text);
|
|
705
|
+
const replacement = quoted(path, next.startsWith("..") ? next : `./${next}`);
|
|
706
|
+
follow(node.arguments[1], path.getStart(sf), path.getEnd(), replacement);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
ts.forEachChild(node, visit);
|
|
711
|
+
};
|
|
712
|
+
visit(sf);
|
|
713
|
+
const unfollowed = [...text.matchAll(new RegExp(SELF_LOCATION.source, "g"))].find((match) => !followed.some(([start, end]) => start <= match.index && match.index < end))?.[0];
|
|
714
|
+
let next = text;
|
|
715
|
+
for (const edit of edits.sort((a, b) => b.start - a.start))
|
|
716
|
+
next = next.slice(0, edit.start) + edit.text + next.slice(edit.end);
|
|
717
|
+
return { text: next, unfollowed };
|
|
718
|
+
}
|
|
550
719
|
/** Rewrite one relative specifier from `from`'s new location to `target`'s new location, keeping the
|
|
551
720
|
* author's style: extension kept when written, dropped when not, `/index` collapsed when it was. */
|
|
552
721
|
function respecify(original, oldTarget, newFrom, newTarget) {
|
|
@@ -578,6 +747,125 @@ function rebase(specifier, from, newFrom) {
|
|
|
578
747
|
const rel = posix.relative(posix.dirname(newFrom), target);
|
|
579
748
|
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
580
749
|
}
|
|
750
|
+
/** `specifier` re-pointed from a split route to its backend half, in the author's extension style. */
|
|
751
|
+
function backendSpecifier(specifier, route, backend, from) {
|
|
752
|
+
const query = /[?#].*$/.exec(specifier)?.[0] ?? "";
|
|
753
|
+
const written = extname(specifier.slice(0, specifier.length - query.length));
|
|
754
|
+
const stem = backend.slice(0, -extname(backend).length);
|
|
755
|
+
const target = written === ""
|
|
756
|
+
? stem
|
|
757
|
+
: written === extname(route)
|
|
758
|
+
? backend
|
|
759
|
+
: `${stem}${written === ".jsx" ? ".js" : written}`;
|
|
760
|
+
const rel = posix.relative(posix.dirname(from), target);
|
|
761
|
+
return (rel.startsWith(".") ? rel : `./${rel}`) + query;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Point an importer's named imports of a split route's moved exports (`loader`, `action`, ...) at the
|
|
765
|
+
* backend half, splitting a statement that also names what stayed. What cannot be rewritten (a
|
|
766
|
+
* namespace or dynamic import that reads a moved name, `export *`, an alias with no target there) is
|
|
767
|
+
* returned as an issue.
|
|
768
|
+
*/
|
|
769
|
+
function redirectMovedImports(ts, file, text, splitOf) {
|
|
770
|
+
const redirects = [];
|
|
771
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
772
|
+
const edits = [];
|
|
773
|
+
const issues = [];
|
|
774
|
+
const mentioned = (split, pattern) => [...split.moved].filter((name) => pattern(escapeRegExp(name)).test(text)).sort();
|
|
775
|
+
const quoted = (names) => names.map((name) => `"${name}"`).join(", ");
|
|
776
|
+
const visit = (node) => {
|
|
777
|
+
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
|
|
778
|
+
node.moduleSpecifier !== undefined &&
|
|
779
|
+
ts.isStringLiteral(node.moduleSpecifier)) {
|
|
780
|
+
const from = node.moduleSpecifier.text;
|
|
781
|
+
const split = splitOf(from);
|
|
782
|
+
if (split === undefined)
|
|
783
|
+
return;
|
|
784
|
+
const clause = ts.isImportDeclaration(node) ? node.importClause : undefined;
|
|
785
|
+
const named = ts.isImportDeclaration(node) ? clause?.namedBindings : node.exportClause;
|
|
786
|
+
if (named !== undefined && (ts.isNamespaceImport(named) || ts.isNamespaceExport(named))) {
|
|
787
|
+
const reads = mentioned(split, (name) => new RegExp(`\\b${named.name.text}\\.${name}\\b`));
|
|
788
|
+
if (reads.length > 0)
|
|
789
|
+
issues.push(`reads ${quoted(reads)} through a namespace import of "${from}", but they moved to "${split.backend}"; import them from there by hand`);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (named === undefined) {
|
|
793
|
+
if (ts.isExportDeclaration(node))
|
|
794
|
+
issues.push(`re-exports everything from "${from}", but ${quoted([...split.moved].sort())} moved to "${split.backend}"; re-export them from there by hand`);
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
const elements = named.elements;
|
|
798
|
+
const isMoved = (el) => split.moved.has((el.propertyName ?? el.name).text);
|
|
799
|
+
const moved = elements.filter(isMoved);
|
|
800
|
+
if (moved.length === 0)
|
|
801
|
+
return;
|
|
802
|
+
if (split.specifier === undefined) {
|
|
803
|
+
issues.push(`imports ${quoted(moved.map((el) => (el.propertyName ?? el.name).text))} from "${from}", but they moved to "${split.backend}" and no target of that alias covers it; point the import there by hand`);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
redirects.push({
|
|
807
|
+
names: moved.map((el) => (el.propertyName ?? el.name).text),
|
|
808
|
+
from,
|
|
809
|
+
to: split.specifier,
|
|
810
|
+
});
|
|
811
|
+
const kept = elements.filter((el) => !isMoved(el)).map((el) => el.getText(sf));
|
|
812
|
+
const quote = node.moduleSpecifier.getText(sf)[0] ?? '"';
|
|
813
|
+
const attributes = node.attributes ? ` ${node.attributes.getText(sf)}` : "";
|
|
814
|
+
const tail = (specifier) => ` from ${quote}${specifier}${quote}${attributes}${terminatorOf(sf, node)}`;
|
|
815
|
+
const keyword = ts.isImportDeclaration(node)
|
|
816
|
+
? `import ${clause?.isTypeOnly ? "type " : ""}`
|
|
817
|
+
: `export ${node.isTypeOnly ? "type " : ""}`;
|
|
818
|
+
const list = (names) => `{ ${names.join(", ")} }`;
|
|
819
|
+
const backend = `${keyword}${list(moved.map((el) => el.getText(sf)))}${tail(split.specifier)}`;
|
|
820
|
+
const stays = [
|
|
821
|
+
...(clause?.name === undefined ? [] : [clause.name.text]),
|
|
822
|
+
...(kept.length === 0 ? [] : [list(kept)]),
|
|
823
|
+
];
|
|
824
|
+
edits.push({
|
|
825
|
+
start: node.getStart(sf),
|
|
826
|
+
end: node.getEnd(),
|
|
827
|
+
text: stays.length === 0 ? backend : `${keyword}${stays.join(", ")}${tail(from)}\n${backend}`,
|
|
828
|
+
});
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (ts.isCallExpression(node) &&
|
|
832
|
+
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
|
833
|
+
node.arguments[0] !== undefined &&
|
|
834
|
+
ts.isStringLiteralLike(node.arguments[0])) {
|
|
835
|
+
const from = node.arguments[0].text;
|
|
836
|
+
const split = splitOf(from);
|
|
837
|
+
const reads = split === undefined ? [] : mentioned(split, (name) => new RegExp(`\\b${name}\\b`));
|
|
838
|
+
if (split !== undefined && reads.length > 0)
|
|
839
|
+
issues.push(`loads "${from}" with import() and mentions ${quoted(reads)}, which moved to "${split.backend}"; load the backend half for them by hand`);
|
|
840
|
+
}
|
|
841
|
+
ts.forEachChild(node, visit);
|
|
842
|
+
};
|
|
843
|
+
visit(sf);
|
|
844
|
+
let out = text;
|
|
845
|
+
for (const edit of edits.sort((a, b) => b.start - a.start))
|
|
846
|
+
out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
|
|
847
|
+
return { text: out, issues, redirects };
|
|
848
|
+
}
|
|
849
|
+
/** The package folder an app without its own package.json sits in (`--dir src`), at most two levels
|
|
850
|
+
* up and never a workspace root, so the scan beside it stays inside one package. */
|
|
851
|
+
function enclosingPackage(root) {
|
|
852
|
+
if (existsSync(join(root, "package.json")))
|
|
853
|
+
return undefined;
|
|
854
|
+
for (const dir of [dirname(root), dirname(dirname(root))]) {
|
|
855
|
+
const manifest = join(dir, "package.json");
|
|
856
|
+
if (dir === root || !existsSync(manifest))
|
|
857
|
+
continue;
|
|
858
|
+
try {
|
|
859
|
+
const parsed = JSON.parse(readFileSync(manifest, "utf8"));
|
|
860
|
+
const workspace = typeof parsed === "object" && parsed !== null && "workspaces" in parsed;
|
|
861
|
+
return workspace ? undefined : dir;
|
|
862
|
+
}
|
|
863
|
+
catch {
|
|
864
|
+
return undefined;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
return undefined;
|
|
868
|
+
}
|
|
581
869
|
function rewriteImports(ts, file, text, rewrite) {
|
|
582
870
|
const replace = (code, offset, out, kind) => {
|
|
583
871
|
const sf = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true, kind);
|
|
@@ -598,6 +886,8 @@ function rewriteImports(ts, file, text, rewrite) {
|
|
|
598
886
|
if (arg !== undefined && ts.isStringLiteralLike(arg))
|
|
599
887
|
literal = arg;
|
|
600
888
|
}
|
|
889
|
+
else if (ts.isCallExpression(node))
|
|
890
|
+
literal = mockedSpecifier(ts, node);
|
|
601
891
|
if (literal !== undefined) {
|
|
602
892
|
const next = rewrite(literal.text);
|
|
603
893
|
if (next !== undefined && next !== literal.text) {
|
|
@@ -697,20 +987,138 @@ function renameRetiredTypes(ts, file, text) {
|
|
|
697
987
|
// ---------------------------------------------------------------------------------------------------
|
|
698
988
|
// The migration
|
|
699
989
|
// ---------------------------------------------------------------------------------------------------
|
|
700
|
-
const ROOT_MOVES = {
|
|
701
|
-
"backend.ts": "backend/app.ts",
|
|
702
|
-
"framework.ts": "backend/framework.ts",
|
|
703
|
-
};
|
|
704
990
|
/** Root files that stay where they are: the CLI's config, and scripts no build loads. */
|
|
705
|
-
|
|
991
|
+
/** The config files the loader reads, old layout included; a path field may live in any of them. */
|
|
992
|
+
const CONFIG_FILES = ["nifra.config.ts", "framework.ts", "backend/framework.ts"];
|
|
993
|
+
/**
|
|
994
|
+
* The module paths a config exports as string literals: `clientModule` and the component pipeline's
|
|
995
|
+
* `entries`. The browser loads `clientModule` and `entries.client`; `ssr` and `rsc` run on the server.
|
|
996
|
+
*/
|
|
997
|
+
function configEntries(ts, file, text) {
|
|
998
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
999
|
+
const bare = (node) => {
|
|
1000
|
+
let current = node;
|
|
1001
|
+
while (current !== undefined &&
|
|
1002
|
+
(ts.isAsExpression(current) ||
|
|
1003
|
+
ts.isSatisfiesExpression(current) ||
|
|
1004
|
+
ts.isParenthesizedExpression(current)))
|
|
1005
|
+
current = current.expression;
|
|
1006
|
+
return current;
|
|
1007
|
+
};
|
|
1008
|
+
const out = [];
|
|
1009
|
+
const literal = (field, role, node) => {
|
|
1010
|
+
const value = bare(node);
|
|
1011
|
+
if (value === undefined || !ts.isStringLiteralLike(value))
|
|
1012
|
+
return;
|
|
1013
|
+
out.push({
|
|
1014
|
+
field,
|
|
1015
|
+
role,
|
|
1016
|
+
value: value.text,
|
|
1017
|
+
start: value.getStart(sf) + 1,
|
|
1018
|
+
end: value.getEnd() - 1,
|
|
1019
|
+
});
|
|
1020
|
+
};
|
|
1021
|
+
for (const statement of sf.statements) {
|
|
1022
|
+
if (!ts.isVariableStatement(statement) ||
|
|
1023
|
+
!hasModifier(ts, statement, ts.SyntaxKind.ExportKeyword))
|
|
1024
|
+
continue;
|
|
1025
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
1026
|
+
if (!ts.isIdentifier(declaration.name))
|
|
1027
|
+
continue;
|
|
1028
|
+
if (declaration.name.text === "clientModule")
|
|
1029
|
+
literal("clientModule", "frontend", declaration.initializer);
|
|
1030
|
+
if (declaration.name.text !== "componentPipeline")
|
|
1031
|
+
continue;
|
|
1032
|
+
const pipeline = bare(declaration.initializer);
|
|
1033
|
+
if (pipeline === undefined || !ts.isObjectLiteralExpression(pipeline))
|
|
1034
|
+
continue;
|
|
1035
|
+
for (const property of pipeline.properties) {
|
|
1036
|
+
if (!ts.isPropertyAssignment(property) || property.name.getText(sf) !== "entries")
|
|
1037
|
+
continue;
|
|
1038
|
+
const entries = bare(property.initializer);
|
|
1039
|
+
if (entries === undefined || !ts.isObjectLiteralExpression(entries))
|
|
1040
|
+
continue;
|
|
1041
|
+
for (const entry of entries.properties) {
|
|
1042
|
+
if (!ts.isPropertyAssignment(entry))
|
|
1043
|
+
continue;
|
|
1044
|
+
const name = entry.name.getText(sf).replace(/^["']|["']$/g, "");
|
|
1045
|
+
if (name === "client" || name === "ssr" || name === "rsc")
|
|
1046
|
+
literal(`componentPipeline.entries.${name}`, name === "client" ? "frontend" : "backend", entry.initializer);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
return out;
|
|
1052
|
+
}
|
|
1053
|
+
/** The package an import names: `@scope/pkg` or `pkg`, without a subpath. */
|
|
1054
|
+
const packageName = (specifier) => {
|
|
1055
|
+
const parts = specifier.split("/");
|
|
1056
|
+
return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : (parts[0] ?? "");
|
|
1057
|
+
};
|
|
1058
|
+
const isRootScript = (file, rootMoves) => !file.includes("/") && SOURCE_FILE.test(file) && rootMoves[file] === undefined;
|
|
706
1059
|
const isTestFile = (file) => /(?:^|\/)(?:test|tests|__tests__)\//.test(file) || /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(file);
|
|
1060
|
+
/** The web config an app has, old root `framework.ts` included, or `undefined`. */
|
|
1061
|
+
const webConfigFile = (root, rootMoves) => rootMoves["framework.ts"] !== undefined
|
|
1062
|
+
? "framework.ts"
|
|
1063
|
+
: existsSync(join(root, FRAMEWORK_FILE))
|
|
1064
|
+
? FRAMEWORK_FILE
|
|
1065
|
+
: hasWebConfig(root, ".")
|
|
1066
|
+
? CONFIG_FILE
|
|
1067
|
+
: undefined;
|
|
1068
|
+
/**
|
|
1069
|
+
* `"api"` when the app declares `"nifra": { "kind": "api" }`, or has no web config, no `routes/` and a
|
|
1070
|
+
* backend; `"web"` when it has `routes/`. Web config without `routes/` could be either, so it throws.
|
|
1071
|
+
*/
|
|
1072
|
+
function layoutMode(root, rootMoves) {
|
|
1073
|
+
const declared = createKindProbe(root)(root);
|
|
1074
|
+
if (declared.invalid !== undefined)
|
|
1075
|
+
throw new Error(declared.invalid);
|
|
1076
|
+
const config = webConfigFile(root, rootMoves);
|
|
1077
|
+
if (declared.kind === "api") {
|
|
1078
|
+
if (config === undefined)
|
|
1079
|
+
return "api";
|
|
1080
|
+
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`);
|
|
1081
|
+
}
|
|
1082
|
+
if (existsSync(join(root, "routes")))
|
|
1083
|
+
return "web";
|
|
1084
|
+
const nested = nestedAppHint(root);
|
|
1085
|
+
if (config !== undefined) {
|
|
1086
|
+
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}`);
|
|
1087
|
+
}
|
|
1088
|
+
if (declared.kind === "web")
|
|
1089
|
+
throw new Error(`[nifra] no routes/ directory in ${root}${nested}`);
|
|
1090
|
+
if (rootMoves["backend.ts"] === undefined && !existsSync(join(root, BACKEND_APP_FILE))) {
|
|
1091
|
+
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}`);
|
|
1092
|
+
}
|
|
1093
|
+
return "api";
|
|
1094
|
+
}
|
|
1095
|
+
/** An app one folder down (`src/routes/`): the `--dir` that migrates it, as a sentence to append. */
|
|
1096
|
+
function nestedAppHint(root) {
|
|
1097
|
+
const found = readdirSync(root, { withFileTypes: true })
|
|
1098
|
+
.filter((entry) => entry.isDirectory() &&
|
|
1099
|
+
!entry.name.startsWith(".") &&
|
|
1100
|
+
!SKIP_DIRS.has(entry.name) &&
|
|
1101
|
+
existsSync(join(root, entry.name, "routes")))
|
|
1102
|
+
.map((entry) => entry.name)
|
|
1103
|
+
.sort(codeUnitOrder);
|
|
1104
|
+
const first = found[0];
|
|
1105
|
+
if (first === undefined)
|
|
1106
|
+
return "";
|
|
1107
|
+
return `. ${found.map((name) => `${name}/routes/`).join(", ")} exists: run \`nifra migrate layout --dir ${first}\` to migrate the app there`;
|
|
1108
|
+
}
|
|
707
1109
|
export async function migrateLayout(appRoot, options = {}) {
|
|
708
1110
|
const ts = options.typescript ?? (await importTypeScript());
|
|
709
1111
|
if (ts === undefined)
|
|
710
1112
|
throw new Error("[nifra] `nifra migrate layout` needs TypeScript: bun add -d typescript");
|
|
711
1113
|
const root = resolve(appRoot);
|
|
712
|
-
|
|
713
|
-
|
|
1114
|
+
// Only a root file that exports what the old layout read moves; any other backend.ts is a module.
|
|
1115
|
+
const rootMoves = Object.fromEntries(retiredRootFiles(root));
|
|
1116
|
+
const mode = layoutMode(root, rootMoves);
|
|
1117
|
+
// An API-only app has no browser: nothing to split, and every file is server code where it is.
|
|
1118
|
+
const api = mode === "api";
|
|
1119
|
+
const zones = appZones(root);
|
|
1120
|
+
const configured = [...(zones.backend ?? []), ...(zones.frontend ?? []), ...(zones.shared ?? [])];
|
|
1121
|
+
const inConfiguredFolder = (file) => configured.some((folder) => file === folder || file.startsWith(`${folder}/`));
|
|
714
1122
|
const original = new Map();
|
|
715
1123
|
const everyFile = listFiles(root);
|
|
716
1124
|
for (const file of everyFile) {
|
|
@@ -726,7 +1134,7 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
726
1134
|
// 1-2. Route halves and middleware.
|
|
727
1135
|
const backendHalves = new Map();
|
|
728
1136
|
for (const [file, text] of original) {
|
|
729
|
-
if (!file.startsWith("routes/") || file.includes(".backend."))
|
|
1137
|
+
if (api || !file.startsWith("routes/") || file.includes(".backend."))
|
|
730
1138
|
continue;
|
|
731
1139
|
if (/(?:^|\/)_middleware\.[cm]?[jt]sx?$/.test(file)) {
|
|
732
1140
|
const target = posix.join(posix.dirname(file), "_layout.backend.ts");
|
|
@@ -776,14 +1184,101 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
776
1184
|
next.set(file, existing === undefined ? text : mergeModules(ts, existing, text));
|
|
777
1185
|
}
|
|
778
1186
|
// 3-4. Zoning by runtime reachability, on the post-split graph.
|
|
779
|
-
for (const [from, to] of Object.entries(
|
|
1187
|
+
for (const [from, to] of Object.entries(rootMoves))
|
|
780
1188
|
if (next.has(from))
|
|
781
1189
|
moveMap.set(from, to);
|
|
782
1190
|
// Assets (images, fonts) resolve too, so they move with the modules that import them.
|
|
783
1191
|
const known = new Set([...next.keys(), ...everyFile]);
|
|
1192
|
+
const resolver = createSpecifierResolver();
|
|
1193
|
+
const realRoot = (() => {
|
|
1194
|
+
try {
|
|
1195
|
+
return realpathSync(root);
|
|
1196
|
+
}
|
|
1197
|
+
catch {
|
|
1198
|
+
return root;
|
|
1199
|
+
}
|
|
1200
|
+
})();
|
|
1201
|
+
/** A non-relative specifier the app's tsconfig `paths`/`baseUrl` or package.json `imports` maps to
|
|
1202
|
+
* one of its files, read from disk as the bundler reads it. */
|
|
1203
|
+
const viaAlias = (file, specifier) => {
|
|
1204
|
+
if (specifier.startsWith(".") || isAbsolute(specifier))
|
|
1205
|
+
return undefined;
|
|
1206
|
+
const found = resolver(join(root, file), specifier);
|
|
1207
|
+
if (found === undefined)
|
|
1208
|
+
return undefined;
|
|
1209
|
+
const rel = toPosix(relative(realRoot, found));
|
|
1210
|
+
return rel.startsWith("../") || isAbsolute(rel) || !known.has(rel) ? undefined : rel;
|
|
1211
|
+
};
|
|
1212
|
+
const resolveAny = (file, specifier) => resolveIn(known, file, specifier) ?? viaAlias(file, specifier);
|
|
1213
|
+
const ownName = (() => {
|
|
1214
|
+
try {
|
|
1215
|
+
const name = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).name;
|
|
1216
|
+
return typeof name === "string" ? name : undefined;
|
|
1217
|
+
}
|
|
1218
|
+
catch {
|
|
1219
|
+
return undefined;
|
|
1220
|
+
}
|
|
1221
|
+
})();
|
|
1222
|
+
const installed = new Map();
|
|
1223
|
+
/** Whether a bare specifier names something outside the app that exists: a built-in, a URL or a
|
|
1224
|
+
* scheme the bundler owns, the app's own package name, or a package installed at or above it. */
|
|
1225
|
+
const external = (specifier) => {
|
|
1226
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(specifier) || specifier === "bun")
|
|
1227
|
+
return true;
|
|
1228
|
+
const name = packageName(specifier.replace(/[?#].*$/, ""));
|
|
1229
|
+
if (builtinModules.includes(name) || name === ownName)
|
|
1230
|
+
return true;
|
|
1231
|
+
let found = installed.get(name);
|
|
1232
|
+
if (found === undefined) {
|
|
1233
|
+
const types = `@types/${name.startsWith("@") ? name.slice(1).replace("/", "__") : name}`;
|
|
1234
|
+
found = false;
|
|
1235
|
+
for (let dir = root;; dir = dirname(dir)) {
|
|
1236
|
+
if (existsSync(join(dir, "node_modules", name)) ||
|
|
1237
|
+
existsSync(join(dir, "node_modules", types))) {
|
|
1238
|
+
found = true;
|
|
1239
|
+
break;
|
|
1240
|
+
}
|
|
1241
|
+
if (dirname(dir) === dir)
|
|
1242
|
+
break;
|
|
1243
|
+
}
|
|
1244
|
+
installed.set(name, found);
|
|
1245
|
+
}
|
|
1246
|
+
return found;
|
|
1247
|
+
};
|
|
1248
|
+
// A test (or any module) that imports `loader` from a page would import a name the page no longer
|
|
1249
|
+
// exports: it reads it from the backend half instead.
|
|
1250
|
+
const splitRoutes = new Map();
|
|
1251
|
+
for (const split of splits)
|
|
1252
|
+
if (next.has(split.file))
|
|
1253
|
+
splitRoutes.set(split.file, { ...split, moved: new Set(split.moved) });
|
|
1254
|
+
/** `base` is where `file` is relative to: the app root, or the package folder around it. */
|
|
1255
|
+
const splitTarget = (base, file, resolve) => (specifier) => {
|
|
1256
|
+
const route = resolve(specifier);
|
|
1257
|
+
const split = route === undefined ? undefined : splitRoutes.get(route);
|
|
1258
|
+
if (route === undefined || split === undefined)
|
|
1259
|
+
return undefined;
|
|
1260
|
+
const inBase = (path) => toPosix(relative(base, join(root, path)));
|
|
1261
|
+
const respelled = specifier.startsWith(".")
|
|
1262
|
+
? backendSpecifier(specifier, inBase(route), inBase(split.backend), file)
|
|
1263
|
+
: resolver.respell(join(base, file), specifier, join(root, split.backend));
|
|
1264
|
+
return { backend: split.backend, moved: split.moved, specifier: respelled };
|
|
1265
|
+
};
|
|
1266
|
+
const redirectedFiles = new Set();
|
|
1267
|
+
for (const [file, text] of splitRoutes.size === 0 ? [] : next) {
|
|
1268
|
+
if (splitRoutes.has(file) || !/\.[cm]?[jt]sx?$/.test(file))
|
|
1269
|
+
continue;
|
|
1270
|
+
const redirected = redirectMovedImports(ts, file, text, splitTarget(root, file, (specifier) => resolveAny(file, specifier)));
|
|
1271
|
+
if (redirected.text !== text) {
|
|
1272
|
+
next.set(file, redirected.text);
|
|
1273
|
+
redirectedFiles.add(file);
|
|
1274
|
+
}
|
|
1275
|
+
for (const reason of redirected.issues)
|
|
1276
|
+
issues.push({ file, reason });
|
|
1277
|
+
}
|
|
784
1278
|
const edges = new Map();
|
|
785
|
-
for (const [file, text] of next) {
|
|
1279
|
+
for (const [file, text] of api ? [] : next) {
|
|
786
1280
|
const refs = [];
|
|
1281
|
+
const reported = new Set();
|
|
787
1282
|
for (const ref of importsOf(ts, file, text)) {
|
|
788
1283
|
if (ref.specifier === "@nifrajs/web/plugins/vite-server-only") {
|
|
789
1284
|
issues.push({
|
|
@@ -791,18 +1286,43 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
791
1286
|
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)",
|
|
792
1287
|
});
|
|
793
1288
|
}
|
|
794
|
-
const target =
|
|
1289
|
+
const target = resolveAny(file, ref.specifier);
|
|
795
1290
|
if (target !== undefined)
|
|
796
1291
|
refs.push({ target, typeOnly: ref.typeOnly });
|
|
797
|
-
else if (
|
|
1292
|
+
else if (!ref.specifier.startsWith(".") &&
|
|
1293
|
+
!isAbsolute(ref.specifier) &&
|
|
1294
|
+
RETIRED_SPECIFIERS[ref.specifier] === undefined &&
|
|
1295
|
+
resolver(join(root, file), ref.specifier) === undefined &&
|
|
1296
|
+
!external(ref.specifier) &&
|
|
1297
|
+
!reported.has(ref.specifier)) {
|
|
1298
|
+
reported.add(ref.specifier);
|
|
798
1299
|
issues.push({
|
|
799
1300
|
file,
|
|
800
|
-
reason: `
|
|
1301
|
+
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`,
|
|
801
1302
|
});
|
|
1303
|
+
}
|
|
802
1304
|
}
|
|
803
1305
|
edges.set(file, refs);
|
|
804
1306
|
}
|
|
805
|
-
|
|
1307
|
+
// The config's module paths are entries too: the browser loads `clientModule`.
|
|
1308
|
+
const entries = CONFIG_FILES.filter((file) => next.has(file)).flatMap((file) => configEntries(ts, file, next.get(file) ?? "").map((entry) => ({
|
|
1309
|
+
...entry,
|
|
1310
|
+
file,
|
|
1311
|
+
// Relative to the app root, as the loader reads it, whichever config file holds it.
|
|
1312
|
+
target: resolveAny("nifra.config.ts", entry.value),
|
|
1313
|
+
})));
|
|
1314
|
+
const entryTargets = (role) => new Set(entries.flatMap((entry) => entry.role === role && entry.target !== undefined ? [entry.target] : []));
|
|
1315
|
+
const browserEntries = entryTargets("frontend");
|
|
1316
|
+
const serverEntries = entryTargets("backend");
|
|
1317
|
+
const files = [...new Set([...next.keys(), ...everyFile.filter((f) => !original.has(f))])];
|
|
1318
|
+
const frontendRoots = [
|
|
1319
|
+
...files.filter((f) => f.startsWith("routes/") && ROUTE_FILE.test(f) && !f.includes(".backend.")),
|
|
1320
|
+
...browserEntries,
|
|
1321
|
+
];
|
|
1322
|
+
// A route page the server imports - a generated `server-manifest.ts` imports them all - is rendered
|
|
1323
|
+
// there, not run as server code, so the server's walk stops at it: what only pages use stays frontend.
|
|
1324
|
+
const pages = new Set(frontendRoots.filter((f) => !browserEntries.has(f)));
|
|
1325
|
+
const reach = (roots, stopAt) => {
|
|
806
1326
|
const seen = new Set();
|
|
807
1327
|
const queue = [...roots];
|
|
808
1328
|
while (queue.length > 0) {
|
|
@@ -810,25 +1330,31 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
810
1330
|
if (seen.has(file))
|
|
811
1331
|
continue;
|
|
812
1332
|
seen.add(file);
|
|
1333
|
+
if (stopAt?.has(file))
|
|
1334
|
+
continue;
|
|
813
1335
|
for (const edge of edges.get(file) ?? [])
|
|
814
1336
|
if (!edge.typeOnly)
|
|
815
1337
|
queue.push(edge.target);
|
|
816
1338
|
}
|
|
817
1339
|
return seen;
|
|
818
1340
|
};
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1341
|
+
const backendRoots = [
|
|
1342
|
+
...files.filter((f) => (f.startsWith("routes/") && f.includes(".backend.")) ||
|
|
1343
|
+
f.startsWith("backend/") ||
|
|
1344
|
+
rootMoves[f] !== undefined ||
|
|
1345
|
+
(isRootScript(f, rootMoves) &&
|
|
1346
|
+
f !== "nifra.config.ts" &&
|
|
1347
|
+
!isTestFile(f) &&
|
|
1348
|
+
!browserEntries.has(f))),
|
|
1349
|
+
...serverEntries,
|
|
1350
|
+
];
|
|
825
1351
|
const browser = reach(frontendRoots);
|
|
826
|
-
const server = reach(backendRoots);
|
|
1352
|
+
const server = reach(backendRoots, pages);
|
|
827
1353
|
// What the app's own server modules reach, without the root scripts: a root file in here is a module
|
|
828
1354
|
// the app imports, not an entry, so it moves into a zone.
|
|
829
|
-
const appServer = reach(backendRoots.filter((f) => f.includes("/") ||
|
|
1355
|
+
const appServer = reach(backendRoots.filter((f) => f.includes("/") || rootMoves[f] !== undefined || serverEntries.has(f)), pages);
|
|
830
1356
|
// A `.server` module ran empty in the browser, so only server code used it: it moves under backend/.
|
|
831
|
-
for (const file of files) {
|
|
1357
|
+
for (const file of api ? [] : files) {
|
|
832
1358
|
if (!LEGACY_SERVER.test(file) || isTestFile(file))
|
|
833
1359
|
continue;
|
|
834
1360
|
const parts = file.replace(LEGACY_SERVER, "$1").split("/");
|
|
@@ -844,11 +1370,12 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
844
1370
|
}
|
|
845
1371
|
}
|
|
846
1372
|
const zoneOf = (file) => browser.has(file) && server.has(file) ? "shared" : browser.has(file) ? "frontend" : "backend";
|
|
847
|
-
for (const file of files) {
|
|
1373
|
+
for (const file of api ? [] : files) {
|
|
848
1374
|
if (moveMap.has(file) ||
|
|
849
1375
|
isTestFile(file) ||
|
|
850
1376
|
file.endsWith(".d.ts") ||
|
|
851
|
-
file === "nifra.config.ts"
|
|
1377
|
+
file === "nifra.config.ts" ||
|
|
1378
|
+
inConfiguredFolder(file))
|
|
852
1379
|
continue;
|
|
853
1380
|
if (/\.(?:frontend|backend|shared|fn)\.[cm]?[jt]sx?$/.test(file))
|
|
854
1381
|
continue;
|
|
@@ -859,7 +1386,16 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
859
1386
|
}
|
|
860
1387
|
else if (!browser.has(file) && !appServer.has(file))
|
|
861
1388
|
continue;
|
|
862
|
-
|
|
1389
|
+
const zone = zoneOf(file);
|
|
1390
|
+
if (DATA_FILE.test(file)) {
|
|
1391
|
+
const folder = file.includes("/") ? posix.dirname(file) : undefined;
|
|
1392
|
+
issues.push({
|
|
1393
|
+
file,
|
|
1394
|
+
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`,
|
|
1395
|
+
});
|
|
1396
|
+
continue;
|
|
1397
|
+
}
|
|
1398
|
+
moveMap.set(file, `${zone}/${file}`);
|
|
863
1399
|
}
|
|
864
1400
|
const claimed = new Map();
|
|
865
1401
|
for (const [from, to] of moveMap) {
|
|
@@ -886,37 +1422,181 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
886
1422
|
const newPath = (file) => moveMap.get(file) ?? file;
|
|
887
1423
|
const final = new Map();
|
|
888
1424
|
const rewritten = [];
|
|
1425
|
+
const unspelled = new Set();
|
|
1426
|
+
/** An alias for a target that moves, through the same alias, or an issue when none covers it. */
|
|
1427
|
+
const respellAlias = (file, specifier, target) => {
|
|
1428
|
+
if (newPath(target) === target)
|
|
1429
|
+
return undefined;
|
|
1430
|
+
const respelled = resolver.respell(join(root, file), specifier, join(root, newPath(target)));
|
|
1431
|
+
if (respelled === undefined && !unspelled.has(`${file}\0${specifier}`)) {
|
|
1432
|
+
unspelled.add(`${file}\0${specifier}`);
|
|
1433
|
+
issues.push({
|
|
1434
|
+
file,
|
|
1435
|
+
reason: `the path alias "${specifier}" names "${target}", which moves to "${newPath(target)}", and no target of that alias covers the new location; point it there by hand`,
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
return respelled;
|
|
1439
|
+
};
|
|
889
1440
|
for (const [file, text] of next) {
|
|
890
1441
|
const imports = rewriteImports(ts, file, text, (specifier) => {
|
|
891
1442
|
const retired = RETIRED_SPECIFIERS[specifier];
|
|
892
1443
|
if (retired !== undefined)
|
|
893
1444
|
return retired;
|
|
894
|
-
const target =
|
|
1445
|
+
const target = resolveAny(file, specifier);
|
|
895
1446
|
// Outside the app (or a skipped dir): the target stays put, so only a moved importer re-bases it.
|
|
896
1447
|
if (target === undefined)
|
|
897
1448
|
return rebase(specifier, file, newPath(file));
|
|
1449
|
+
if (!specifier.startsWith("."))
|
|
1450
|
+
return respellAlias(file, specifier, target);
|
|
898
1451
|
if (newPath(target) === target && newPath(file) === file)
|
|
899
1452
|
return undefined;
|
|
900
1453
|
return respecify(specifier, target, newPath(file), newPath(target));
|
|
901
1454
|
});
|
|
902
1455
|
const updated = renameRetiredTypes(ts, file, imports);
|
|
903
1456
|
final.set(newPath(file), updated);
|
|
904
|
-
if (
|
|
1457
|
+
if (!moveMap.has(file) &&
|
|
1458
|
+
(redirectedFiles.has(file) || (updated !== text && original.get(file) === text)))
|
|
905
1459
|
rewritten.push(file);
|
|
906
1460
|
}
|
|
1461
|
+
// A config path names its module from the app root, so it follows the module, never the config file.
|
|
1462
|
+
for (const configFile of CONFIG_FILES) {
|
|
1463
|
+
const at = newPath(configFile);
|
|
1464
|
+
const text = final.get(at);
|
|
1465
|
+
if (text === undefined || !next.has(configFile))
|
|
1466
|
+
continue;
|
|
1467
|
+
const edits = [];
|
|
1468
|
+
for (const entry of configEntries(ts, at, text)) {
|
|
1469
|
+
const target = resolveAny("nifra.config.ts", entry.value);
|
|
1470
|
+
if (target === undefined || newPath(target) === target)
|
|
1471
|
+
continue;
|
|
1472
|
+
const value = entry.value.startsWith(".")
|
|
1473
|
+
? respecify(entry.value, target, "nifra.config.ts", newPath(target))
|
|
1474
|
+
: respellAlias(configFile, entry.value, target);
|
|
1475
|
+
if (value !== undefined)
|
|
1476
|
+
edits.push({ start: entry.start, end: entry.end, text: value });
|
|
1477
|
+
}
|
|
1478
|
+
if (edits.length === 0)
|
|
1479
|
+
continue;
|
|
1480
|
+
let updated = text;
|
|
1481
|
+
for (const edit of edits.sort((a, b) => b.start - a.start))
|
|
1482
|
+
updated = updated.slice(0, edit.start) + edit.text + updated.slice(edit.end);
|
|
1483
|
+
final.set(at, updated);
|
|
1484
|
+
if (!moveMap.has(configFile) && !rewritten.includes(configFile))
|
|
1485
|
+
rewritten.push(configFile);
|
|
1486
|
+
}
|
|
1487
|
+
const moves = [...moveMap]
|
|
1488
|
+
.map(([from, to]) => ({ from, to }))
|
|
1489
|
+
.sort((a, b) => codeUnitOrder(a.from, b.from));
|
|
1490
|
+
// A moved module's paths from its own location now start from its new folder: re-point the ones it
|
|
1491
|
+
// spells out, and report the first one it cannot follow.
|
|
1492
|
+
const unfollowed = new Map();
|
|
1493
|
+
for (const { from, to } of moves) {
|
|
1494
|
+
const text = final.get(to);
|
|
1495
|
+
if (text === undefined || !SELF_LOCATION.test(text))
|
|
1496
|
+
continue;
|
|
1497
|
+
if (!/\.[cm]?[jt]sx?$/.test(to)) {
|
|
1498
|
+
unfollowed.set(to, SELF_LOCATION.exec(text)?.[0] ?? "");
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
1501
|
+
const result = rebaseOwnPaths(ts, from, to, text, (target) => known.has(target) ? newPath(target) : target);
|
|
1502
|
+
final.set(to, result.text);
|
|
1503
|
+
if (result.unfollowed !== undefined)
|
|
1504
|
+
unfollowed.set(to, result.unfollowed);
|
|
1505
|
+
}
|
|
907
1506
|
// A path in a string or comment is not an import, so nothing above rewrote it: a build script that
|
|
908
|
-
// writes `data/x.json` would keep writing to the old place
|
|
909
|
-
|
|
1507
|
+
// writes `data/x.json` would keep writing to the old place, and so would a deploy script or
|
|
1508
|
+
// `package.json` beside the code.
|
|
1509
|
+
const settled = new Set([...known].map(newPath));
|
|
1510
|
+
const scanned = [...final];
|
|
1511
|
+
for (const file of everyFile) {
|
|
1512
|
+
if (!original.has(file) &&
|
|
1513
|
+
TOOLING_FILE.test(file) &&
|
|
1514
|
+
statSync(join(root, file)).size <= 256_000)
|
|
1515
|
+
scanned.push([newPath(file), readFileSync(join(root, file), "utf8")]);
|
|
1516
|
+
}
|
|
1517
|
+
for (const [file, text] of scanned) {
|
|
1518
|
+
let live;
|
|
910
1519
|
for (const [from, to] of moveMap) {
|
|
911
1520
|
// `shared/lib/x.ts` contains `lib/x.ts`; that is the new path, not a stale one.
|
|
912
1521
|
const newPrefix = to.endsWith(from) ? to.slice(0, to.length - from.length) : undefined;
|
|
913
1522
|
const stale = [
|
|
914
1523
|
...text.matchAll(new RegExp(`(?<![\\w.-])${escapeRegExp(from)}(?![\\w])`, "g")),
|
|
915
|
-
].some((match) =>
|
|
1524
|
+
].some((match) => {
|
|
1525
|
+
if (newPrefix !== undefined && text.slice(0, match.index).endsWith(newPrefix))
|
|
1526
|
+
return false;
|
|
1527
|
+
// An import that resolves after the moves is current, even when it still reads like the old
|
|
1528
|
+
// path: `./x.json` from a module that moved together with `x.json`.
|
|
1529
|
+
live ??= liveSpecifierSpans(ts, file, text, settled);
|
|
1530
|
+
const end = match.index + from.length;
|
|
1531
|
+
return !live.some(([start, stop]) => start <= match.index && end <= stop);
|
|
1532
|
+
});
|
|
916
1533
|
if (stale)
|
|
917
1534
|
issues.push({ file, reason: `mentions "${from}" by path; it moved to "${to}"` });
|
|
918
1535
|
}
|
|
919
1536
|
}
|
|
1537
|
+
// Code beside an app one folder down (a package's test/ when the app is its src/) is not the
|
|
1538
|
+
// migration's to edit, so each import there that stops resolving to what it named is reported.
|
|
1539
|
+
const pkg = enclosingPackage(root);
|
|
1540
|
+
if (pkg !== undefined) {
|
|
1541
|
+
const prefix = toPosix(relative(pkg, root));
|
|
1542
|
+
const inApp = new Set([...known].map((file) => `${prefix}/${file}`));
|
|
1543
|
+
const appPath = (file, specifier) => resolveIn(inApp, file, specifier)?.slice(prefix.length + 1);
|
|
1544
|
+
const outside = ` - it is outside ${prefix}/, so the migration does not rewrite it`;
|
|
1545
|
+
for (const file of listFiles(pkg)) {
|
|
1546
|
+
if (file.startsWith(`${prefix}/`) || !/\.[cm]?[jt]sx?$/.test(file))
|
|
1547
|
+
continue;
|
|
1548
|
+
const text = readFileSync(join(pkg, file), "utf8");
|
|
1549
|
+
const at = toPosix(relative(root, join(pkg, file)));
|
|
1550
|
+
for (const { specifier } of importsOf(ts, file, text)) {
|
|
1551
|
+
const target = appPath(file, specifier);
|
|
1552
|
+
const to = target === undefined ? undefined : moveMap.get(target);
|
|
1553
|
+
if (to !== undefined)
|
|
1554
|
+
issues.push({
|
|
1555
|
+
file: at,
|
|
1556
|
+
reason: `imports "${specifier}", which moves to "${to}"${outside}`,
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
const redirected = redirectMovedImports(ts, file, text, splitTarget(pkg, file, (specifier) => appPath(file, specifier)));
|
|
1560
|
+
for (const { names, from, to } of redirected.redirects) {
|
|
1561
|
+
issues.push({
|
|
1562
|
+
file: at,
|
|
1563
|
+
reason: `imports ${names.map((name) => `"${name}"`).join(", ")} from "${from}"; import it from "${to}"${outside}`,
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
for (const reason of redirected.issues)
|
|
1567
|
+
issues.push({ file: at, reason });
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
// Paths a module finds at run time are not imports either: one built from the module's own location
|
|
1571
|
+
// now starts from its new folder, and a folder listed at run time loses whatever moved out of it.
|
|
1572
|
+
const leaving = new Map();
|
|
1573
|
+
for (const move of moves) {
|
|
1574
|
+
const own = unfollowed.get(move.to);
|
|
1575
|
+
if (own !== undefined) {
|
|
1576
|
+
issues.push({
|
|
1577
|
+
file: move.to,
|
|
1578
|
+
reason: `builds a path from its own location (${own}) that the migration could not re-point, and it moved from "${move.from}"; check that path by hand`,
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
const dir = posix.dirname(move.from);
|
|
1582
|
+
if (dir === "." || ZONE_DIRS.has(dir.split("/")[0] ?? "") || inConfiguredFolder(move.from))
|
|
1583
|
+
continue;
|
|
1584
|
+
leaving.set(dir, [...(leaving.get(dir) ?? []), move]);
|
|
1585
|
+
}
|
|
1586
|
+
for (const [dir, group] of leaving) {
|
|
1587
|
+
const staying = files.filter((f) => posix.dirname(f) === dir && !moveMap.has(f) && !isTestFile(f) && !f.endsWith(".d.ts"));
|
|
1588
|
+
const first = group[0];
|
|
1589
|
+
if (staying.length === 0 || first === undefined)
|
|
1590
|
+
continue;
|
|
1591
|
+
const others = group.length === 1 ? "" : ` with ${group.length - 1} other file(s)`;
|
|
1592
|
+
const left = staying.length === 1
|
|
1593
|
+
? `"${staying[0]}" stays`
|
|
1594
|
+
: `${staying.length} files such as "${staying[0]}" stay`;
|
|
1595
|
+
issues.push({
|
|
1596
|
+
file: first.from,
|
|
1597
|
+
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`,
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
920
1600
|
// The server refuses data a loader or action returns without an output schema; only the author
|
|
921
1601
|
// knows which fields the browser may see.
|
|
922
1602
|
for (const [file, text] of final) {
|
|
@@ -925,14 +1605,43 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
925
1605
|
for (const { name, schema } of missingOutputSchemas(text)) {
|
|
926
1606
|
issues.push({
|
|
927
1607
|
file,
|
|
928
|
-
reason: `exports ${name === "action" ? "an" : "a"} ${name} but no ${schema}, so data it returns fails the request. Declare what the browser may see: export const ${schema} = t.object({ ... })`,
|
|
1608
|
+
reason: `exports ${name === "action" ? "an" : "a"} ${name} but no ${schema}, so data it returns fails the request. Declare what the browser may see: export const ${schema} = t.object({ ... }), or run \`nifra schemas --write\` after the migration to draft it from the ${name}'s return type`,
|
|
929
1609
|
});
|
|
930
1610
|
}
|
|
931
1611
|
}
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1612
|
+
// Tooling that lists folders or globs, not files: the import rewrite above never sees it.
|
|
1613
|
+
const tooling = [];
|
|
1614
|
+
const toolingWrites = new Map();
|
|
1615
|
+
const tsconfig = coverMovedFiles(ts, root, moves);
|
|
1616
|
+
if (tsconfig?.issue !== undefined)
|
|
1617
|
+
issues.push({ file: "tsconfig.json", reason: tsconfig.issue });
|
|
1618
|
+
if (tsconfig?.text !== undefined) {
|
|
1619
|
+
toolingWrites.set("tsconfig.json", tsconfig.text);
|
|
1620
|
+
tooling.push({ file: "tsconfig.json", change: `include += ${tsconfig.added.join(", ")}` });
|
|
1621
|
+
}
|
|
1622
|
+
for (const file of everyFile) {
|
|
1623
|
+
if (!TAILWIND_CONFIG.test(file))
|
|
1624
|
+
continue;
|
|
1625
|
+
const reason = staleContentGlobs(ts, readFileSync(join(root, file), "utf8"), moves);
|
|
1626
|
+
if (reason !== undefined)
|
|
1627
|
+
issues.push({ file, reason });
|
|
1628
|
+
}
|
|
1629
|
+
const gitignore = ignoreNifraState(root);
|
|
1630
|
+
if (gitignore !== undefined) {
|
|
1631
|
+
toolingWrites.set(".gitignore", gitignore);
|
|
1632
|
+
tooling.push({ file: ".gitignore", change: "ignore .nifra/ (dev and build state)" });
|
|
1633
|
+
}
|
|
1634
|
+
// `nifra check` reads the declaration statically; without it, a later routes/ or config would zone the app.
|
|
1635
|
+
const kind = api && createKindProbe(root)(root).kind === undefined ? declareApiKind(ts, root) : {};
|
|
1636
|
+
if (kind.issue !== undefined)
|
|
1637
|
+
issues.push({ file: "package.json", reason: kind.issue });
|
|
1638
|
+
if (kind.text !== undefined) {
|
|
1639
|
+
toolingWrites.set("package.json", kind.text);
|
|
1640
|
+
tooling.push({ file: "package.json", change: '"nifra": { "kind": "api" }' });
|
|
1641
|
+
}
|
|
935
1642
|
if (options.write === true) {
|
|
1643
|
+
for (const [file, text] of toolingWrites)
|
|
1644
|
+
writeFileSync(join(root, file), text);
|
|
936
1645
|
for (const [file, text] of final) {
|
|
937
1646
|
if (original.get(file) === text)
|
|
938
1647
|
continue;
|
|
@@ -940,43 +1649,221 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
940
1649
|
mkdirSync(dirname(path), { recursive: true });
|
|
941
1650
|
writeFileSync(path, text);
|
|
942
1651
|
}
|
|
1652
|
+
const vacated = [];
|
|
943
1653
|
for (const file of original.keys()) {
|
|
944
|
-
if (
|
|
945
|
-
|
|
1654
|
+
if (final.has(file))
|
|
1655
|
+
continue;
|
|
1656
|
+
rmSync(join(root, file), { force: true });
|
|
1657
|
+
vacated.push(file);
|
|
946
1658
|
}
|
|
947
1659
|
for (const { from, to } of moves) {
|
|
948
1660
|
if (original.has(from) || !existsSync(join(root, from)))
|
|
949
1661
|
continue;
|
|
950
1662
|
mkdirSync(dirname(join(root, to)), { recursive: true });
|
|
951
1663
|
renameSync(join(root, from), join(root, to));
|
|
1664
|
+
vacated.push(from);
|
|
952
1665
|
}
|
|
953
|
-
|
|
1666
|
+
pruneVacatedDirs(root, vacated);
|
|
954
1667
|
}
|
|
955
1668
|
return {
|
|
956
1669
|
ok: issues.length === 0,
|
|
957
1670
|
write: options.write === true,
|
|
1671
|
+
mode,
|
|
958
1672
|
splits: splits.sort((a, b) => codeUnitOrder(a.file, b.file)),
|
|
959
1673
|
moves,
|
|
960
1674
|
rewritten: rewritten.sort(),
|
|
1675
|
+
tooling,
|
|
961
1676
|
issues,
|
|
962
1677
|
};
|
|
963
1678
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1679
|
+
/** The app's `"nifra": { "zones": ... }`, so folders it already zones are left where they are. */
|
|
1680
|
+
function appZones(root) {
|
|
1681
|
+
const manifest = join(root, "package.json");
|
|
1682
|
+
if (!existsSync(manifest))
|
|
1683
|
+
return {};
|
|
1684
|
+
let parsed;
|
|
1685
|
+
try {
|
|
1686
|
+
parsed = JSON.parse(readFileSync(manifest, "utf8"));
|
|
1687
|
+
}
|
|
1688
|
+
catch {
|
|
1689
|
+
return {};
|
|
1690
|
+
}
|
|
1691
|
+
const nifra = typeof parsed === "object" && parsed !== null ? Reflect.get(parsed, "nifra") : undefined;
|
|
1692
|
+
const zones = typeof nifra === "object" && nifra !== null ? Reflect.get(nifra, "zones") : undefined;
|
|
1693
|
+
return zones === undefined ? {} : parseZoneConfig(zones, manifest);
|
|
1694
|
+
}
|
|
1695
|
+
/** Whether a tsconfig `include` entry takes in `file` (app-relative), the way tsc reads it. */
|
|
1696
|
+
function includes(pattern, file) {
|
|
1697
|
+
let spec = pattern.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
1698
|
+
if (spec === "" || spec === ".")
|
|
1699
|
+
return true;
|
|
1700
|
+
if (!/[*?]/.test(spec))
|
|
1701
|
+
return file === spec || file.startsWith(`${spec}/`);
|
|
1702
|
+
if (spec.endsWith("**"))
|
|
1703
|
+
spec = `${spec}/*`;
|
|
1704
|
+
else if (!/[*?.][^/]*$/.test(spec.slice(spec.lastIndexOf("/") + 1)))
|
|
1705
|
+
spec = `${spec}/**/*`;
|
|
1706
|
+
return new Bun.Glob(spec).match(file);
|
|
1707
|
+
}
|
|
1708
|
+
/**
|
|
1709
|
+
* Add each zone folder that receives moved TypeScript to tsconfig `include`, editing only that array
|
|
1710
|
+
* so comments and layout survive. A tsconfig without `include` (or with only `extends`) is left
|
|
1711
|
+
* alone: tsc's default, or the base config, decides, and `nifra check` reports any folder left out.
|
|
1712
|
+
*/
|
|
1713
|
+
function coverMovedFiles(ts, root, moves) {
|
|
1714
|
+
const path = join(root, "tsconfig.json");
|
|
1715
|
+
if (!existsSync(path))
|
|
1716
|
+
return undefined;
|
|
1717
|
+
const text = readFileSync(path, "utf8");
|
|
1718
|
+
const source = ts.parseJsonText(path, text);
|
|
1719
|
+
const top = source.statements[0]?.expression;
|
|
1720
|
+
if (top === undefined || !ts.isObjectLiteralExpression(top))
|
|
1721
|
+
return { added: [], issue: "is not a JSON object; add the zone folders to its include by hand" };
|
|
1722
|
+
const include = jsonProperty(ts, top, "include");
|
|
1723
|
+
if (include === undefined)
|
|
1724
|
+
return undefined;
|
|
1725
|
+
const array = include.initializer;
|
|
1726
|
+
if (!ts.isArrayLiteralExpression(array))
|
|
1727
|
+
return { added: [], issue: '"include" is not an array; add the zone folders to it by hand' };
|
|
1728
|
+
const patterns = array.elements.filter(ts.isStringLiteral).map((element) => element.text);
|
|
1729
|
+
const added = [];
|
|
1730
|
+
for (const { to } of moves) {
|
|
1731
|
+
if (!/\.(?:[cm]?ts|tsx)$/.test(to) || /\.d\.[cm]?ts$/.test(to))
|
|
1732
|
+
continue;
|
|
1733
|
+
const folder = to.split("/")[0] ?? to;
|
|
1734
|
+
if (added.includes(folder) || patterns.some((pattern) => includes(pattern, to)))
|
|
1735
|
+
continue;
|
|
1736
|
+
added.push(folder);
|
|
1737
|
+
}
|
|
1738
|
+
if (added.length === 0)
|
|
1739
|
+
return undefined;
|
|
1740
|
+
added.sort(codeUnitOrder);
|
|
1741
|
+
const quoted = added.map((folder) => JSON.stringify(folder));
|
|
1742
|
+
const last = array.elements[array.elements.length - 1];
|
|
1743
|
+
if (last === undefined) {
|
|
1744
|
+
const at = array.getStart(source) + 1;
|
|
1745
|
+
return { text: `${text.slice(0, at)}${quoted.join(", ")}${text.slice(at)}`, added };
|
|
1746
|
+
}
|
|
1747
|
+
let at = last.end;
|
|
1748
|
+
const trailing = /^\s*,/.exec(text.slice(at));
|
|
1749
|
+
if (trailing !== null)
|
|
1750
|
+
at += trailing[0].length;
|
|
1751
|
+
// A multi-line array gets one entry per line at the last element's indent, below any comment that
|
|
1752
|
+
// closes the last element's line; anything else gets them inline.
|
|
1753
|
+
if (text.slice(array.getStart(source), last.getStart(source)).includes("\n")) {
|
|
1754
|
+
const lineStart = text.lastIndexOf("\n", last.getStart(source)) + 1;
|
|
1755
|
+
const indent = text.slice(lineStart, last.getStart(source)).match(/^\s*/)?.[0] ?? "";
|
|
1756
|
+
const newline = text.indexOf("\n", at);
|
|
1757
|
+
const eol = newline === -1 ? text.length : newline;
|
|
1758
|
+
if (/^\s*(?:\/\/.*|\/\*.*\*\/\s*)?$/.test(text.slice(at, eol))) {
|
|
1759
|
+
const head = trailing !== null
|
|
1760
|
+
? text.slice(0, eol)
|
|
1761
|
+
: `${text.slice(0, last.end)},${text.slice(last.end, eol)}`;
|
|
1762
|
+
const entries = quoted.join(`,\n${indent}`);
|
|
1763
|
+
return {
|
|
1764
|
+
text: `${head}\n${indent}${entries}${trailing !== null ? "," : ""}${text.slice(eol)}`,
|
|
1765
|
+
added,
|
|
1766
|
+
};
|
|
975
1767
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1768
|
+
}
|
|
1769
|
+
const insert = trailing !== null ? ` ${quoted.join(", ")},` : `, ${quoted.join(", ")}`;
|
|
1770
|
+
return { text: `${text.slice(0, at)}${insert}${text.slice(at)}`, added };
|
|
1771
|
+
}
|
|
1772
|
+
const jsonProperty = (ts, object, key) => object.properties.find((property) => ts.isPropertyAssignment(property) &&
|
|
1773
|
+
ts.isStringLiteral(property.name) &&
|
|
1774
|
+
property.name.text === key);
|
|
1775
|
+
/** `text` with `entry` as the last property of `object`, on its own line when the object spans lines. */
|
|
1776
|
+
function insertProperty(source, text, object, entry) {
|
|
1777
|
+
const open = object.getStart(source);
|
|
1778
|
+
const last = object.properties[object.properties.length - 1];
|
|
1779
|
+
if (last === undefined)
|
|
1780
|
+
return `${text.slice(0, open)}{ ${entry} }${text.slice(object.end)}`;
|
|
1781
|
+
const start = last.getStart(source);
|
|
1782
|
+
if (!text.slice(open, start).includes("\n")) {
|
|
1783
|
+
return `${text.slice(0, last.end)}, ${entry}${text.slice(last.end)}`;
|
|
1784
|
+
}
|
|
1785
|
+
const indent = /^[ \t]*/.exec(text.slice(text.lastIndexOf("\n", start) + 1, start))?.[0] ?? "";
|
|
1786
|
+
return `${text.slice(0, last.end)},\n${indent}${entry}${text.slice(last.end)}`;
|
|
1787
|
+
}
|
|
1788
|
+
/** The app's package.json with `"nifra": { "kind": "api" }` added, editing only where it goes. */
|
|
1789
|
+
function declareApiKind(ts, root) {
|
|
1790
|
+
const path = join(root, "package.json");
|
|
1791
|
+
if (!existsSync(path)) {
|
|
1792
|
+
return {
|
|
1793
|
+
issue: 'there is none in the app; declare "nifra": { "kind": "api" } in the package.json that governs it',
|
|
1794
|
+
};
|
|
1795
|
+
}
|
|
1796
|
+
const text = readFileSync(path, "utf8");
|
|
1797
|
+
const source = ts.parseJsonText(path, text);
|
|
1798
|
+
const top = source.statements[0]?.expression;
|
|
1799
|
+
if (top === undefined || !ts.isObjectLiteralExpression(top))
|
|
1800
|
+
return { issue: 'is not a JSON object; add "nifra": { "kind": "api" } to it by hand' };
|
|
1801
|
+
const nifra = jsonProperty(ts, top, "nifra");
|
|
1802
|
+
if (nifra === undefined) {
|
|
1803
|
+
return { text: insertProperty(source, text, top, '"nifra": { "kind": "api" }') };
|
|
1804
|
+
}
|
|
1805
|
+
if (!ts.isObjectLiteralExpression(nifra.initializer))
|
|
1806
|
+
return { issue: '"nifra" is not an object; add "kind": "api" to it by hand' };
|
|
1807
|
+
return { text: insertProperty(source, text, nifra.initializer, '"kind": "api"') };
|
|
1808
|
+
}
|
|
1809
|
+
/** Tailwind `content` globs that matched a moved file at its old path and match nothing at its new one. */
|
|
1810
|
+
function staleContentGlobs(ts, text, moves) {
|
|
1811
|
+
const globs = [];
|
|
1812
|
+
const source = ts.createSourceFile("tailwind.config.ts", text, ts.ScriptTarget.Latest, true);
|
|
1813
|
+
const visit = (node) => {
|
|
1814
|
+
if (ts.isStringLiteralLike(node) && /[*/]/.test(node.text) && !node.text.startsWith("!"))
|
|
1815
|
+
globs.push(node.text.replace(/^\.\//, ""));
|
|
1816
|
+
ts.forEachChild(node, visit);
|
|
979
1817
|
};
|
|
980
|
-
|
|
1818
|
+
visit(source);
|
|
1819
|
+
const matchers = globs.map((glob) => ({ glob, matcher: new Bun.Glob(glob) }));
|
|
1820
|
+
const lost = moves.filter(({ from, to }) => matchers.some(({ matcher }) => matcher.match(from)) &&
|
|
1821
|
+
!matchers.some(({ matcher }) => matcher.match(to)));
|
|
1822
|
+
const first = lost[0];
|
|
1823
|
+
if (first === undefined)
|
|
1824
|
+
return undefined;
|
|
1825
|
+
const folders = [...new Set(lost.map(({ to }) => to.split("/")[0] ?? to))].sort(codeUnitOrder);
|
|
1826
|
+
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)`;
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* `.nifra/` holds generated types, dev state and the dev server's launch token; none of it belongs in
|
|
1830
|
+
* git. The new `.gitignore` text when it is not ignored yet, else `undefined`.
|
|
1831
|
+
*/
|
|
1832
|
+
function ignoreNifraState(root) {
|
|
1833
|
+
let exitCode = null;
|
|
1834
|
+
try {
|
|
1835
|
+
exitCode = Bun.spawnSync(["git", "check-ignore", "-q", ".nifra/types"], {
|
|
1836
|
+
cwd: root,
|
|
1837
|
+
stdout: "ignore",
|
|
1838
|
+
stderr: "ignore",
|
|
1839
|
+
}).exitCode;
|
|
1840
|
+
}
|
|
1841
|
+
catch {
|
|
1842
|
+
// No git installed: the same as no checkout.
|
|
1843
|
+
}
|
|
1844
|
+
if (exitCode === 0)
|
|
1845
|
+
return undefined;
|
|
1846
|
+
const path = join(root, ".gitignore");
|
|
1847
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
1848
|
+
// Outside a git checkout `check-ignore` cannot answer; the app's own file decides.
|
|
1849
|
+
if (exitCode !== 1 && /^\/?\.nifra(?:\/|\*|\/\*\*)?\s*$/m.test(current))
|
|
1850
|
+
return undefined;
|
|
1851
|
+
const separator = current === "" || current.endsWith("\n") ? "" : "\n";
|
|
1852
|
+
return `${current}${separator}.nifra/\n`;
|
|
1853
|
+
}
|
|
1854
|
+
/** Remove each directory the run emptied, then each parent that leaves empty. A directory that was
|
|
1855
|
+
* empty before the run is the app's own, not the migration's to remove. */
|
|
1856
|
+
function pruneVacatedDirs(root, vacated) {
|
|
1857
|
+
const dirs = new Set();
|
|
1858
|
+
for (const file of vacated)
|
|
1859
|
+
dirs.add(dirname(join(root, file)));
|
|
1860
|
+
// Deepest first, so a parent is judged after its emptied children are gone.
|
|
1861
|
+
for (const start of [...dirs].sort((a, b) => b.length - a.length)) {
|
|
1862
|
+
for (let dir = start; dir !== root && dir.startsWith(`${root}${sep}`); dir = dirname(dir)) {
|
|
1863
|
+
if (!existsSync(dir) || readdirSync(dir).length > 0)
|
|
1864
|
+
break;
|
|
1865
|
+
rmdirSync(dir);
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
981
1868
|
}
|
|
982
1869
|
//# sourceMappingURL=migrate-layout.js.map
|