@nifrajs/cli 5.0.0 → 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 +7 -2
- package/dist/assure-hydration.d.ts.map +1 -1
- package/dist/assure-hydration.js +4 -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 +24 -7
- package/dist/capabilities-tool.js.map +1 -1
- package/dist/check-diagnostics.d.ts +2 -0
- package/dist/check-diagnostics.d.ts.map +1 -1
- package/dist/check-diagnostics.js.map +1 -1
- package/dist/check-scan.d.ts +8 -2
- package/dist/check-scan.d.ts.map +1 -1
- package/dist/check-scan.js +43 -10
- package/dist/check-scan.js.map +1 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +39 -33
- package/dist/check.js.map +1 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +106 -48
- package/dist/cli.js.map +1 -1
- package/dist/client-module.d.ts +5 -3
- package/dist/client-module.d.ts.map +1 -1
- package/dist/client-module.js +34 -10
- package/dist/client-module.js.map +1 -1
- package/dist/command-catalog.d.ts +9 -1
- package/dist/command-catalog.d.ts.map +1 -1
- package/dist/command-catalog.js +67 -22
- package/dist/command-catalog.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/fix-recipes.d.ts +2 -0
- package/dist/fix-recipes.d.ts.map +1 -1
- package/dist/fix-recipes.js +93 -12
- package/dist/fix-recipes.js.map +1 -1
- package/dist/init-agents.d.ts +16 -0
- package/dist/init-agents.d.ts.map +1 -1
- package/dist/init-agents.js +3 -2
- 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/project-specifiers.d.ts +14 -1
- package/dist/internal/project-specifiers.d.ts.map +1 -1
- package/dist/internal/project-specifiers.js +54 -15
- package/dist/internal/project-specifiers.js.map +1 -1
- package/dist/internal/source-facts.d.ts +6 -1
- package/dist/internal/source-facts.d.ts.map +1 -1
- package/dist/internal/source-facts.js +11 -5
- package/dist/internal/source-facts.js.map +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 +9 -0
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +32 -1
- package/dist/introspect.js.map +1 -1
- package/dist/levels-tool.d.ts.map +1 -1
- package/dist/levels-tool.js +5 -8
- package/dist/levels-tool.js.map +1 -1
- package/dist/load.d.ts +2 -2
- package/dist/load.d.ts.map +1 -1
- package/dist/load.js +23 -3
- package/dist/load.js.map +1 -1
- package/dist/mcp-context.d.ts.map +1 -1
- package/dist/mcp-context.js +6 -21
- 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 +1 -0
- package/dist/mcp-exec.d.ts.map +1 -1
- package/dist/mcp-exec.js +8 -3
- package/dist/mcp-exec.js.map +1 -1
- package/dist/mcp-http.js +1 -1
- package/dist/mcp-io.d.ts +13 -0
- package/dist/mcp-io.d.ts.map +1 -1
- package/dist/mcp-io.js +23 -0
- package/dist/mcp-io.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.d.ts.map +1 -1
- package/dist/mcp.js +40 -17
- package/dist/mcp.js.map +1 -1
- package/dist/migrate-layout.d.ts +40 -1
- package/dist/migrate-layout.d.ts.map +1 -1
- package/dist/migrate-layout.js +1316 -228
- package/dist/migrate-layout.js.map +1 -1
- package/dist/output-schemas.d.ts +34 -0
- package/dist/output-schemas.d.ts.map +1 -1
- package/dist/output-schemas.js +132 -21
- package/dist/output-schemas.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/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/review.d.ts.map +1 -1
- package/dist/review.js +5 -0
- package/dist/review.js.map +1 -1
- package/dist/rules/codes.d.ts +3 -1
- package/dist/rules/codes.d.ts.map +1 -1
- package/dist/rules/codes.js +3 -1
- package/dist/rules/codes.js.map +1 -1
- package/dist/rules/data-guard.d.ts +6 -0
- package/dist/rules/data-guard.d.ts.map +1 -1
- package/dist/rules/data-guard.js +37 -0
- package/dist/rules/data-guard.js.map +1 -1
- package/dist/rules/legacy.d.ts.map +1 -1
- package/dist/rules/legacy.js +20 -1
- package/dist/rules/legacy.js.map +1 -1
- package/dist/rules/pages.d.ts +29 -3
- package/dist/rules/pages.d.ts.map +1 -1
- package/dist/rules/pages.js +236 -22
- package/dist/rules/pages.js.map +1 -1
- package/dist/rules/security.d.ts +3 -2
- package/dist/rules/security.d.ts.map +1 -1
- package/dist/rules/security.js +42 -11
- package/dist/rules/security.js.map +1 -1
- package/dist/rules/zones.d.ts.map +1 -1
- package/dist/rules/zones.js +31 -9
- package/dist/rules/zones.js.map +1 -1
- package/dist/stylex-migrate.d.ts.map +1 -1
- package/dist/stylex-migrate.js +127 -17
- package/dist/stylex-migrate.js.map +1 -1
- package/dist/test-tool.d.ts.map +1 -1
- package/dist/test-tool.js +3 -9
- package/dist/test-tool.js.map +1 -1
- package/dist/upgrade.d.ts +20 -5
- package/dist/upgrade.d.ts.map +1 -1
- package/dist/upgrade.js +86 -9
- package/dist/upgrade.js.map +1 -1
- package/dist/verification-plan.d.ts.map +1 -1
- package/dist/verification-plan.js +5 -2
- package/dist/verification-plan.js.map +1 -1
- package/dist/version-drift.d.ts.map +1 -1
- package/dist/version-drift.js +55 -3
- package/dist/version-drift.js.map +1 -1
- package/docs/examples.json +2 -2
- package/docs/llms-full.txt +481 -61
- package/docs/types.json +147 -70
- package/package.json +11 -11
- package/src/assure-hydration.ts +4 -1
- package/src/assure.ts +88 -10
- package/src/capabilities-tool.ts +33 -7
- package/src/check-diagnostics.ts +2 -0
- package/src/check-scan.ts +56 -9
- package/src/check.ts +55 -39
- package/src/cli.ts +104 -35
- package/src/client-module.ts +45 -9
- package/src/command-catalog.ts +77 -21
- package/src/dev-feed-tool.ts +112 -1
- package/src/fix-recipes.ts +101 -10
- package/src/init-agents.ts +4 -3
- package/src/internal/config-paths.ts +154 -0
- package/src/internal/installed-package.ts +15 -0
- package/src/internal/project-specifiers.ts +69 -16
- package/src/internal/source-facts.ts +23 -5
- package/src/internal/typescript-import.ts +5 -3
- package/src/introspect.ts +39 -1
- package/src/levels-tool.ts +7 -9
- package/src/load.ts +29 -3
- package/src/mcp-context.ts +5 -18
- package/src/mcp-delegate.ts +22 -10
- package/src/mcp-exec.ts +9 -2
- package/src/mcp-http.ts +1 -1
- package/src/mcp-io.ts +33 -0
- package/src/mcp-path.ts +8 -22
- package/src/mcp.ts +48 -19
- package/src/migrate-layout.ts +1496 -237
- package/src/output-schemas.ts +179 -23
- package/src/project-path.ts +44 -13
- package/src/release-verification.ts +20 -0
- package/src/review.ts +5 -0
- package/src/rules/codes.ts +3 -1
- package/src/rules/data-guard.ts +40 -0
- package/src/rules/legacy.ts +22 -1
- package/src/rules/pages.ts +266 -24
- package/src/rules/security.ts +49 -11
- package/src/rules/zones.ts +30 -7
- package/src/stylex-migrate.ts +117 -17
- package/src/test-tool.ts +3 -9
- package/src/upgrade.ts +96 -8
- package/src/verification-plan.ts +9 -2
- package/src/version-drift.ts +53 -2
package/src/migrate-layout.ts
CHANGED
|
@@ -12,16 +12,18 @@
|
|
|
12
12
|
* the server side -> `backend/`, both -> `shared/`. Imports through tsconfig `paths`/`baseUrl` and
|
|
13
13
|
* package.json `imports` count as the bundler reads them;
|
|
14
14
|
* 5. rewrites every import in the app to the new locations (test-runner mocks included), an alias
|
|
15
|
-
* through the same alias
|
|
16
|
-
*
|
|
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`;
|
|
17
19
|
* 6. adds the zone folders moved TypeScript lands in to tsconfig `include`, and `.nifra/` to
|
|
18
20
|
* `.gitignore`.
|
|
19
21
|
*
|
|
20
22
|
* Data files (JSON, YAML, CSV, ...) and folders the app zones in package.json stay where they are.
|
|
21
23
|
*
|
|
22
24
|
* It is a dry run unless `write` is set, and it reports what it could not decide (a frontend that uses
|
|
23
|
-
* a moved name at runtime, a helper both halves need, a loader with no output schema, an
|
|
24
|
-
*
|
|
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.
|
|
25
27
|
*/
|
|
26
28
|
import {
|
|
27
29
|
existsSync,
|
|
@@ -42,10 +44,13 @@ import { parseZoneConfig, type ZoneConfig } from "@nifrajs/web/zones"
|
|
|
42
44
|
import type * as TS from "typescript"
|
|
43
45
|
import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE, retiredRootFiles } from "./app-files.ts"
|
|
44
46
|
import { codeUnitOrder } from "./internal/code-unit-order.ts"
|
|
47
|
+
import { configPathReader, type StaticConfigPath } from "./internal/config-paths.ts"
|
|
45
48
|
import { createKindProbe, hasWebConfig } from "./internal/nifra-project.ts"
|
|
46
|
-
import { createSpecifierResolver } from "./internal/project-specifiers.ts"
|
|
49
|
+
import { createSpecifierResolver, isFile } from "./internal/project-specifiers.ts"
|
|
47
50
|
import { importTypeScript } from "./internal/typescript-import.ts"
|
|
51
|
+
import { type OutputSchemaEntry, planOutputSchemas } from "./output-schemas.ts"
|
|
48
52
|
import { missingOutputSchemas } from "./rules/data-guard.ts"
|
|
53
|
+
import { apiPrefixFix } from "./rules/pages.ts"
|
|
49
54
|
|
|
50
55
|
/** Exports only the server or the build reads: `@nifrajs/web`'s route-pair contract. */
|
|
51
56
|
const BACKEND_EXPORTS = BACKEND_ROUTE_EXPORTS
|
|
@@ -75,6 +80,8 @@ const TOOLING_FILE =
|
|
|
75
80
|
const SELF_LOCATION = /\bimport\.meta\.(?:dirname|dir|filename|url)\b|\b__(?:dirname|filename)\b/
|
|
76
81
|
const SKIP_DIRS = new Set(["node_modules", "dist", "build", "coverage", ".git", ".vite", "public"])
|
|
77
82
|
const ZONE_DIRS = new Set(["frontend", "backend", "shared", "routes", "public"])
|
|
83
|
+
type FolderSide = "frontend" | "backend" | "shared"
|
|
84
|
+
const FOLDER_SIDES: readonly FolderSide[] = ["frontend", "backend", "shared"]
|
|
78
85
|
/** Data other tools may read by path too; reported, never moved. Images and fonts still move. */
|
|
79
86
|
const DATA_FILE = /\.(?:json|jsonc|json5|ndjson|ya?ml|toml|csv|tsv|txt|xml|sql)$/
|
|
80
87
|
/** Test-runner calls that name a module by path, as an import does. */
|
|
@@ -91,13 +98,34 @@ const MOCK_CALLS: ReadonlySet<string> = new Set([
|
|
|
91
98
|
"jest.unstable_mockModule",
|
|
92
99
|
])
|
|
93
100
|
const TAILWIND_CONFIG = /^tailwind\.config\.[cm]?[jt]s$/
|
|
101
|
+
/** What a `x.test.ts` beside `x.<ext>` tests. */
|
|
102
|
+
const SUBJECT_EXTENSIONS = [
|
|
103
|
+
".ts",
|
|
104
|
+
".tsx",
|
|
105
|
+
".mts",
|
|
106
|
+
".cts",
|
|
107
|
+
".js",
|
|
108
|
+
".jsx",
|
|
109
|
+
".mjs",
|
|
110
|
+
".cjs",
|
|
111
|
+
".svelte",
|
|
112
|
+
".vue",
|
|
113
|
+
]
|
|
94
114
|
|
|
95
115
|
export interface LayoutMigrationIssue {
|
|
96
116
|
readonly file: string
|
|
97
117
|
readonly reason: string
|
|
98
118
|
}
|
|
99
119
|
|
|
120
|
+
/** A step left for after the migration that a command takes: it needs no decision, so `ok` ignores it. */
|
|
121
|
+
export interface LayoutMigrationFollowUp {
|
|
122
|
+
readonly file: string
|
|
123
|
+
readonly reason: string
|
|
124
|
+
readonly command: string
|
|
125
|
+
}
|
|
126
|
+
|
|
100
127
|
export interface LayoutMigrationResult {
|
|
128
|
+
/** No issue needs a decision; follow-ups may remain. */
|
|
101
129
|
readonly ok: boolean
|
|
102
130
|
readonly write: boolean
|
|
103
131
|
/** `"api"` for an app that serves no pages: only its backend entry moves, nothing is split or zoned. */
|
|
@@ -107,6 +135,8 @@ export interface LayoutMigrationResult {
|
|
|
107
135
|
readonly file: string
|
|
108
136
|
readonly backend: string
|
|
109
137
|
readonly moved: readonly string[]
|
|
138
|
+
/** The module beside the route both halves import constant data from, when the split made one. */
|
|
139
|
+
readonly shared?: string
|
|
110
140
|
}>
|
|
111
141
|
/** Files that moved, app-relative. */
|
|
112
142
|
readonly moves: ReadonlyArray<{ readonly from: string; readonly to: string }>
|
|
@@ -114,11 +144,24 @@ export interface LayoutMigrationResult {
|
|
|
114
144
|
readonly rewritten: readonly string[]
|
|
115
145
|
/** Tooling config the migration updated so it still covers what moved. */
|
|
116
146
|
readonly tooling: ReadonlyArray<{ readonly file: string; readonly change: string }>
|
|
147
|
+
/**
|
|
148
|
+
* Output schemas drafted for the backend halves the split wrote, from their return types:
|
|
149
|
+
* `missing` in a dry run, `generated` once written. Each is a draft the author reviews.
|
|
150
|
+
*/
|
|
151
|
+
readonly schemas: readonly OutputSchemaEntry[]
|
|
117
152
|
readonly issues: readonly LayoutMigrationIssue[]
|
|
153
|
+
/** Commands to run after the migration, each with what it settles. */
|
|
154
|
+
readonly followUps: readonly LayoutMigrationFollowUp[]
|
|
118
155
|
}
|
|
119
156
|
|
|
120
157
|
export interface LayoutMigrationOptions {
|
|
121
158
|
readonly write?: boolean
|
|
159
|
+
/**
|
|
160
|
+
* Leave the modules the layout would move into `frontend/`, `backend/` or `shared/` where they are,
|
|
161
|
+
* and list them in the app's `"nifra": { "zones": ... }` instead: the widest folder whose files all
|
|
162
|
+
* belong to one side, else the file itself. Route splits and the root entry moves still happen.
|
|
163
|
+
*/
|
|
164
|
+
readonly zones?: boolean
|
|
122
165
|
/** Inject the TypeScript compiler (tests); defaults to the CLI's own. */
|
|
123
166
|
readonly typescript?: typeof TS
|
|
124
167
|
}
|
|
@@ -128,6 +171,15 @@ type Files = Map<string, string>
|
|
|
128
171
|
|
|
129
172
|
const toPosix = (path: string): string => path.replaceAll("\\", "/")
|
|
130
173
|
|
|
174
|
+
/** A specifier without its `?query` or `#hash`; a leading `#` names a package.json import. Cut by
|
|
175
|
+
* index, since `/[?#].*$/` retries from every `#` and is quadratic on a long run of them. */
|
|
176
|
+
const withoutQuery = (specifier: string): string => {
|
|
177
|
+
const query = specifier.indexOf("?", 1)
|
|
178
|
+
const hash = specifier.indexOf("#", 1)
|
|
179
|
+
const cut = query === -1 ? hash : hash === -1 ? query : Math.min(query, hash)
|
|
180
|
+
return cut === -1 ? specifier : specifier.slice(0, cut)
|
|
181
|
+
}
|
|
182
|
+
|
|
131
183
|
/** Every first-party file under `root`, app-relative and posix. */
|
|
132
184
|
function listFiles(root: string): string[] {
|
|
133
185
|
const out: string[] = []
|
|
@@ -156,12 +208,29 @@ interface StatementInfo {
|
|
|
156
208
|
readonly exports: ReadonlyMap<string, readonly string[]>
|
|
157
209
|
readonly valueRefs: ReadonlySet<string>
|
|
158
210
|
readonly typeRefs: ReadonlySet<string>
|
|
211
|
+
/** A local `export { a, b as c }`: each entry, which the reachability of its own half follows. */
|
|
212
|
+
readonly listed: ReadonlyArray<ListedExport>
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
interface ListedExport {
|
|
216
|
+
readonly exported: string
|
|
217
|
+
readonly local: string
|
|
218
|
+
/** `export type { X }` or `export { type X }`: a read in a type position. */
|
|
219
|
+
readonly type: boolean
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The names one half reads, by position. */
|
|
223
|
+
interface Uses {
|
|
224
|
+
readonly value: ReadonlySet<string>
|
|
225
|
+
readonly type: ReadonlySet<string>
|
|
159
226
|
}
|
|
160
227
|
|
|
161
228
|
export interface SplitResult {
|
|
162
229
|
readonly frontend: string
|
|
163
230
|
/** `undefined` when the file exports nothing for the server. */
|
|
164
231
|
readonly backend: string | undefined
|
|
232
|
+
/** The module beside the route that holds constant data both halves import, when there is any. */
|
|
233
|
+
readonly shared?: string
|
|
165
234
|
readonly moved: readonly string[]
|
|
166
235
|
readonly issues: readonly string[]
|
|
167
236
|
}
|
|
@@ -247,6 +316,7 @@ function describeStatements(ts: typeof TS, sf: TS.SourceFile): StatementInfo[] {
|
|
|
247
316
|
return sf.statements.map((node) => {
|
|
248
317
|
const declares = new Set<string>()
|
|
249
318
|
const exports = new Map<string, string[]>()
|
|
319
|
+
const listed: ListedExport[] = []
|
|
250
320
|
const exported = hasModifier(ts, node, ts.SyntaxKind.ExportKeyword)
|
|
251
321
|
const isDefault = hasModifier(ts, node, ts.SyntaxKind.DefaultKeyword)
|
|
252
322
|
const addExport = (local: string, as: string): void => {
|
|
@@ -286,10 +356,10 @@ function describeStatements(ts: typeof TS, sf: TS.SourceFile): StatementInfo[] {
|
|
|
286
356
|
ts.isNamedExports(node.exportClause)
|
|
287
357
|
) {
|
|
288
358
|
for (const el of node.exportClause.elements) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
359
|
+
const local = (el.propertyName ?? el.name).text
|
|
360
|
+
addExport(node.moduleSpecifier ? `\0${el.name.text}` : local, el.name.text)
|
|
361
|
+
if (node.moduleSpecifier === undefined)
|
|
362
|
+
listed.push({ exported: el.name.text, local, type: node.isTypeOnly || el.isTypeOnly })
|
|
293
363
|
}
|
|
294
364
|
}
|
|
295
365
|
const refs = ts.isImportDeclaration(node)
|
|
@@ -306,34 +376,72 @@ function describeStatements(ts: typeof TS, sf: TS.SourceFile): StatementInfo[] {
|
|
|
306
376
|
exports,
|
|
307
377
|
valueRefs: refs.value,
|
|
308
378
|
typeRefs: refs.type,
|
|
379
|
+
listed,
|
|
309
380
|
}
|
|
310
381
|
})
|
|
311
382
|
}
|
|
312
383
|
|
|
313
|
-
/**
|
|
384
|
+
/**
|
|
385
|
+
* Rebuild an import keeping only the bindings `uses` names; `undefined` when none remain. Bindings
|
|
386
|
+
* read only in types become `import type`, so no bundler loads the module for them: always in a
|
|
387
|
+
* `fresh` module, and in the page only where the split narrowed the import.
|
|
388
|
+
*/
|
|
314
389
|
function filterImport(
|
|
315
390
|
ts: typeof TS,
|
|
316
391
|
sf: TS.SourceFile,
|
|
317
392
|
node: TS.ImportDeclaration,
|
|
318
|
-
|
|
393
|
+
uses: Uses,
|
|
394
|
+
fresh: boolean,
|
|
319
395
|
): string | undefined {
|
|
320
396
|
const clause = node.importClause
|
|
321
397
|
const spec = node.moduleSpecifier.getText(sf)
|
|
322
398
|
const attributes = node.attributes ? ` ${node.attributes.getText(sf)}` : ""
|
|
323
399
|
if (clause === undefined) return node.getFullText(sf)
|
|
324
|
-
|
|
325
|
-
|
|
400
|
+
interface Part {
|
|
401
|
+
readonly text: string
|
|
402
|
+
/** Without an inline `type`, for an `import type`. */
|
|
403
|
+
readonly bare: string
|
|
404
|
+
readonly typeOnly: boolean
|
|
405
|
+
}
|
|
406
|
+
const heads: Part[] = []
|
|
407
|
+
const named: Part[] = []
|
|
408
|
+
let total = 0
|
|
409
|
+
const add = (into: Part[], name: string, text: string, bare: string): void => {
|
|
410
|
+
total++
|
|
411
|
+
if (uses.value.has(name) || uses.type.has(name))
|
|
412
|
+
into.push({ text, bare, typeOnly: !uses.value.has(name) })
|
|
413
|
+
}
|
|
414
|
+
if (clause.name) add(heads, clause.name.text, clause.name.text, clause.name.text)
|
|
326
415
|
const bindings = clause.namedBindings
|
|
327
|
-
if (bindings && ts.isNamespaceImport(bindings)
|
|
328
|
-
|
|
416
|
+
if (bindings && ts.isNamespaceImport(bindings)) {
|
|
417
|
+
const text = `* as ${bindings.name.text}`
|
|
418
|
+
add(heads, bindings.name.text, text, text)
|
|
419
|
+
}
|
|
329
420
|
if (bindings && ts.isNamedImports(bindings)) {
|
|
330
|
-
const
|
|
331
|
-
|
|
421
|
+
for (const el of bindings.elements) {
|
|
422
|
+
const renamed = el.propertyName ? `${el.propertyName.getText(sf)} as ` : ""
|
|
423
|
+
add(named, el.name.text, el.getText(sf), `${renamed}${el.name.text}`)
|
|
424
|
+
}
|
|
332
425
|
}
|
|
333
|
-
|
|
334
|
-
|
|
426
|
+
const kept = [...heads, ...named]
|
|
427
|
+
if (kept.length === 0) return undefined
|
|
335
428
|
const leading = node.getFullText(sf).slice(0, node.getStart(sf) - node.getFullStart())
|
|
336
|
-
|
|
429
|
+
const statement = (typeOnly: boolean, body: string): string =>
|
|
430
|
+
`import ${typeOnly ? "type " : ""}${body} from ${spec}${attributes}${terminatorOf(sf, node)}`
|
|
431
|
+
const list = (parts: readonly Part[], text: (part: Part) => string): string[] =>
|
|
432
|
+
parts.length === 0 ? [] : [`{ ${parts.map(text).join(", ")} }`]
|
|
433
|
+
if (
|
|
434
|
+
!clause.isTypeOnly &&
|
|
435
|
+
node.attributes === undefined &&
|
|
436
|
+
(fresh || kept.length < total) &&
|
|
437
|
+
kept.every((part) => part.typeOnly)
|
|
438
|
+
) {
|
|
439
|
+
// `import type` takes one default, namespace or list, so each goes in its own statement.
|
|
440
|
+
const bodies = [...heads.map((part) => part.bare), ...list(named, (part) => part.bare)]
|
|
441
|
+
return `${leading}${bodies.map((body) => statement(true, body)).join("\n")}`
|
|
442
|
+
}
|
|
443
|
+
const body = [...heads.map((part) => part.text), ...list(named, (part) => part.text)]
|
|
444
|
+
return `${leading}${statement(clause.isTypeOnly, body.join(", "))}`
|
|
337
445
|
}
|
|
338
446
|
|
|
339
447
|
/** The `;` a statement was written with, so a rebuilt one keeps the file's style. */
|
|
@@ -363,6 +471,126 @@ const joinStatements = (texts: readonly string[]): string => {
|
|
|
363
471
|
return body.endsWith("\n") ? body : `${body}\n`
|
|
364
472
|
}
|
|
365
473
|
|
|
474
|
+
/** Options for {@link splitRouteSource}. */
|
|
475
|
+
export interface SplitOptions {
|
|
476
|
+
/**
|
|
477
|
+
* The file name of a module beside the route (`index.shared.ts`) to hold constant data both halves
|
|
478
|
+
* read, which each half then imports. Without it, that data is copied into each half.
|
|
479
|
+
*/
|
|
480
|
+
readonly sharedFile?: string
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const PURE_OBJECT_CALLS = new Set(["keys", "values", "entries", "freeze", "fromEntries"])
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Whether `node` builds constant data: literals, arrays and objects of it, operators over it,
|
|
487
|
+
* `Object.keys`-style reads, and the file's own top-level names, which go into `reads`. `undefined`
|
|
488
|
+
* when it is data; else the import it reads, or `""` for anything else (a call, a function, a global).
|
|
489
|
+
*/
|
|
490
|
+
function dataBlocker(
|
|
491
|
+
ts: typeof TS,
|
|
492
|
+
node: TS.Node,
|
|
493
|
+
isLocal: (name: string) => boolean,
|
|
494
|
+
reads: Set<string>,
|
|
495
|
+
): string | undefined {
|
|
496
|
+
const data = (n: TS.Node): string | undefined => {
|
|
497
|
+
if (
|
|
498
|
+
ts.isStringLiteral(n) ||
|
|
499
|
+
ts.isNumericLiteral(n) ||
|
|
500
|
+
ts.isBigIntLiteral(n) ||
|
|
501
|
+
ts.isNoSubstitutionTemplateLiteral(n) ||
|
|
502
|
+
n.kind === ts.SyntaxKind.TrueKeyword ||
|
|
503
|
+
n.kind === ts.SyntaxKind.FalseKeyword ||
|
|
504
|
+
n.kind === ts.SyntaxKind.NullKeyword
|
|
505
|
+
)
|
|
506
|
+
return undefined
|
|
507
|
+
if (ts.isIdentifier(n)) {
|
|
508
|
+
if (isLocal(n.text)) {
|
|
509
|
+
reads.add(n.text)
|
|
510
|
+
return undefined
|
|
511
|
+
}
|
|
512
|
+
return n.text === "undefined" ? undefined : n.text
|
|
513
|
+
}
|
|
514
|
+
const every = (nodes: readonly TS.Node[]): string | undefined => {
|
|
515
|
+
for (const child of nodes) {
|
|
516
|
+
const blocker = data(child)
|
|
517
|
+
if (blocker !== undefined) return blocker
|
|
518
|
+
}
|
|
519
|
+
return undefined
|
|
520
|
+
}
|
|
521
|
+
if (ts.isTemplateExpression(n)) return every(n.templateSpans.map((span) => span.expression))
|
|
522
|
+
if (ts.isPrefixUnaryExpression(n))
|
|
523
|
+
return n.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
524
|
+
n.operator === ts.SyntaxKind.MinusMinusToken
|
|
525
|
+
? ""
|
|
526
|
+
: data(n.operand)
|
|
527
|
+
if (ts.isBinaryExpression(n)) {
|
|
528
|
+
const operator = n.operatorToken.kind
|
|
529
|
+
const assigns =
|
|
530
|
+
operator >= ts.SyntaxKind.FirstAssignment && operator <= ts.SyntaxKind.LastAssignment
|
|
531
|
+
if (
|
|
532
|
+
assigns ||
|
|
533
|
+
operator === ts.SyntaxKind.CommaToken ||
|
|
534
|
+
operator === ts.SyntaxKind.InKeyword ||
|
|
535
|
+
operator === ts.SyntaxKind.InstanceOfKeyword
|
|
536
|
+
)
|
|
537
|
+
return ""
|
|
538
|
+
return every([n.left, n.right])
|
|
539
|
+
}
|
|
540
|
+
if (ts.isConditionalExpression(n)) return every([n.condition, n.whenTrue, n.whenFalse])
|
|
541
|
+
if (ts.isArrayLiteralExpression(n))
|
|
542
|
+
return every(
|
|
543
|
+
n.elements.flatMap((element) =>
|
|
544
|
+
ts.isOmittedExpression(element)
|
|
545
|
+
? []
|
|
546
|
+
: [ts.isSpreadElement(element) ? element.expression : element],
|
|
547
|
+
),
|
|
548
|
+
)
|
|
549
|
+
if (ts.isObjectLiteralExpression(n)) {
|
|
550
|
+
for (const property of n.properties) {
|
|
551
|
+
const blocker = ts.isPropertyAssignment(property)
|
|
552
|
+
? ((ts.isComputedPropertyName(property.name)
|
|
553
|
+
? data(property.name.expression)
|
|
554
|
+
: undefined) ?? data(property.initializer))
|
|
555
|
+
: ts.isShorthandPropertyAssignment(property)
|
|
556
|
+
? property.objectAssignmentInitializer === undefined
|
|
557
|
+
? data(property.name)
|
|
558
|
+
: ""
|
|
559
|
+
: ts.isSpreadAssignment(property)
|
|
560
|
+
? data(property.expression)
|
|
561
|
+
: ""
|
|
562
|
+
if (blocker !== undefined) return blocker
|
|
563
|
+
}
|
|
564
|
+
return undefined
|
|
565
|
+
}
|
|
566
|
+
if (
|
|
567
|
+
ts.isParenthesizedExpression(n) ||
|
|
568
|
+
ts.isAsExpression(n) ||
|
|
569
|
+
ts.isSatisfiesExpression(n) ||
|
|
570
|
+
ts.isNonNullExpression(n) ||
|
|
571
|
+
ts.isTypeAssertionExpression(n)
|
|
572
|
+
)
|
|
573
|
+
return data(n.expression)
|
|
574
|
+
if (ts.isPropertyAccessExpression(n))
|
|
575
|
+
return ts.isPrivateIdentifier(n.name) ? "" : data(n.expression)
|
|
576
|
+
if (ts.isElementAccessExpression(n)) return every([n.expression, n.argumentExpression])
|
|
577
|
+
if (ts.isCallExpression(n)) {
|
|
578
|
+
const callee = n.expression
|
|
579
|
+
const objectRead =
|
|
580
|
+
ts.isPropertyAccessExpression(callee) &&
|
|
581
|
+
ts.isIdentifier(callee.expression) &&
|
|
582
|
+
callee.expression.text === "Object" &&
|
|
583
|
+
!isLocal("Object") &&
|
|
584
|
+
PURE_OBJECT_CALLS.has(callee.name.text) &&
|
|
585
|
+
n.questionDotToken === undefined &&
|
|
586
|
+
n.typeArguments === undefined
|
|
587
|
+
return objectRead ? every(n.arguments) : ""
|
|
588
|
+
}
|
|
589
|
+
return ""
|
|
590
|
+
}
|
|
591
|
+
return data(node)
|
|
592
|
+
}
|
|
593
|
+
|
|
366
594
|
/**
|
|
367
595
|
* Split one route module's source into its frontend and backend halves. `backendSpecifier` is how the
|
|
368
596
|
* frontend refers to the backend half in an `import type` (`./index.backend.ts`).
|
|
@@ -372,6 +600,7 @@ export function splitRouteSource(
|
|
|
372
600
|
source: string,
|
|
373
601
|
fileName: string,
|
|
374
602
|
backendSpecifier: string,
|
|
603
|
+
options: SplitOptions = {},
|
|
375
604
|
): SplitResult {
|
|
376
605
|
const kind = fileName.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS
|
|
377
606
|
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, kind)
|
|
@@ -384,70 +613,177 @@ export function splitRouteSource(
|
|
|
384
613
|
if (moved.length === 0) return { frontend: source, backend: undefined, moved: [], issues: [] }
|
|
385
614
|
|
|
386
615
|
const declaredBy = new Map<string, StatementInfo>()
|
|
616
|
+
const importedBy = new Map<string, StatementInfo>()
|
|
387
617
|
for (const info of statements) {
|
|
388
|
-
|
|
389
|
-
for (const name of info.declares)
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
if (target !== undefined && !backendSet.has(target)) {
|
|
401
|
-
backendSet.add(target)
|
|
402
|
-
queue.push(target)
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
// An exported-name statement (`export { loader }`) reaches the declaration it names.
|
|
406
|
-
for (const local of info.exports.keys()) {
|
|
407
|
-
const target = declaredBy.get(local)
|
|
408
|
-
if (target !== undefined && !backendSet.has(target)) {
|
|
409
|
-
backendSet.add(target)
|
|
410
|
-
queue.push(target)
|
|
411
|
-
}
|
|
618
|
+
const into = ts.isImportDeclaration(info.node) ? importedBy : declaredBy
|
|
619
|
+
for (const name of info.declares) into.set(name, info)
|
|
620
|
+
}
|
|
621
|
+
const isTypeDeclaration = (info: StatementInfo): boolean =>
|
|
622
|
+
ts.isInterfaceDeclaration(info.node) || ts.isTypeAliasDeclaration(info.node)
|
|
623
|
+
/** What `info` reads for one half: an export list reads only the locals it exports there. */
|
|
624
|
+
const readsFor = (info: StatementInfo, backend: boolean): Uses => {
|
|
625
|
+
if (info.listed.length === 0) return { value: info.valueRefs, type: info.typeRefs }
|
|
626
|
+
const mine = info.listed.filter((entry) => BACKEND_EXPORTS.has(entry.exported) === backend)
|
|
627
|
+
return {
|
|
628
|
+
value: new Set(mine.filter((entry) => !entry.type).map((entry) => entry.local)),
|
|
629
|
+
type: new Set(mine.filter((entry) => entry.type).map((entry) => entry.local)),
|
|
412
630
|
}
|
|
413
631
|
}
|
|
414
|
-
|
|
415
|
-
// The frontend half: everything that is not a backend export statement, minus declarations that only
|
|
416
|
-
// the backend half needs.
|
|
417
632
|
const movedLocals = new Set<string>()
|
|
418
633
|
for (const info of statements) {
|
|
419
634
|
for (const [local, names] of info.exports)
|
|
420
635
|
if (names.some((name) => BACKEND_EXPORTS.has(name))) movedLocals.add(local)
|
|
421
636
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
// a statement with no declaration (a side effect) stays where it ran
|
|
429
|
-
(info.declares.size === 0 && info.exports.size === 0),
|
|
430
|
-
)
|
|
431
|
-
const fqueue = [...seed]
|
|
432
|
-
for (const info of fqueue) frontendKeep.add(info)
|
|
433
|
-
while (fqueue.length > 0) {
|
|
434
|
-
const info = fqueue.pop() as StatementInfo
|
|
435
|
-
for (const name of info.valueRefs) {
|
|
436
|
-
if (movedLocals.has(name) && !info.declares.has(name)) continue
|
|
637
|
+
|
|
638
|
+
/** Each half's statements. What `shared` holds, both import, so neither half keeps a copy. */
|
|
639
|
+
const partition = (
|
|
640
|
+
shared: ReadonlySet<StatementInfo>,
|
|
641
|
+
): { backendSet: Set<StatementInfo>; frontendKeep: Set<StatementInfo> } => {
|
|
642
|
+
const local = (name: string): StatementInfo | undefined => {
|
|
437
643
|
const target = declaredBy.get(name)
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
644
|
+
return target === undefined || shared.has(target) ? undefined : target
|
|
645
|
+
}
|
|
646
|
+
// The backend half: its export statements plus every top-level declaration they reach.
|
|
647
|
+
const backendSet = new Set<StatementInfo>()
|
|
648
|
+
const queue: StatementInfo[] = statements.filter(isBackendStatement)
|
|
649
|
+
for (const info of queue) backendSet.add(info)
|
|
650
|
+
while (queue.length > 0) {
|
|
651
|
+
const info = queue.pop() as StatementInfo
|
|
652
|
+
const reads = readsFor(info, true)
|
|
653
|
+
for (const name of [...reads.value, ...reads.type]) {
|
|
654
|
+
const target = local(name)
|
|
655
|
+
if (target !== undefined && !backendSet.has(target)) {
|
|
656
|
+
backendSet.add(target)
|
|
657
|
+
queue.push(target)
|
|
658
|
+
}
|
|
441
659
|
}
|
|
442
660
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
661
|
+
|
|
662
|
+
// The frontend half: everything that is not a backend export statement, minus declarations that
|
|
663
|
+
// only the backend half needs. An export list that also names backend exports keeps the rest.
|
|
664
|
+
const frontendKeep = new Set<StatementInfo>()
|
|
665
|
+
const seed = statements.filter(
|
|
666
|
+
(info) =>
|
|
667
|
+
!ts.isImportDeclaration(info.node) &&
|
|
668
|
+
!shared.has(info) &&
|
|
669
|
+
(!backendSet.has(info) ||
|
|
670
|
+
exportsOf(info).some((name) => !BACKEND_EXPORTS.has(name)) ||
|
|
671
|
+
// a statement with no declaration (a side effect) stays where it ran
|
|
672
|
+
(info.declares.size === 0 && info.exports.size === 0)),
|
|
673
|
+
)
|
|
674
|
+
const fqueue = [...seed]
|
|
675
|
+
for (const info of fqueue) frontendKeep.add(info)
|
|
676
|
+
while (fqueue.length > 0) {
|
|
677
|
+
const info = fqueue.pop() as StatementInfo
|
|
678
|
+
const reads = readsFor(info, false)
|
|
679
|
+
for (const name of reads.value) {
|
|
680
|
+
if (movedLocals.has(name) && !info.declares.has(name)) continue
|
|
681
|
+
const target = local(name)
|
|
682
|
+
if (target !== undefined && !frontendKeep.has(target) && !isBackendStatement(target)) {
|
|
683
|
+
frontendKeep.add(target)
|
|
684
|
+
fqueue.push(target)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
for (const name of reads.type) {
|
|
688
|
+
if (movedLocals.has(name)) continue
|
|
689
|
+
const target = local(name)
|
|
690
|
+
if (target !== undefined && !frontendKeep.has(target) && !isBackendStatement(target)) {
|
|
691
|
+
frontendKeep.add(target)
|
|
692
|
+
fqueue.push(target)
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
return { backendSet, frontendKeep }
|
|
697
|
+
}
|
|
698
|
+
let { backendSet, frontendKeep } = partition(new Set())
|
|
699
|
+
const copies = (): StatementInfo[] =>
|
|
700
|
+
[...frontendKeep].filter(
|
|
701
|
+
(info) =>
|
|
702
|
+
backendSet.has(info) &&
|
|
703
|
+
!isBackendStatement(info) &&
|
|
704
|
+
info.declares.size > 0 &&
|
|
705
|
+
!isTypeDeclaration(info),
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
// Constant data both halves read moves to a module beside the route, with the declarations it
|
|
709
|
+
// needs: one definition, which neither half's bundle can drift from.
|
|
710
|
+
const shared = new Set<StatementInfo>()
|
|
711
|
+
const sharedTypes = new Set<StatementInfo>()
|
|
712
|
+
const sharedImports = new Set<string>()
|
|
713
|
+
const unshared = new Map<StatementInfo, string>()
|
|
714
|
+
if (options.sharedFile !== undefined) {
|
|
715
|
+
interface Closure {
|
|
716
|
+
readonly values: Set<StatementInfo>
|
|
717
|
+
readonly types: Set<StatementInfo>
|
|
718
|
+
readonly imports: Set<string>
|
|
719
|
+
}
|
|
720
|
+
/** Why `info` cannot move to the shared module, or `undefined` once it and what it needs are in `closure`. */
|
|
721
|
+
const gather = (info: StatementInfo, closure: Closure): string | undefined => {
|
|
722
|
+
if (closure.values.has(info) || shared.has(info)) return undefined
|
|
723
|
+
const node = info.node
|
|
724
|
+
const reads = new Set<string>()
|
|
725
|
+
let blocker: string | undefined = ""
|
|
726
|
+
if (
|
|
727
|
+
!isBackendStatement(info) &&
|
|
728
|
+
ts.isVariableStatement(node) &&
|
|
729
|
+
(node.declarationList.flags & ts.NodeFlags.Const) !== 0 &&
|
|
730
|
+
!hasModifier(ts, node, ts.SyntaxKind.DeclareKeyword)
|
|
731
|
+
) {
|
|
732
|
+
blocker = undefined
|
|
733
|
+
for (const declaration of node.declarationList.declarations) {
|
|
734
|
+
blocker =
|
|
735
|
+
ts.isIdentifier(declaration.name) && declaration.initializer !== undefined
|
|
736
|
+
? dataBlocker(ts, declaration.initializer, (name) => declaredBy.has(name), reads)
|
|
737
|
+
: ""
|
|
738
|
+
if (blocker !== undefined) break
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (blocker !== undefined)
|
|
742
|
+
return blocker !== "" && importedBy.has(blocker)
|
|
743
|
+
? `it reads the import "${blocker}"`
|
|
744
|
+
: "it is not constant data"
|
|
745
|
+
closure.values.add(info)
|
|
746
|
+
for (const name of reads) {
|
|
747
|
+
const target = declaredBy.get(name)
|
|
748
|
+
const why = target === undefined ? undefined : gather(target, closure)
|
|
749
|
+
if (why !== undefined) return `it reads "${name}", which ${why.slice(3)}`
|
|
750
|
+
}
|
|
751
|
+
for (const name of info.typeRefs) {
|
|
752
|
+
const why = gatherType(name, closure)
|
|
753
|
+
if (why !== undefined) return why
|
|
754
|
+
}
|
|
755
|
+
return undefined
|
|
756
|
+
}
|
|
757
|
+
const gatherType = (name: string, closure: Closure): string | undefined => {
|
|
758
|
+
const local = declaredBy.get(name)
|
|
759
|
+
if (local === undefined) {
|
|
760
|
+
if (importedBy.has(name)) closure.imports.add(name)
|
|
761
|
+
return undefined
|
|
762
|
+
}
|
|
763
|
+
if (!isTypeDeclaration(local)) {
|
|
764
|
+
const why = gather(local, closure)
|
|
765
|
+
return why === undefined ? undefined : `its type reads "${name}", which ${why.slice(3)}`
|
|
766
|
+
}
|
|
767
|
+
if (closure.types.has(local) || sharedTypes.has(local)) return undefined
|
|
768
|
+
closure.types.add(local)
|
|
769
|
+
for (const ref of [...local.typeRefs, ...local.valueRefs]) {
|
|
770
|
+
const why = gatherType(ref, closure)
|
|
771
|
+
if (why !== undefined) return why
|
|
449
772
|
}
|
|
773
|
+
return undefined
|
|
450
774
|
}
|
|
775
|
+
for (const info of copies()) {
|
|
776
|
+
const closure: Closure = { values: new Set(), types: new Set(), imports: new Set() }
|
|
777
|
+
const why = gather(info, closure)
|
|
778
|
+
if (why !== undefined) {
|
|
779
|
+
unshared.set(info, why)
|
|
780
|
+
continue
|
|
781
|
+
}
|
|
782
|
+
for (const value of closure.values) shared.add(value)
|
|
783
|
+
for (const type of closure.types) sharedTypes.add(type)
|
|
784
|
+
for (const name of closure.imports) sharedImports.add(name)
|
|
785
|
+
}
|
|
786
|
+
if (shared.size > 0) ({ backendSet, frontendKeep } = partition(shared))
|
|
451
787
|
}
|
|
452
788
|
|
|
453
789
|
// A moved name the frontend still mentions: in a type it becomes `import type`; at runtime it is an
|
|
@@ -455,44 +791,88 @@ export function splitRouteSource(
|
|
|
455
791
|
const typeImports = new Set<string>()
|
|
456
792
|
for (const info of frontendKeep) {
|
|
457
793
|
if (isBackendStatement(info)) continue
|
|
794
|
+
const reads = readsFor(info, false)
|
|
458
795
|
for (const name of movedLocals) {
|
|
459
796
|
if (info.declares.has(name)) continue
|
|
460
|
-
if (
|
|
797
|
+
if (reads.value.has(name))
|
|
461
798
|
issues.push(`the frontend uses "${name}" at runtime, but it runs on the server only`)
|
|
462
|
-
if (
|
|
799
|
+
if (reads.type.has(name) || reads.value.has(name)) typeImports.add(name)
|
|
463
800
|
}
|
|
464
801
|
}
|
|
465
|
-
for (const info of
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
()
|
|
802
|
+
for (const info of copies()) {
|
|
803
|
+
const why = unshared.get(info)
|
|
804
|
+
issues.push(
|
|
805
|
+
`"${[...info.declares].join(", ")}" is needed by both halves and was copied into each${why === undefined ? "" : `, since ${why}`}; consider moving it to shared/`,
|
|
469
806
|
)
|
|
470
|
-
if (values.length > 0)
|
|
471
|
-
issues.push(
|
|
472
|
-
`"${values.join(", ")}" is needed by both halves and was copied into each; consider moving it to shared/`,
|
|
473
|
-
)
|
|
474
807
|
}
|
|
475
808
|
|
|
476
|
-
const
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
809
|
+
const uses = (infos: Iterable<StatementInfo>, backend: boolean): Uses => {
|
|
810
|
+
const value = new Set<string>()
|
|
811
|
+
const type = new Set<string>()
|
|
812
|
+
for (const info of infos) {
|
|
813
|
+
const reads = readsFor(info, backend)
|
|
814
|
+
for (const name of reads.value) value.add(name)
|
|
815
|
+
for (const name of reads.type) type.add(name)
|
|
816
|
+
}
|
|
817
|
+
return { value, type }
|
|
818
|
+
}
|
|
819
|
+
const backendUses = uses(backendSet, true)
|
|
820
|
+
const frontendUses = uses(frontendKeep, false)
|
|
821
|
+
|
|
822
|
+
const semi = statements.some((info) => terminatorOf(sf, info.node) !== "") ? ";" : ""
|
|
823
|
+
// The imports the split writes quote the way the file's own imports do.
|
|
824
|
+
const firstImport = statements.find((info) => ts.isImportDeclaration(info.node))?.node
|
|
825
|
+
const quote =
|
|
826
|
+
firstImport !== undefined && ts.isImportDeclaration(firstImport)
|
|
827
|
+
? (firstImport.moduleSpecifier.getText(sf)[0] ?? '"')
|
|
828
|
+
: '"'
|
|
829
|
+
const sharedNames = [...shared].flatMap((info) => [...info.declares])
|
|
830
|
+
const sharedSpecifier = (() => {
|
|
831
|
+
const file = options.sharedFile ?? ""
|
|
832
|
+
// The halves name the module the way the route names its other relative imports.
|
|
833
|
+
const relative = statements.flatMap((info) =>
|
|
834
|
+
ts.isImportDeclaration(info.node) &&
|
|
835
|
+
ts.isStringLiteral(info.node.moduleSpecifier) &&
|
|
836
|
+
info.node.moduleSpecifier.text.startsWith(".")
|
|
837
|
+
? [info.node.moduleSpecifier.text]
|
|
838
|
+
: [],
|
|
839
|
+
)
|
|
840
|
+
const extension = relative
|
|
841
|
+
.map((specifier) => /\.(ts|tsx|js|jsx)$/.exec(specifier)?.[1])
|
|
842
|
+
.find((found) => found !== undefined)
|
|
843
|
+
const suffix = extension === undefined ? "" : extension.startsWith("t") ? ".ts" : ".js"
|
|
844
|
+
return `./${file.replace(/\.ts$/, "")}${suffix}`
|
|
845
|
+
})()
|
|
846
|
+
const sharedImport = (half: Uses): string[] => {
|
|
847
|
+
const names = sharedNames
|
|
848
|
+
.filter((name) => half.value.has(name) || half.type.has(name))
|
|
849
|
+
.sort(codeUnitOrder)
|
|
850
|
+
if (names.length === 0) return []
|
|
851
|
+
const typeOnly = names.every((name) => !half.value.has(name))
|
|
852
|
+
return [
|
|
853
|
+
`\nimport ${typeOnly ? "type " : ""}{ ${names.join(", ")} } from ${quote}${sharedSpecifier}${quote}${semi}`,
|
|
854
|
+
]
|
|
855
|
+
}
|
|
856
|
+
// The page still exports what it exported, now from the shared module.
|
|
857
|
+
const reexported = [...shared].flatMap((info) =>
|
|
858
|
+
[...info.exports].flatMap(([local, names]) =>
|
|
859
|
+
names.map((name) => (name === local ? local : `${local} as ${name}`)),
|
|
860
|
+
),
|
|
861
|
+
)
|
|
484
862
|
|
|
485
863
|
const backendTexts: string[] = []
|
|
486
864
|
const frontendTexts: string[] = []
|
|
487
865
|
let afterImports = 0
|
|
866
|
+
let backendAfterImports = 0
|
|
488
867
|
for (const info of statements) {
|
|
489
868
|
const node = info.node
|
|
490
869
|
if (ts.isImportDeclaration(node)) {
|
|
491
|
-
const back = filterImport(ts, sf, node,
|
|
870
|
+
const back = filterImport(ts, sf, node, backendUses, true)
|
|
492
871
|
if (back !== undefined) backendTexts.push(node.importClause ? back : "")
|
|
493
|
-
const front = filterImport(ts, sf, node,
|
|
872
|
+
const front = filterImport(ts, sf, node, frontendUses, false)
|
|
494
873
|
if (front !== undefined) frontendTexts.push(front)
|
|
495
874
|
afterImports = frontendTexts.length
|
|
875
|
+
backendAfterImports = backendTexts.length
|
|
496
876
|
continue
|
|
497
877
|
}
|
|
498
878
|
if (isBackendStatement(info)) {
|
|
@@ -518,14 +898,17 @@ export function splitRouteSource(
|
|
|
518
898
|
if (frontendKeep.has(info)) frontendTexts.push(info.text)
|
|
519
899
|
}
|
|
520
900
|
// After the other imports: the nearest relative path sorts last.
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
0,
|
|
526
|
-
`\nimport type { ${[...typeImports].sort().join(", ")} } from "${backendSpecifier}"${semi}`,
|
|
901
|
+
const frontendImports = sharedImport(frontendUses)
|
|
902
|
+
if (reexported.length > 0)
|
|
903
|
+
frontendImports.push(
|
|
904
|
+
`\nexport { ${reexported.join(", ")} } from ${quote}${sharedSpecifier}${quote}${semi}`,
|
|
527
905
|
)
|
|
528
|
-
|
|
906
|
+
if (typeImports.size > 0)
|
|
907
|
+
frontendImports.push(
|
|
908
|
+
`\nimport type { ${[...typeImports].sort().join(", ")} } from ${quote}${backendSpecifier}${quote}${semi}`,
|
|
909
|
+
)
|
|
910
|
+
frontendTexts.splice(afterImports, 0, ...frontendImports)
|
|
911
|
+
backendTexts.splice(backendAfterImports, 0, ...sharedImport(backendUses))
|
|
529
912
|
// A JSX pragma counts only in the file's leading comments, and the backend half has no JSX.
|
|
530
913
|
const pragmas = (ts.getLeadingCommentRanges(source, 0) ?? [])
|
|
531
914
|
.map((range) => source.slice(range.pos, range.end))
|
|
@@ -536,12 +919,34 @@ export function splitRouteSource(
|
|
|
536
919
|
text,
|
|
537
920
|
)
|
|
538
921
|
const frontend = lift(joinStatements(frontendTexts))
|
|
539
|
-
|
|
922
|
+
let sharedText: string | undefined
|
|
923
|
+
if (shared.size > 0) {
|
|
924
|
+
const texts: string[] = []
|
|
925
|
+
const typeUses: Uses = { value: new Set(), type: sharedImports }
|
|
926
|
+
for (const info of statements) {
|
|
927
|
+
if (ts.isImportDeclaration(info.node)) {
|
|
928
|
+
const kept = filterImport(ts, sf, info.node, typeUses, true)
|
|
929
|
+
if (kept !== undefined && info.node.importClause !== undefined) texts.push(kept)
|
|
930
|
+
} else if (shared.has(info) || sharedTypes.has(info)) {
|
|
931
|
+
texts.push(withExport(ts, sf, info))
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
sharedText = lift(joinStatements(texts))
|
|
935
|
+
}
|
|
936
|
+
const result: SplitResult = {
|
|
540
937
|
frontend: pragmas.length > 0 ? `${pragmas.join("\n")}\n${frontend}` : frontend,
|
|
541
938
|
backend: lift(joinStatements(backendTexts)),
|
|
542
939
|
moved: [...new Set(moved)].sort(),
|
|
543
940
|
issues,
|
|
544
941
|
}
|
|
942
|
+
return sharedText === undefined ? result : { ...result, shared: sharedText }
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** A declaration the shared module holds is exported, so either half can import it. */
|
|
946
|
+
function withExport(ts: typeof TS, sf: TS.SourceFile, info: StatementInfo): string {
|
|
947
|
+
if (hasModifier(ts, info.node, ts.SyntaxKind.ExportKeyword)) return info.text
|
|
948
|
+
const at = info.node.getStart(sf) - info.node.getFullStart()
|
|
949
|
+
return `${info.text.slice(0, at)}export ${info.text.slice(at)}`
|
|
545
950
|
}
|
|
546
951
|
|
|
547
952
|
/** A helper the backend half copies keeps its declaration; an `export` on it would add a backend export. */
|
|
@@ -736,7 +1141,7 @@ function resolveIn(
|
|
|
736
1141
|
specifier: string,
|
|
737
1142
|
): string | undefined {
|
|
738
1143
|
if (!specifier.startsWith(".")) return undefined
|
|
739
|
-
const clean = specifier
|
|
1144
|
+
const clean = withoutQuery(specifier)
|
|
740
1145
|
const base = posix.normalize(posix.join(posix.dirname(from), clean))
|
|
741
1146
|
const candidates = [
|
|
742
1147
|
base,
|
|
@@ -806,15 +1211,24 @@ function rebaseOwnPaths(
|
|
|
806
1211
|
const quote = node.getText(sf)[0] ?? '"'
|
|
807
1212
|
return `${quote}${value}${quote}`
|
|
808
1213
|
}
|
|
809
|
-
// The path `relative` names from the module's old folder, named from its new folder
|
|
810
|
-
|
|
811
|
-
|
|
1214
|
+
// The path `relative` names from the module's old folder, named from its new folder; `undefined`
|
|
1215
|
+
// when neither moved, so a module that stays keeps its spelling.
|
|
1216
|
+
const rebased = (relative: string): string | undefined => {
|
|
1217
|
+
const before = posix.normalize(posix.join(posix.dirname(from), relative))
|
|
1218
|
+
const target = locate(before)
|
|
1219
|
+
if (from === to && target === before) return undefined
|
|
812
1220
|
const next = posix.relative(posix.dirname(to), target) || "."
|
|
813
1221
|
return relative.endsWith("/") && next !== "." ? `${next}/` : next
|
|
814
1222
|
}
|
|
815
|
-
const follow = (
|
|
1223
|
+
const follow = (
|
|
1224
|
+
anchor: TS.Node,
|
|
1225
|
+
start: number,
|
|
1226
|
+
end: number,
|
|
1227
|
+
replacement: string | undefined,
|
|
1228
|
+
): void => {
|
|
816
1229
|
followed.push([anchor.getStart(sf), anchor.getEnd()])
|
|
817
|
-
if (text.slice(start, end) !== replacement)
|
|
1230
|
+
if (replacement !== undefined && text.slice(start, end) !== replacement)
|
|
1231
|
+
edits.push({ start, end, text: replacement })
|
|
818
1232
|
}
|
|
819
1233
|
|
|
820
1234
|
const visit = (node: TS.Node): void => {
|
|
@@ -832,8 +1246,12 @@ function rebaseOwnPaths(
|
|
|
832
1246
|
!(callee.endsWith("resolve") && parts.some((part) => part.text.startsWith("/")))
|
|
833
1247
|
) {
|
|
834
1248
|
const next = rebased(posix.join(...parts.map((part) => part.text)))
|
|
835
|
-
const
|
|
836
|
-
|
|
1249
|
+
const replacement =
|
|
1250
|
+
next === undefined
|
|
1251
|
+
? undefined
|
|
1252
|
+
: (parts.length === 1 ? [next] : next.split("/").filter((s) => s !== ""))
|
|
1253
|
+
.map((segment) => quoted(first, segment))
|
|
1254
|
+
.join(", ")
|
|
837
1255
|
follow(dir, first.getStart(sf), last.getEnd(), replacement)
|
|
838
1256
|
return
|
|
839
1257
|
}
|
|
@@ -850,7 +1268,7 @@ function rebaseOwnPaths(
|
|
|
850
1268
|
node.left,
|
|
851
1269
|
node.right.getStart(sf),
|
|
852
1270
|
node.right.getEnd(),
|
|
853
|
-
quoted(node.right, `/${next}`),
|
|
1271
|
+
next === undefined ? undefined : quoted(node.right, `/${next}`),
|
|
854
1272
|
)
|
|
855
1273
|
return
|
|
856
1274
|
}
|
|
@@ -858,7 +1276,8 @@ function rebaseOwnPaths(
|
|
|
858
1276
|
const span = node.templateSpans[0]
|
|
859
1277
|
if (span !== undefined && isOwnDir(span.expression) && span.literal.text.startsWith("/")) {
|
|
860
1278
|
const next = rebased(span.literal.text.slice(1) || ".")
|
|
861
|
-
const replacement =
|
|
1279
|
+
const replacement =
|
|
1280
|
+
next === undefined ? undefined : `\`\${${span.expression.getText(sf)}}/${next}\``
|
|
862
1281
|
follow(span.expression, node.getStart(sf), node.getEnd(), replacement)
|
|
863
1282
|
return
|
|
864
1283
|
}
|
|
@@ -872,7 +1291,8 @@ function rebaseOwnPaths(
|
|
|
872
1291
|
const path = node.arguments[0]
|
|
873
1292
|
if (isLiteral(path) && !/^(?:\/|[a-z][a-z\d+.-]*:)/i.test(path.text)) {
|
|
874
1293
|
const next = rebased(path.text)
|
|
875
|
-
const replacement =
|
|
1294
|
+
const replacement =
|
|
1295
|
+
next === undefined ? undefined : quoted(path, next.startsWith("..") ? next : `./${next}`)
|
|
876
1296
|
follow(node.arguments[1], path.getStart(sf), path.getEnd(), replacement)
|
|
877
1297
|
return
|
|
878
1298
|
}
|
|
@@ -898,7 +1318,7 @@ function respecify(
|
|
|
898
1318
|
newFrom: string,
|
|
899
1319
|
newTarget: string,
|
|
900
1320
|
): string {
|
|
901
|
-
const query =
|
|
1321
|
+
const query = original.slice(withoutQuery(original).length)
|
|
902
1322
|
const clean = original.slice(0, original.length - query.length)
|
|
903
1323
|
let target = newTarget
|
|
904
1324
|
const writtenExt = extname(clean)
|
|
@@ -928,16 +1348,16 @@ function rebase(specifier: string, from: string, newFrom: string): string | unde
|
|
|
928
1348
|
}
|
|
929
1349
|
|
|
930
1350
|
/** A split route as an importer sees it: the names that left it, and how this importer spells the
|
|
931
|
-
* backend half they went to
|
|
1351
|
+
* backend half they went to. */
|
|
932
1352
|
interface SplitTarget {
|
|
933
1353
|
readonly backend: string
|
|
934
1354
|
readonly moved: ReadonlySet<string>
|
|
935
|
-
readonly specifier: string
|
|
1355
|
+
readonly specifier: string
|
|
936
1356
|
}
|
|
937
1357
|
|
|
938
1358
|
/** `specifier` re-pointed from a split route to its backend half, in the author's extension style. */
|
|
939
1359
|
function backendSpecifier(specifier: string, route: string, backend: string, from: string): string {
|
|
940
|
-
const query =
|
|
1360
|
+
const query = specifier.slice(withoutQuery(specifier).length)
|
|
941
1361
|
const written = extname(specifier.slice(0, specifier.length - query.length))
|
|
942
1362
|
const stem = backend.slice(0, -extname(backend).length)
|
|
943
1363
|
const target =
|
|
@@ -953,8 +1373,7 @@ function backendSpecifier(specifier: string, route: string, backend: string, fro
|
|
|
953
1373
|
/**
|
|
954
1374
|
* Point an importer's named imports of a split route's moved exports (`loader`, `action`, ...) at the
|
|
955
1375
|
* backend half, splitting a statement that also names what stayed. What cannot be rewritten (a
|
|
956
|
-
* namespace or dynamic import that reads a moved name, `export
|
|
957
|
-
* returned as an issue.
|
|
1376
|
+
* namespace or dynamic import that reads a moved name, `export *`) is returned as an issue.
|
|
958
1377
|
*/
|
|
959
1378
|
function redirectMovedImports(
|
|
960
1379
|
ts: typeof TS,
|
|
@@ -1010,12 +1429,6 @@ function redirectMovedImports(
|
|
|
1010
1429
|
split.moved.has((el.propertyName ?? el.name).text)
|
|
1011
1430
|
const moved = elements.filter(isMoved)
|
|
1012
1431
|
if (moved.length === 0) return
|
|
1013
|
-
if (split.specifier === undefined) {
|
|
1014
|
-
issues.push(
|
|
1015
|
-
`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`,
|
|
1016
|
-
)
|
|
1017
|
-
return
|
|
1018
|
-
}
|
|
1019
1432
|
redirects.push({
|
|
1020
1433
|
names: moved.map((el) => (el.propertyName ?? el.name).text),
|
|
1021
1434
|
from,
|
|
@@ -1049,14 +1462,29 @@ function redirectMovedImports(
|
|
|
1049
1462
|
node.arguments[0] !== undefined &&
|
|
1050
1463
|
ts.isStringLiteralLike(node.arguments[0])
|
|
1051
1464
|
) {
|
|
1052
|
-
const
|
|
1465
|
+
const literal = node.arguments[0]
|
|
1466
|
+
const from = literal.text
|
|
1053
1467
|
const split = splitOf(from)
|
|
1054
1468
|
const reads =
|
|
1055
1469
|
split === undefined ? [] : mentioned(split, (name) => new RegExp(`\\b${name}\\b`))
|
|
1056
|
-
if (split !== undefined && reads.length > 0)
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1470
|
+
if (split !== undefined && reads.length > 0) {
|
|
1471
|
+
const names = dynamicReads(ts, sf, node)
|
|
1472
|
+
if (
|
|
1473
|
+
names !== undefined &&
|
|
1474
|
+
names.length > 0 &&
|
|
1475
|
+
names.every((name) => split.moved.has(name))
|
|
1476
|
+
) {
|
|
1477
|
+
edits.push({
|
|
1478
|
+
start: literal.getStart(sf) + 1,
|
|
1479
|
+
end: literal.getEnd() - 1,
|
|
1480
|
+
text: split.specifier,
|
|
1481
|
+
})
|
|
1482
|
+
redirects.push({ names, from, to: split.specifier })
|
|
1483
|
+
} else
|
|
1484
|
+
issues.push(
|
|
1485
|
+
`loads "${from}" with import() and mentions ${quoted(reads)}, which moved to "${split.backend}"; load the backend half for them by hand`,
|
|
1486
|
+
)
|
|
1487
|
+
}
|
|
1060
1488
|
}
|
|
1061
1489
|
ts.forEachChild(node, visit)
|
|
1062
1490
|
}
|
|
@@ -1067,6 +1495,193 @@ function redirectMovedImports(
|
|
|
1067
1495
|
return { text: out, issues, redirects }
|
|
1068
1496
|
}
|
|
1069
1497
|
|
|
1498
|
+
/**
|
|
1499
|
+
* The exports code reads from what `import()` loads: off the awaited module (`(await import(x)).a`),
|
|
1500
|
+
* through a destructuring, or through a variable used only as `mod.a`. `undefined` when the module
|
|
1501
|
+
* goes anywhere else, so its reads cannot be listed.
|
|
1502
|
+
*/
|
|
1503
|
+
function dynamicReads(
|
|
1504
|
+
ts: typeof TS,
|
|
1505
|
+
sf: TS.SourceFile,
|
|
1506
|
+
call: TS.CallExpression,
|
|
1507
|
+
): string[] | undefined {
|
|
1508
|
+
let node: TS.Node = call
|
|
1509
|
+
if (!ts.isAwaitExpression(node.parent)) return undefined
|
|
1510
|
+
node = node.parent
|
|
1511
|
+
while (ts.isParenthesizedExpression(node.parent)) node = node.parent
|
|
1512
|
+
const parent = node.parent
|
|
1513
|
+
if (ts.isPropertyAccessExpression(parent) && parent.expression === node) return [parent.name.text]
|
|
1514
|
+
if (!ts.isVariableDeclaration(parent) || parent.initializer !== node) return undefined
|
|
1515
|
+
const binding = parent.name
|
|
1516
|
+
if (ts.isObjectBindingPattern(binding)) {
|
|
1517
|
+
const names: string[] = []
|
|
1518
|
+
for (const element of binding.elements) {
|
|
1519
|
+
const key = element.propertyName ?? element.name
|
|
1520
|
+
if (
|
|
1521
|
+
element.dotDotDotToken !== undefined ||
|
|
1522
|
+
!(ts.isIdentifier(key) || ts.isStringLiteral(key))
|
|
1523
|
+
)
|
|
1524
|
+
return undefined
|
|
1525
|
+
names.push(key.text)
|
|
1526
|
+
}
|
|
1527
|
+
return names
|
|
1528
|
+
}
|
|
1529
|
+
if (!ts.isIdentifier(binding)) return undefined
|
|
1530
|
+
const names = new Set<string>()
|
|
1531
|
+
let escapes = false
|
|
1532
|
+
const visit = (current: TS.Node): void => {
|
|
1533
|
+
if (ts.isIdentifier(current) && current.text === binding.text && current !== binding) {
|
|
1534
|
+
const owner = current.parent
|
|
1535
|
+
if (ts.isPropertyAccessExpression(owner) && owner.expression === current)
|
|
1536
|
+
names.add(owner.name.text)
|
|
1537
|
+
else if (
|
|
1538
|
+
!(
|
|
1539
|
+
(ts.isPropertyAccessExpression(owner) ||
|
|
1540
|
+
ts.isPropertyAssignment(owner) ||
|
|
1541
|
+
ts.isPropertyDeclaration(owner) ||
|
|
1542
|
+
ts.isMethodDeclaration(owner)) &&
|
|
1543
|
+
owner.name === current
|
|
1544
|
+
)
|
|
1545
|
+
)
|
|
1546
|
+
escapes = true
|
|
1547
|
+
}
|
|
1548
|
+
ts.forEachChild(current, visit)
|
|
1549
|
+
}
|
|
1550
|
+
visit(sf)
|
|
1551
|
+
return escapes ? undefined : [...names].sort(codeUnitOrder)
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/** A `workspaces` glob as a matcher over package-relative folder paths. */
|
|
1555
|
+
function workspacePattern(glob: string): RegExp {
|
|
1556
|
+
const source = glob
|
|
1557
|
+
.replace(/^\.\//, "")
|
|
1558
|
+
.replace(/\/+$/, "")
|
|
1559
|
+
.split("/")
|
|
1560
|
+
.map((segment) =>
|
|
1561
|
+
segment === "**"
|
|
1562
|
+
? "(?:[^/]+/)*?[^/]*"
|
|
1563
|
+
: segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*"),
|
|
1564
|
+
)
|
|
1565
|
+
.join("/")
|
|
1566
|
+
return new RegExp(`^${source}$`)
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* The workspace an app belongs to: the nearest folder above it whose package.json declares
|
|
1571
|
+
* `workspaces` that include the app, and the package folders those globs (less any `!` exclusion)
|
|
1572
|
+
* name, other than the app and any folder that holds it. `undefined` outside one.
|
|
1573
|
+
*/
|
|
1574
|
+
function workspaceSiblings(root: string): readonly string[] | undefined {
|
|
1575
|
+
for (let dir = dirname(root), depth = 0; depth < 8; dir = dirname(dir), depth += 1) {
|
|
1576
|
+
let declared: unknown
|
|
1577
|
+
try {
|
|
1578
|
+
declared = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).workspaces
|
|
1579
|
+
} catch {
|
|
1580
|
+
declared = undefined
|
|
1581
|
+
}
|
|
1582
|
+
const listed = Array.isArray(declared)
|
|
1583
|
+
? declared
|
|
1584
|
+
: typeof declared === "object" && declared !== null
|
|
1585
|
+
? Reflect.get(declared, "packages")
|
|
1586
|
+
: undefined
|
|
1587
|
+
if (Array.isArray(listed)) {
|
|
1588
|
+
const globs = listed.filter((glob): glob is string => typeof glob === "string")
|
|
1589
|
+
const include = globs.filter((glob) => !glob.startsWith("!")).map(workspacePattern)
|
|
1590
|
+
const exclude = globs
|
|
1591
|
+
.filter((glob) => glob.startsWith("!"))
|
|
1592
|
+
.map((glob) => workspacePattern(glob.slice(1)))
|
|
1593
|
+
const member = (rel: string): boolean =>
|
|
1594
|
+
include.some((pattern) => pattern.test(rel)) &&
|
|
1595
|
+
!exclude.some((pattern) => pattern.test(rel))
|
|
1596
|
+
// The nearest declaration decides, as package managers read it.
|
|
1597
|
+
if (!member(toPosix(relative(dir, root)))) return undefined
|
|
1598
|
+
const packages: string[] = []
|
|
1599
|
+
const walk = (folder: string, depth: number): void => {
|
|
1600
|
+
for (const entry of readdirSync(folder, { withFileTypes: true })) {
|
|
1601
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || SKIP_DIRS.has(entry.name))
|
|
1602
|
+
continue
|
|
1603
|
+
const full = join(folder, entry.name)
|
|
1604
|
+
const rel = toPosix(relative(dir, full))
|
|
1605
|
+
if (exclude.some((pattern) => pattern.test(rel))) continue
|
|
1606
|
+
if (member(rel) && existsSync(join(full, "package.json"))) {
|
|
1607
|
+
const inside = toPosix(relative(full, root))
|
|
1608
|
+
if (inside.startsWith("../") || isAbsolute(inside)) packages.push(full)
|
|
1609
|
+
continue
|
|
1610
|
+
}
|
|
1611
|
+
if (depth < 4) walk(full, depth + 1)
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
walk(dir, 0)
|
|
1615
|
+
return packages.sort()
|
|
1616
|
+
}
|
|
1617
|
+
if (dirname(dir) === dir) return undefined
|
|
1618
|
+
}
|
|
1619
|
+
return undefined
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/**
|
|
1623
|
+
* Where code in a workspace sibling names an app file that moves: a relative import into the app, or
|
|
1624
|
+
* the file's path in a string or command (`bun -e 'import("./src/x.ts")'` with the app as its working
|
|
1625
|
+
* directory). Only files that mention the app's folder or package name are read further, and a path
|
|
1626
|
+
* the sibling's own package holds is its own file. Report-only: the migration never edits a sibling.
|
|
1627
|
+
*/
|
|
1628
|
+
function siblingReferences(
|
|
1629
|
+
ts: typeof TS,
|
|
1630
|
+
root: string,
|
|
1631
|
+
pkg: string,
|
|
1632
|
+
file: string,
|
|
1633
|
+
text: string,
|
|
1634
|
+
moves: ReadonlyMap<string, string>,
|
|
1635
|
+
known: ReadonlySet<string>,
|
|
1636
|
+
ownName: string | undefined,
|
|
1637
|
+
): Array<{ readonly from: string; readonly to: string; readonly line: number }> {
|
|
1638
|
+
const abs = join(pkg, file)
|
|
1639
|
+
// A process started in the app (`cwd: \`${repo}apps/web\``) reads its paths from the app root.
|
|
1640
|
+
const runsInApp = new RegExp(
|
|
1641
|
+
`\\bcwd\\s*:[^,\\n]*\\b${escapeRegExp(posix.basename(toPosix(root)))}\\b`,
|
|
1642
|
+
).test(text)
|
|
1643
|
+
const found = new Map<string, { from: string; to: string; line: number }>()
|
|
1644
|
+
const report = (from: string, index: number): void => {
|
|
1645
|
+
const to = moves.get(from)
|
|
1646
|
+
if (to === undefined || found.has(from)) return
|
|
1647
|
+
found.set(from, { from, to, line: text.slice(0, index).split("\n").length })
|
|
1648
|
+
}
|
|
1649
|
+
if (/\.(?:[cm]?[jt]sx?)$/.test(file)) {
|
|
1650
|
+
for (const { specifier } of importsOf(ts, file, text)) {
|
|
1651
|
+
const clean = withoutQuery(specifier)
|
|
1652
|
+
const inApp = clean.startsWith(".")
|
|
1653
|
+
? toPosix(relative(root, resolve(dirname(abs), clean)))
|
|
1654
|
+
: ownName !== undefined && clean.startsWith(`${ownName}/`)
|
|
1655
|
+
? clean.slice(ownName.length + 1)
|
|
1656
|
+
: undefined
|
|
1657
|
+
if (inApp === undefined || inApp.startsWith("../") || isAbsolute(inApp)) continue
|
|
1658
|
+
const target = resolveIn(known, "index.ts", `./${inApp}`)
|
|
1659
|
+
if (target !== undefined) report(target, text.indexOf(specifier))
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
for (const from of moves.keys()) {
|
|
1663
|
+
const stem = SOURCE_FILE.test(from)
|
|
1664
|
+
? from.slice(0, from.length - extname(from).length)
|
|
1665
|
+
: undefined
|
|
1666
|
+
const pattern = new RegExp(
|
|
1667
|
+
`(?<![\\w.-])(?:\\./)?(?:${escapeRegExp(from)}(?![\\w])${stem === undefined ? "" : `|${escapeRegExp(stem)}(?=["'\`])`})`,
|
|
1668
|
+
"g",
|
|
1669
|
+
)
|
|
1670
|
+
for (const match of text.matchAll(pattern)) {
|
|
1671
|
+
// `packages/x/src/a.ts` names another package's `src/a.ts`; only a prefix that reaches the
|
|
1672
|
+
// app counts, and a path the sibling holds itself is its own.
|
|
1673
|
+
const prefix = /(?:[\w.@-]+\/)+$/.exec(text.slice(0, match.index))?.[0] ?? ""
|
|
1674
|
+
const bases = prefix === "" ? [] : [dirname(abs), pkg, dirname(pkg), dirname(dirname(pkg))]
|
|
1675
|
+
if (prefix !== "" && !bases.some((base) => resolve(base, prefix) === root)) continue
|
|
1676
|
+
const own = existsSync(join(pkg, from)) || existsSync(join(dirname(abs), from))
|
|
1677
|
+
if (prefix === "" && own && !runsInApp) continue
|
|
1678
|
+
report(from, match.index)
|
|
1679
|
+
break
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
return [...found.values()]
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1070
1685
|
/** The package folder an app without its own package.json sits in (`--dir src`), at most two levels
|
|
1071
1686
|
* up and never a workspace root, so the scan beside it stays inside one package. */
|
|
1072
1687
|
function enclosingPackage(root: string): string | undefined {
|
|
@@ -1219,18 +1834,14 @@ function renameRetiredTypes(ts: typeof TS, file: string, text: string): string {
|
|
|
1219
1834
|
/** The config files the loader reads, old layout included; a path field may live in any of them. */
|
|
1220
1835
|
const CONFIG_FILES = ["nifra.config.ts", "framework.ts", "backend/framework.ts"]
|
|
1221
1836
|
|
|
1222
|
-
interface ConfigEntry {
|
|
1837
|
+
interface ConfigEntry extends StaticConfigPath {
|
|
1223
1838
|
/** `clientModule`, or `componentPipeline.entries.<name>`. */
|
|
1224
1839
|
readonly field: string
|
|
1225
1840
|
readonly role: "frontend" | "backend"
|
|
1226
|
-
readonly value: string
|
|
1227
|
-
/** The literal's text, quotes excluded: [start, end). */
|
|
1228
|
-
readonly start: number
|
|
1229
|
-
readonly end: number
|
|
1230
1841
|
}
|
|
1231
1842
|
|
|
1232
1843
|
/**
|
|
1233
|
-
* The module paths a config exports
|
|
1844
|
+
* The module paths a config exports, read statically: `clientModule` and the component pipeline's
|
|
1234
1845
|
* `entries`. The browser loads `clientModule` and `entries.client`; `ssr` and `rsc` run on the server.
|
|
1235
1846
|
*/
|
|
1236
1847
|
function configEntries(ts: typeof TS, file: string, text: string): ConfigEntry[] {
|
|
@@ -1246,17 +1857,11 @@ function configEntries(ts: typeof TS, file: string, text: string): ConfigEntry[]
|
|
|
1246
1857
|
current = current.expression
|
|
1247
1858
|
return current
|
|
1248
1859
|
}
|
|
1860
|
+
const read = configPathReader(ts, sf)
|
|
1249
1861
|
const out: ConfigEntry[] = []
|
|
1250
1862
|
const literal = (field: string, role: ConfigEntry["role"], node: TS.Expression | undefined) => {
|
|
1251
|
-
const
|
|
1252
|
-
if (
|
|
1253
|
-
out.push({
|
|
1254
|
-
field,
|
|
1255
|
-
role,
|
|
1256
|
-
value: value.text,
|
|
1257
|
-
start: value.getStart(sf) + 1,
|
|
1258
|
-
end: value.getEnd() - 1,
|
|
1259
|
-
})
|
|
1863
|
+
const path = read(node)
|
|
1864
|
+
if (path !== undefined) out.push({ ...path, field, role })
|
|
1260
1865
|
}
|
|
1261
1866
|
for (const statement of sf.statements) {
|
|
1262
1867
|
if (
|
|
@@ -1386,7 +1991,7 @@ export async function migrateLayout(
|
|
|
1386
1991
|
original.set(file, readFileSync(join(root, file), "utf8"))
|
|
1387
1992
|
}
|
|
1388
1993
|
const issues: LayoutMigrationIssue[] = []
|
|
1389
|
-
const splits: Array<{ file: string; backend: string; moved: string[] }> = []
|
|
1994
|
+
const splits: Array<{ file: string; backend: string; moved: string[]; shared?: string }> = []
|
|
1390
1995
|
/** The app after the migration: new path -> text. */
|
|
1391
1996
|
const next: Files = new Map(original)
|
|
1392
1997
|
/** Old path -> new path, for every file that moves. */
|
|
@@ -1409,13 +2014,25 @@ export async function migrateLayout(
|
|
|
1409
2014
|
if (!ROUTE_FILE.test(file)) continue
|
|
1410
2015
|
const backendFile = file.replace(ROUTE_FILE, ".backend.ts")
|
|
1411
2016
|
const specifier = `./${posix.basename(backendFile)}`
|
|
2017
|
+
const sharedFile = file.replace(ROUTE_FILE, ".shared.ts")
|
|
2018
|
+
// A taken name leaves data both halves read copied into each, as reported.
|
|
2019
|
+
const splitOptions: SplitOptions =
|
|
2020
|
+
next.has(sharedFile) || existsSync(join(root, sharedFile))
|
|
2021
|
+
? {}
|
|
2022
|
+
: { sharedFile: posix.basename(sharedFile) }
|
|
1412
2023
|
let result: SplitResult
|
|
1413
2024
|
if (/\.(?:svelte|vue)$/.test(file)) {
|
|
1414
2025
|
const blocks = scriptBlocks(text)
|
|
1415
2026
|
const block =
|
|
1416
2027
|
blocks.find((b) => b.module) ?? blocks.find((b) => !b.setup && file.endsWith(".vue"))
|
|
1417
2028
|
if (block === undefined) continue
|
|
1418
|
-
const inner = splitRouteSource(
|
|
2029
|
+
const inner = splitRouteSource(
|
|
2030
|
+
ts,
|
|
2031
|
+
text.slice(block.start, block.end),
|
|
2032
|
+
"block.ts",
|
|
2033
|
+
specifier,
|
|
2034
|
+
splitOptions,
|
|
2035
|
+
)
|
|
1419
2036
|
result = {
|
|
1420
2037
|
...inner,
|
|
1421
2038
|
frontend: `${text.slice(0, block.start)}\n${inner.frontend}${text.slice(block.end)}`,
|
|
@@ -1433,7 +2050,7 @@ export async function migrateLayout(
|
|
|
1433
2050
|
}
|
|
1434
2051
|
continue
|
|
1435
2052
|
} else {
|
|
1436
|
-
result = splitRouteSource(ts, text, file, specifier)
|
|
2053
|
+
result = splitRouteSource(ts, text, file, specifier, splitOptions)
|
|
1437
2054
|
}
|
|
1438
2055
|
if (result.backend === undefined) continue
|
|
1439
2056
|
next.set(file, result.frontend)
|
|
@@ -1441,7 +2058,12 @@ export async function migrateLayout(
|
|
|
1441
2058
|
backendFile,
|
|
1442
2059
|
mergeModules(ts, backendHalves.get(backendFile) ?? "", result.backend),
|
|
1443
2060
|
)
|
|
1444
|
-
|
|
2061
|
+
if (result.shared === undefined)
|
|
2062
|
+
splits.push({ file, backend: backendFile, moved: [...result.moved] })
|
|
2063
|
+
else {
|
|
2064
|
+
next.set(sharedFile, result.shared)
|
|
2065
|
+
splits.push({ file, backend: backendFile, moved: [...result.moved], shared: sharedFile })
|
|
2066
|
+
}
|
|
1445
2067
|
for (const reason of result.issues) issues.push({ file, reason })
|
|
1446
2068
|
}
|
|
1447
2069
|
for (const [file, text] of backendHalves) {
|
|
@@ -1453,7 +2075,11 @@ export async function migrateLayout(
|
|
|
1453
2075
|
for (const [from, to] of Object.entries(rootMoves)) if (next.has(from)) moveMap.set(from, to)
|
|
1454
2076
|
// Assets (images, fonts) resolve too, so they move with the modules that import them.
|
|
1455
2077
|
const known = new Set([...next.keys(), ...everyFile])
|
|
1456
|
-
const
|
|
2078
|
+
const knownPaths = new Set([...known].map((file) => join(root, file)))
|
|
2079
|
+
// Route halves the split creates resolve before they are written.
|
|
2080
|
+
const resolver = createSpecifierResolver({
|
|
2081
|
+
moduleExists: (path) => knownPaths.has(path) || isFile(path),
|
|
2082
|
+
})
|
|
1457
2083
|
const realRoot = (() => {
|
|
1458
2084
|
try {
|
|
1459
2085
|
return realpathSync(root)
|
|
@@ -1461,14 +2087,21 @@ export async function migrateLayout(
|
|
|
1461
2087
|
return root
|
|
1462
2088
|
}
|
|
1463
2089
|
})()
|
|
2090
|
+
/** An app file as the migration names it, from a path the resolver found (real, or as planned). */
|
|
2091
|
+
const appFile = (path: string | undefined): string | undefined => {
|
|
2092
|
+
if (path === undefined) return undefined
|
|
2093
|
+
for (const base of [realRoot, root]) {
|
|
2094
|
+
const rel = toPosix(relative(base, path))
|
|
2095
|
+
if (!rel.startsWith("../") && !isAbsolute(rel)) return rel
|
|
2096
|
+
}
|
|
2097
|
+
return undefined
|
|
2098
|
+
}
|
|
1464
2099
|
/** A non-relative specifier the app's tsconfig `paths`/`baseUrl` or package.json `imports` maps to
|
|
1465
|
-
* one of its files,
|
|
2100
|
+
* one of its files, as the bundler reads it. */
|
|
1466
2101
|
const viaAlias = (file: string, specifier: string): string | undefined => {
|
|
1467
2102
|
if (specifier.startsWith(".") || isAbsolute(specifier)) return undefined
|
|
1468
|
-
const
|
|
1469
|
-
|
|
1470
|
-
const rel = toPosix(relative(realRoot, found))
|
|
1471
|
-
return rel.startsWith("../") || isAbsolute(rel) || !known.has(rel) ? undefined : rel
|
|
2103
|
+
const rel = appFile(resolver(join(root, file), withoutQuery(specifier)))
|
|
2104
|
+
return rel === undefined || !known.has(rel) ? undefined : rel
|
|
1472
2105
|
}
|
|
1473
2106
|
const resolveAny = (file: string, specifier: string): string | undefined =>
|
|
1474
2107
|
resolveIn(known, file, specifier) ?? viaAlias(file, specifier)
|
|
@@ -1485,7 +2118,7 @@ export async function migrateLayout(
|
|
|
1485
2118
|
* scheme the bundler owns, the app's own package name, or a package installed at or above it. */
|
|
1486
2119
|
const external = (specifier: string): boolean => {
|
|
1487
2120
|
if (/^[a-z][a-z\d+.-]*:/i.test(specifier) || specifier === "bun") return true
|
|
1488
|
-
const name = packageName(specifier
|
|
2121
|
+
const name = packageName(withoutQuery(specifier))
|
|
1489
2122
|
if (builtinModules.includes(name) || name === ownName) return true
|
|
1490
2123
|
let found = installed.get(name)
|
|
1491
2124
|
if (found === undefined) {
|
|
@@ -1518,9 +2151,10 @@ export async function migrateLayout(
|
|
|
1518
2151
|
const split = route === undefined ? undefined : splitRoutes.get(route)
|
|
1519
2152
|
if (route === undefined || split === undefined) return undefined
|
|
1520
2153
|
const inBase = (path: string): string => toPosix(relative(base, join(root, path)))
|
|
2154
|
+
const relativePath = backendSpecifier(specifier, inBase(route), inBase(split.backend), file)
|
|
1521
2155
|
const respelled = specifier.startsWith(".")
|
|
1522
|
-
?
|
|
1523
|
-
: resolver.respell(join(base, file), specifier, join(root, split.backend))
|
|
2156
|
+
? relativePath
|
|
2157
|
+
: (resolver.respell(join(base, file), specifier, join(root, split.backend)) ?? relativePath)
|
|
1524
2158
|
return { backend: split.backend, moved: split.moved, specifier: respelled }
|
|
1525
2159
|
}
|
|
1526
2160
|
const redirectedFiles = new Set<string>()
|
|
@@ -1574,8 +2208,12 @@ export async function migrateLayout(
|
|
|
1574
2208
|
configEntries(ts, file, next.get(file) ?? "").map((entry) => ({
|
|
1575
2209
|
...entry,
|
|
1576
2210
|
file,
|
|
1577
|
-
//
|
|
1578
|
-
|
|
2211
|
+
// A string is read from the app root, whichever config file holds it; a joined path from the
|
|
2212
|
+
// config's own folder.
|
|
2213
|
+
target:
|
|
2214
|
+
entry.base === "app"
|
|
2215
|
+
? resolveAny("nifra.config.ts", entry.path)
|
|
2216
|
+
: resolveIn(known, file, entry.path.startsWith("../") ? entry.path : `./${entry.path}`),
|
|
1579
2217
|
})),
|
|
1580
2218
|
)
|
|
1581
2219
|
const entryTargets = (role: ConfigEntry["role"]): Set<string> =>
|
|
@@ -1589,7 +2227,8 @@ export async function migrateLayout(
|
|
|
1589
2227
|
const files = [...new Set([...next.keys(), ...everyFile.filter((f) => !original.has(f))])]
|
|
1590
2228
|
const frontendRoots = [
|
|
1591
2229
|
...files.filter(
|
|
1592
|
-
(f) =>
|
|
2230
|
+
(f) =>
|
|
2231
|
+
f.startsWith("routes/") && ROUTE_FILE.test(f) && !f.includes(".backend.") && !isTestFile(f),
|
|
1593
2232
|
),
|
|
1594
2233
|
...browserEntries,
|
|
1595
2234
|
]
|
|
@@ -1645,7 +2284,9 @@ export async function migrateLayout(
|
|
|
1645
2284
|
})
|
|
1646
2285
|
}
|
|
1647
2286
|
}
|
|
1648
|
-
|
|
2287
|
+
/** With `zones`, the files that stay where they are and the side package.json gives them. */
|
|
2288
|
+
const inPlace = new Map<string, FolderSide>()
|
|
2289
|
+
const zoneOf = (file: string): FolderSide =>
|
|
1649
2290
|
browser.has(file) && server.has(file) ? "shared" : browser.has(file) ? "frontend" : "backend"
|
|
1650
2291
|
for (const file of api ? [] : files) {
|
|
1651
2292
|
if (
|
|
@@ -1662,6 +2303,10 @@ export async function migrateLayout(
|
|
|
1662
2303
|
if (ZONE_DIRS.has(top) || (!browser.has(file) && !server.has(file))) continue
|
|
1663
2304
|
} else if (!browser.has(file) && !appServer.has(file)) continue
|
|
1664
2305
|
const zone = zoneOf(file)
|
|
2306
|
+
if (DATA_FILE.test(file) && options.zones === true) {
|
|
2307
|
+
inPlace.set(file, zone)
|
|
2308
|
+
continue
|
|
2309
|
+
}
|
|
1665
2310
|
if (DATA_FILE.test(file)) {
|
|
1666
2311
|
const folder = file.includes("/") ? posix.dirname(file) : undefined
|
|
1667
2312
|
issues.push({
|
|
@@ -1672,6 +2317,51 @@ export async function migrateLayout(
|
|
|
1672
2317
|
}
|
|
1673
2318
|
moveMap.set(file, `${zone}/${file}`)
|
|
1674
2319
|
}
|
|
2320
|
+
// A folder whose reached files all move into one zone takes its other files along: a file nothing
|
|
2321
|
+
// imports at runtime (a component not used yet, an asset) has no zone of its own, and leaving it
|
|
2322
|
+
// behind splits the folder for every tool that lists it. Server built-ins never go to the browser.
|
|
2323
|
+
const zonesUnder = new Map<string, Set<string>>()
|
|
2324
|
+
for (const [from, to] of moveMap) {
|
|
2325
|
+
if (isTestFile(from)) continue
|
|
2326
|
+
for (let dir = posix.dirname(from); dir !== "."; dir = posix.dirname(dir)) {
|
|
2327
|
+
zonesUnder.set(dir, (zonesUnder.get(dir) ?? new Set<string>()).add(to.split("/")[0] ?? ""))
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
for (const file of api ? [] : files) {
|
|
2331
|
+
if (moveMap.has(file) || browser.has(file) || server.has(file)) continue
|
|
2332
|
+
if (ZONE_DIRS.has(file.split("/")[0] ?? "") || inConfiguredFolder(file)) continue
|
|
2333
|
+
// A test and its snapshot follow their subject below.
|
|
2334
|
+
if (
|
|
2335
|
+
isTestFile(file) ||
|
|
2336
|
+
file.endsWith(".snap") ||
|
|
2337
|
+
file.endsWith(".d.ts") ||
|
|
2338
|
+
DATA_FILE.test(file)
|
|
2339
|
+
)
|
|
2340
|
+
continue
|
|
2341
|
+
let zones: ReadonlySet<string> | undefined
|
|
2342
|
+
for (
|
|
2343
|
+
let dir = posix.dirname(file);
|
|
2344
|
+
dir !== "." && zones === undefined;
|
|
2345
|
+
dir = posix.dirname(dir)
|
|
2346
|
+
) {
|
|
2347
|
+
// A nested package is its own install, never part of the app's folder.
|
|
2348
|
+
if (existsSync(join(root, dir, "package.json"))) break
|
|
2349
|
+
zones = zonesUnder.get(dir)
|
|
2350
|
+
}
|
|
2351
|
+
const [zone] = zones?.size === 1 ? zones : []
|
|
2352
|
+
if (zone === undefined) continue
|
|
2353
|
+
if (zone !== "backend" && /from\s+["'](?:node:|bun:)/.test(next.get(file) ?? "")) continue
|
|
2354
|
+
moveMap.set(file, `${zone}/${file}`)
|
|
2355
|
+
}
|
|
2356
|
+
if (options.zones === true) {
|
|
2357
|
+
for (const [from, to] of moveMap) {
|
|
2358
|
+
const side = FOLDER_SIDES.find((side) => to === `${side}/${from}`)
|
|
2359
|
+
if (side === undefined) continue
|
|
2360
|
+
inPlace.set(from, side)
|
|
2361
|
+
moveMap.delete(from)
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
const declared = zoneDeclarations(inPlace, files, (file) => next.get(file), inConfiguredFolder)
|
|
1675
2365
|
const claimed = new Map<string, string>()
|
|
1676
2366
|
for (const [from, to] of moveMap) {
|
|
1677
2367
|
const holder = claimed.get(to) ?? (known.has(to) && !moveMap.has(to) ? to : undefined)
|
|
@@ -1685,32 +2375,120 @@ export async function migrateLayout(
|
|
|
1685
2375
|
})
|
|
1686
2376
|
moveMap.delete(from)
|
|
1687
2377
|
}
|
|
1688
|
-
|
|
1689
|
-
|
|
2378
|
+
const newPath = (file: string): string => moveMap.get(file) ?? file
|
|
2379
|
+
// A test beside its subject (`x.test.ts` by `x.ts`, or in a `__tests__/` under its folder) moves
|
|
2380
|
+
// with it, snapshots included, once the subject's place is settled; a test never decides a zone.
|
|
2381
|
+
const present = new Set(files)
|
|
2382
|
+
for (const test of files) {
|
|
2383
|
+
const stem = /^(.+?)\.(?:test|spec)\.[cm]?[jt]sx?$/.exec(posix.basename(test))?.[1]
|
|
2384
|
+
if (stem === undefined || moveMap.has(test) || !next.has(test)) continue
|
|
2385
|
+
const dir = posix.dirname(test)
|
|
2386
|
+
const nested = posix.basename(dir) === "__tests__"
|
|
2387
|
+
const home = nested ? posix.dirname(dir) : dir
|
|
2388
|
+
const subjects = SUBJECT_EXTENSIONS.map((ext) => posix.join(home, `${stem}${ext}`)).filter(
|
|
2389
|
+
(file) => present.has(file) && !isTestFile(file),
|
|
2390
|
+
)
|
|
2391
|
+
const subject = subjects.find((file) => moveMap.has(file))
|
|
2392
|
+
if (subject === undefined) continue
|
|
2393
|
+
if (new Set(subjects.map((file) => posix.dirname(newPath(file)))).size > 1) {
|
|
2394
|
+
issues.push({
|
|
2395
|
+
file: test,
|
|
2396
|
+
reason: `tests ${subjects.map((file) => `"${file}"`).join(" and ")}, which end up in different folders, so it stays here; move it by hand`,
|
|
2397
|
+
})
|
|
2398
|
+
continue
|
|
2399
|
+
}
|
|
2400
|
+
const to = posix.join(
|
|
2401
|
+
posix.dirname(newPath(subject)),
|
|
2402
|
+
nested ? "__tests__" : "",
|
|
2403
|
+
posix.basename(test),
|
|
2404
|
+
)
|
|
2405
|
+
const pairs: Array<readonly [string, string]> = [[test, to]]
|
|
2406
|
+
const snapshot = posix.join(dir, "__snapshots__", `${posix.basename(test)}.snap`)
|
|
2407
|
+
if (present.has(snapshot))
|
|
2408
|
+
pairs.push([
|
|
2409
|
+
snapshot,
|
|
2410
|
+
posix.join(posix.dirname(to), "__snapshots__", posix.basename(snapshot)),
|
|
2411
|
+
])
|
|
2412
|
+
const taken = pairs.find(([, to]) => claimed.has(to) || (known.has(to) && !moveMap.has(to)))
|
|
2413
|
+
if (taken !== undefined) {
|
|
2414
|
+
issues.push({
|
|
2415
|
+
file: test,
|
|
2416
|
+
reason: `would follow "${subject}" to "${taken[1]}", which already exists or another file moves to; move it by hand`,
|
|
2417
|
+
})
|
|
2418
|
+
continue
|
|
2419
|
+
}
|
|
2420
|
+
for (const [from, to] of pairs) {
|
|
2421
|
+
moveMap.set(from, to)
|
|
2422
|
+
claimed.set(to, from)
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
for (const [from, to] of [
|
|
2426
|
+
...moveMap,
|
|
2427
|
+
...[...inPlace].map(([file, side]) => [file, side] as const),
|
|
2428
|
+
]) {
|
|
2429
|
+
// A test is never bundled, so its own server imports decide nothing.
|
|
2430
|
+
if (isTestFile(from)) continue
|
|
2431
|
+
if (
|
|
2432
|
+
(to === "shared" || to.startsWith("shared/")) &&
|
|
2433
|
+
/from\s+["'](?:node:|bun:)/.test(next.get(from) ?? "")
|
|
2434
|
+
) {
|
|
1690
2435
|
issues.push({
|
|
1691
2436
|
file: from,
|
|
1692
|
-
reason: `both halves import it at runtime, but it uses a server built-in; split it before moving to ${to}`,
|
|
2437
|
+
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}`}`,
|
|
1693
2438
|
})
|
|
1694
2439
|
}
|
|
1695
2440
|
}
|
|
1696
2441
|
|
|
1697
2442
|
// 5. Rewrite imports to the new locations.
|
|
1698
|
-
const newPath = (file: string): string => moveMap.get(file) ?? file
|
|
1699
2443
|
const final: Files = new Map()
|
|
1700
2444
|
const rewritten: string[] = []
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
2445
|
+
// The app as the migration leaves it, where every rewritten specifier must reach its target.
|
|
2446
|
+
const planned = new Set(
|
|
2447
|
+
[...known].filter((file) => next.has(file) || !original.has(file)).map(newPath),
|
|
2448
|
+
)
|
|
2449
|
+
const plannedResolver = createSpecifierResolver({
|
|
2450
|
+
moduleExists: (path) => {
|
|
2451
|
+
const file = appFile(path)
|
|
2452
|
+
if (file !== undefined && (planned.has(file) || known.has(file))) return planned.has(file)
|
|
2453
|
+
return isFile(path)
|
|
2454
|
+
},
|
|
2455
|
+
})
|
|
2456
|
+
/** Whether `specifier`, written in `from` after the moves, loads `target` there. */
|
|
2457
|
+
const reaches = (from: string, specifier: string, target: string): boolean =>
|
|
2458
|
+
specifier.startsWith(".")
|
|
2459
|
+
? resolveIn(planned, from, specifier) === target
|
|
2460
|
+
: appFile(plannedResolver(join(root, from), withoutQuery(specifier))) === target
|
|
2461
|
+
const unreached = new Set<string>()
|
|
2462
|
+
const unreachable = (file: string, specifier: string, target: string, from: string): void => {
|
|
2463
|
+
if (unreached.has(`${file}\0${specifier}`)) return
|
|
2464
|
+
unreached.add(`${file}\0${specifier}`)
|
|
2465
|
+
issues.push({
|
|
2466
|
+
file,
|
|
2467
|
+
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`,
|
|
2468
|
+
})
|
|
2469
|
+
}
|
|
2470
|
+
/**
|
|
2471
|
+
* How `from` names `target` after the moves, for the alias `specifier` that `file` writes: as written
|
|
2472
|
+
* while it still reaches it, through the same alias when one of its targets covers the new place,
|
|
2473
|
+
* else as a relative path. `undefined` leaves it as written.
|
|
2474
|
+
*/
|
|
2475
|
+
const aliasTo = (
|
|
2476
|
+
file: string,
|
|
2477
|
+
specifier: string,
|
|
2478
|
+
target: string,
|
|
2479
|
+
from = newPath(file),
|
|
2480
|
+
): string | undefined => {
|
|
2481
|
+
const to = newPath(target)
|
|
2482
|
+
if (to === target && from === file) return undefined
|
|
2483
|
+
const written = withoutQuery(specifier)
|
|
2484
|
+
if (reaches(from, written, to)) return undefined
|
|
2485
|
+
const respelled = resolver.respell(join(root, file), written, join(root, to))
|
|
2486
|
+
if (respelled !== undefined && reaches(from, respelled, to))
|
|
2487
|
+
return `${respelled}${specifier.slice(written.length)}`
|
|
2488
|
+
const relativePath = respecify(specifier, target, from, to)
|
|
2489
|
+
if (reaches(from, relativePath, to)) return relativePath
|
|
2490
|
+
unreachable(file, specifier, target, from)
|
|
2491
|
+
return undefined
|
|
1714
2492
|
}
|
|
1715
2493
|
for (const [file, text] of next) {
|
|
1716
2494
|
const imports = rewriteImports(ts, file, text, (specifier) => {
|
|
@@ -1719,9 +2497,12 @@ export async function migrateLayout(
|
|
|
1719
2497
|
const target = resolveAny(file, specifier)
|
|
1720
2498
|
// Outside the app (or a skipped dir): the target stays put, so only a moved importer re-bases it.
|
|
1721
2499
|
if (target === undefined) return rebase(specifier, file, newPath(file))
|
|
1722
|
-
if (!specifier.startsWith(".")) return
|
|
2500
|
+
if (!specifier.startsWith(".")) return aliasTo(file, specifier, target)
|
|
1723
2501
|
if (newPath(target) === target && newPath(file) === file) return undefined
|
|
1724
|
-
|
|
2502
|
+
const relativePath = respecify(specifier, target, newPath(file), newPath(target))
|
|
2503
|
+
if (!reaches(newPath(file), relativePath, newPath(target)))
|
|
2504
|
+
unreachable(file, specifier, target, newPath(file))
|
|
2505
|
+
return relativePath
|
|
1725
2506
|
})
|
|
1726
2507
|
const updated = renameRetiredTypes(ts, file, imports)
|
|
1727
2508
|
final.set(newPath(file), updated)
|
|
@@ -1732,19 +2513,21 @@ export async function migrateLayout(
|
|
|
1732
2513
|
rewritten.push(file)
|
|
1733
2514
|
}
|
|
1734
2515
|
|
|
1735
|
-
// A config
|
|
2516
|
+
// A config string names its module from the app root, so it follows the module, never the config
|
|
2517
|
+
// file; a path joined onto the config's folder is re-pointed with the module's own paths below.
|
|
1736
2518
|
for (const configFile of CONFIG_FILES) {
|
|
1737
2519
|
const at = newPath(configFile)
|
|
1738
2520
|
const text = final.get(at)
|
|
1739
2521
|
if (text === undefined || !next.has(configFile)) continue
|
|
1740
2522
|
const edits: Array<{ start: number; end: number; text: string }> = []
|
|
1741
2523
|
for (const entry of configEntries(ts, at, text)) {
|
|
1742
|
-
|
|
2524
|
+
if (entry.span === undefined) continue
|
|
2525
|
+
const target = resolveAny("nifra.config.ts", entry.path)
|
|
1743
2526
|
if (target === undefined || newPath(target) === target) continue
|
|
1744
|
-
const value = entry.
|
|
1745
|
-
? respecify(entry.
|
|
1746
|
-
:
|
|
1747
|
-
if (value !== undefined) edits.push({ start: entry.
|
|
2527
|
+
const value = entry.path.startsWith(".")
|
|
2528
|
+
? respecify(entry.path, target, "nifra.config.ts", newPath(target))
|
|
2529
|
+
: aliasTo(configFile, entry.path, target, "nifra.config.ts")
|
|
2530
|
+
if (value !== undefined) edits.push({ start: entry.span[0], end: entry.span[1], text: value })
|
|
1748
2531
|
}
|
|
1749
2532
|
if (edits.length === 0) continue
|
|
1750
2533
|
let updated = text
|
|
@@ -1774,11 +2557,33 @@ export async function migrateLayout(
|
|
|
1774
2557
|
final.set(to, result.text)
|
|
1775
2558
|
if (result.unfollowed !== undefined) unfollowed.set(to, result.unfollowed)
|
|
1776
2559
|
}
|
|
2560
|
+
// A module that stays still names files from its own folder (a config's `clientModule` joined onto
|
|
2561
|
+
// `import.meta.dir`): each that moved is re-pointed.
|
|
2562
|
+
const landed = new Set(moveMap.values())
|
|
2563
|
+
for (const [file, text] of final) {
|
|
2564
|
+
if (landed.has(file) || !/\.[cm]?[jt]sx?$/.test(file) || !SELF_LOCATION.test(text)) continue
|
|
2565
|
+
const result = rebaseOwnPaths(ts, file, file, text, (target) =>
|
|
2566
|
+
known.has(target) ? newPath(target) : target,
|
|
2567
|
+
)
|
|
2568
|
+
if (result.text === text) continue
|
|
2569
|
+
final.set(file, result.text)
|
|
2570
|
+
if (original.has(file) && !rewritten.includes(file)) rewritten.push(file)
|
|
2571
|
+
}
|
|
1777
2572
|
|
|
1778
2573
|
// A path in a string or comment is not an import, so nothing above rewrote it: a build script that
|
|
1779
2574
|
// writes `data/x.json` would keep writing to the old place, and so would a deploy script or
|
|
1780
2575
|
// `package.json` beside the code.
|
|
1781
2576
|
const settled = new Set([...known].map(newPath))
|
|
2577
|
+
// A new backend half starts where its route was, so it keeps that route's place in each program.
|
|
2578
|
+
const splitFrom = new Map(
|
|
2579
|
+
splits
|
|
2580
|
+
.filter((split) => !original.has(split.backend))
|
|
2581
|
+
.map((split) => [split.backend, split.file]),
|
|
2582
|
+
)
|
|
2583
|
+
const layout = [...known]
|
|
2584
|
+
.filter((file) => next.has(file) || !original.has(file))
|
|
2585
|
+
.map((file) => ({ from: splitFrom.get(file) ?? file, to: newPath(file) }))
|
|
2586
|
+
const tsconfigs = followTsconfigs(ts, root, moveMap, layout)
|
|
1782
2587
|
const scanned: Array<readonly [string, string]> = [...final]
|
|
1783
2588
|
for (const file of everyFile) {
|
|
1784
2589
|
if (
|
|
@@ -1786,7 +2591,10 @@ export async function migrateLayout(
|
|
|
1786
2591
|
TOOLING_FILE.test(file) &&
|
|
1787
2592
|
statSync(join(root, file)).size <= 256_000
|
|
1788
2593
|
)
|
|
1789
|
-
scanned.push([
|
|
2594
|
+
scanned.push([
|
|
2595
|
+
newPath(file),
|
|
2596
|
+
tsconfigs.files.get(file)?.text ?? readFileSync(join(root, file), "utf8"),
|
|
2597
|
+
])
|
|
1790
2598
|
}
|
|
1791
2599
|
for (const [file, text] of scanned) {
|
|
1792
2600
|
let live: ReadonlyArray<readonly [number, number]> | undefined
|
|
@@ -1845,6 +2653,36 @@ export async function migrateLayout(
|
|
|
1845
2653
|
}
|
|
1846
2654
|
}
|
|
1847
2655
|
|
|
2656
|
+
// A workspace sibling is not the migration's to edit either: each file that names a moved app file,
|
|
2657
|
+
// by import or by path, is reported with the line and both paths.
|
|
2658
|
+
const appName = posix.basename(toPosix(root))
|
|
2659
|
+
const mentionsApp = new RegExp(`(?:^|[/'"\`])${escapeRegExp(appName)}(?:[/'"\`]|$)`, "m")
|
|
2660
|
+
for (const sibling of moveMap.size === 0 ? [] : (workspaceSiblings(root) ?? [])) {
|
|
2661
|
+
for (const file of listFiles(sibling)) {
|
|
2662
|
+
if (!/\.[cm]?[jt]sx?$/.test(file) && !TOOLING_FILE.test(file)) continue
|
|
2663
|
+
if (statSync(join(sibling, file)).size > 256_000) continue
|
|
2664
|
+
const text = readFileSync(join(sibling, file), "utf8")
|
|
2665
|
+
if (!mentionsApp.test(text) && (ownName === undefined || !text.includes(`${ownName}/`)))
|
|
2666
|
+
continue
|
|
2667
|
+
const at = toPosix(relative(root, join(sibling, file)))
|
|
2668
|
+
for (const { from, to, line } of siblingReferences(
|
|
2669
|
+
ts,
|
|
2670
|
+
root,
|
|
2671
|
+
sibling,
|
|
2672
|
+
file,
|
|
2673
|
+
text,
|
|
2674
|
+
moveMap,
|
|
2675
|
+
known,
|
|
2676
|
+
ownName,
|
|
2677
|
+
)) {
|
|
2678
|
+
issues.push({
|
|
2679
|
+
file: at,
|
|
2680
|
+
reason: `names "${from}" on line ${line}, and it moves to "${to}" - it is outside the app, so the migration does not rewrite it`,
|
|
2681
|
+
})
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
|
|
1848
2686
|
// Paths a module finds at run time are not imports either: one built from the module's own location
|
|
1849
2687
|
// now starts from its new folder, and a folder listed at run time loses whatever moved out of it.
|
|
1850
2688
|
const leaving = new Map<string, Array<{ from: string; to: string }>>()
|
|
@@ -1878,14 +2716,46 @@ export async function migrateLayout(
|
|
|
1878
2716
|
})
|
|
1879
2717
|
}
|
|
1880
2718
|
|
|
1881
|
-
// The server refuses data a loader or action returns without an output schema
|
|
1882
|
-
//
|
|
2719
|
+
// The server refuses data a loader or action returns without an output schema. Each half the split
|
|
2720
|
+
// wrote gets a draft from its return type, read from the layout as planned so the dry run shows
|
|
2721
|
+
// what --write adds; only the author knows which fields the browser may see, so it is a draft.
|
|
2722
|
+
const halves = [...new Set(splits.map((split) => split.backend))].filter((file) =>
|
|
2723
|
+
final.has(file),
|
|
2724
|
+
)
|
|
2725
|
+
const unwritten = new Map<string, string>()
|
|
2726
|
+
for (const [file, text] of final) if (original.get(file) !== text) unwritten.set(file, text)
|
|
2727
|
+
const schemaPlan =
|
|
2728
|
+
halves.length === 0
|
|
2729
|
+
? undefined
|
|
2730
|
+
: await planOutputSchemas(root, halves, (file) => final.get(file), {
|
|
2731
|
+
overlay: unwritten,
|
|
2732
|
+
// What NF-C030 would flag: a schema for data the function returns, not for `return null`.
|
|
2733
|
+
draft: (file, schema) =>
|
|
2734
|
+
missingOutputSchemas(final.get(file) ?? "").some((found) => found.schema === schema),
|
|
2735
|
+
})
|
|
2736
|
+
for (const [file, text] of schemaPlan?.edits ?? []) final.set(file, text)
|
|
2737
|
+
const schemas = (schemaPlan?.entries ?? []).filter((entry) => entry.status === "missing")
|
|
2738
|
+
const followUps: LayoutMigrationFollowUp[] = []
|
|
1883
2739
|
for (const [file, text] of final) {
|
|
1884
2740
|
if (!file.startsWith("routes/") || !/\.backend\.[cm]?[jt]s$/.test(file)) continue
|
|
1885
2741
|
for (const { name, schema } of missingOutputSchemas(text)) {
|
|
2742
|
+
const why = schemaPlan?.entries.find(
|
|
2743
|
+
(entry) => entry.file === file && entry.schema === schema && entry.reason !== undefined,
|
|
2744
|
+
)?.reason
|
|
2745
|
+
const missing = `exports ${name === "action" ? "an" : "a"} ${name} but no ${schema}, so data it returns fails the request`
|
|
2746
|
+
// A half this run did not write keeps the schema it had: drafting one is the next command, not a
|
|
2747
|
+
// decision the migration left open.
|
|
2748
|
+
if (why === undefined && (schemaPlan === undefined || schemaPlan.warnings.length === 0)) {
|
|
2749
|
+
followUps.push({
|
|
2750
|
+
file,
|
|
2751
|
+
reason: `${missing}; draft it from the ${name}'s return type`,
|
|
2752
|
+
command: "nifra schemas --write",
|
|
2753
|
+
})
|
|
2754
|
+
continue
|
|
2755
|
+
}
|
|
1886
2756
|
issues.push({
|
|
1887
2757
|
file,
|
|
1888
|
-
reason:
|
|
2758
|
+
reason: `${missing}. Declare what the browser may see: export const ${schema} = t.object({ ... }); no draft was written: ${why ?? schemaPlan?.warnings.join("; ")}`,
|
|
1889
2759
|
})
|
|
1890
2760
|
}
|
|
1891
2761
|
}
|
|
@@ -1893,12 +2763,11 @@ export async function migrateLayout(
|
|
|
1893
2763
|
// Tooling that lists folders or globs, not files: the import rewrite above never sees it.
|
|
1894
2764
|
const tooling: Array<{ file: string; change: string }> = []
|
|
1895
2765
|
const toolingWrites = new Map<string, string>()
|
|
1896
|
-
const
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
toolingWrites.set("tsconfig.json", tsconfig.text)
|
|
1900
|
-
tooling.push({ file: "tsconfig.json", change: `include += ${tsconfig.added.join(", ")}` })
|
|
2766
|
+
for (const [file, { text, changes }] of tsconfigs.files) {
|
|
2767
|
+
toolingWrites.set(file, text)
|
|
2768
|
+
for (const change of changes) tooling.push({ file, change })
|
|
1901
2769
|
}
|
|
2770
|
+
issues.push(...tsconfigs.issues)
|
|
1902
2771
|
for (const file of everyFile) {
|
|
1903
2772
|
if (!TAILWIND_CONFIG.test(file)) continue
|
|
1904
2773
|
const reason = staleContentGlobs(ts, readFileSync(join(root, file), "utf8"), moves)
|
|
@@ -1909,6 +2778,33 @@ export async function migrateLayout(
|
|
|
1909
2778
|
toolingWrites.set(".gitignore", gitignore)
|
|
1910
2779
|
tooling.push({ file: ".gitignore", change: "ignore .nifra/ (dev and build state)" })
|
|
1911
2780
|
}
|
|
2781
|
+
// A root backend.ts was served at /api by default until 5.0, which mounts it only where apiPrefix says.
|
|
2782
|
+
if (!api && rootMoves["backend.ts"] !== undefined) {
|
|
2783
|
+
const mount = apiPrefixFix({
|
|
2784
|
+
framework: final.get(FRAMEWORK_FILE),
|
|
2785
|
+
config: final.get(CONFIG_FILE),
|
|
2786
|
+
})
|
|
2787
|
+
for (const [file, text] of [
|
|
2788
|
+
[FRAMEWORK_FILE, mount.framework],
|
|
2789
|
+
[CONFIG_FILE, mount.config],
|
|
2790
|
+
] as const) {
|
|
2791
|
+
if (text === undefined) continue
|
|
2792
|
+
final.set(file, text)
|
|
2793
|
+
tooling.push({
|
|
2794
|
+
file,
|
|
2795
|
+
change: 'export const apiPrefix = "/api", the mount 4.x made by default',
|
|
2796
|
+
})
|
|
2797
|
+
}
|
|
2798
|
+
}
|
|
2799
|
+
if (declared.size > 0) {
|
|
2800
|
+
const zoned = declareZones(ts, root, declared)
|
|
2801
|
+
if (zoned.issue !== undefined) issues.push({ file: "package.json", reason: zoned.issue })
|
|
2802
|
+
if (zoned.text !== undefined) {
|
|
2803
|
+
toolingWrites.set("package.json", zoned.text)
|
|
2804
|
+
for (const [path, side] of declared)
|
|
2805
|
+
tooling.push({ file: "package.json", change: `"nifra.zones" lists "${path}" as ${side}` })
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
1912
2808
|
// `nifra check` reads the declaration statically; without it, a later routes/ or config would zone the app.
|
|
1913
2809
|
const kind = api && createKindProbe(root)(root).kind === undefined ? declareApiKind(ts, root) : {}
|
|
1914
2810
|
if (kind.issue !== undefined) issues.push({ file: "package.json", reason: kind.issue })
|
|
@@ -1943,14 +2839,57 @@ export async function migrateLayout(
|
|
|
1943
2839
|
ok: issues.length === 0,
|
|
1944
2840
|
write: options.write === true,
|
|
1945
2841
|
mode,
|
|
2842
|
+
followUps,
|
|
1946
2843
|
splits: splits.sort((a, b) => codeUnitOrder(a.file, b.file)),
|
|
1947
2844
|
moves,
|
|
1948
2845
|
rewritten: rewritten.sort(),
|
|
1949
2846
|
tooling,
|
|
2847
|
+
schemas: schemas.map((entry) =>
|
|
2848
|
+
options.write === true && entry.reason === undefined
|
|
2849
|
+
? { ...entry, status: "generated" }
|
|
2850
|
+
: entry,
|
|
2851
|
+
),
|
|
1950
2852
|
issues,
|
|
1951
2853
|
}
|
|
1952
2854
|
}
|
|
1953
2855
|
|
|
2856
|
+
/**
|
|
2857
|
+
* Split one route file in place: its server-only exports, and the code only they use, move into its
|
|
2858
|
+
* `x.backend.ts`, created or merged into. Returns the files written, app-relative; nothing for a file
|
|
2859
|
+
* that exports nothing for the server. Refuses, naming why, a split that would need a decision.
|
|
2860
|
+
*/
|
|
2861
|
+
export async function splitRouteFile(
|
|
2862
|
+
appRoot: string,
|
|
2863
|
+
file: string,
|
|
2864
|
+
options: Pick<LayoutMigrationOptions, "typescript"> = {},
|
|
2865
|
+
): Promise<readonly string[]> {
|
|
2866
|
+
if (!/\.[jt]sx$/.test(file)) return []
|
|
2867
|
+
const ts = options.typescript ?? (await importTypeScript())
|
|
2868
|
+
if (ts === undefined)
|
|
2869
|
+
throw new Error("[nifra] splitting a route needs TypeScript: bun add -d typescript")
|
|
2870
|
+
const root = resolve(appRoot)
|
|
2871
|
+
const backendFile = file.replace(ROUTE_FILE, ".backend.ts")
|
|
2872
|
+
const sharedFile = file.replace(ROUTE_FILE, ".shared.ts")
|
|
2873
|
+
const result = splitRouteSource(
|
|
2874
|
+
ts,
|
|
2875
|
+
readFileSync(join(root, file), "utf8"),
|
|
2876
|
+
file,
|
|
2877
|
+
`./${posix.basename(backendFile)}`,
|
|
2878
|
+
existsSync(join(root, sharedFile)) ? {} : { sharedFile: posix.basename(sharedFile) },
|
|
2879
|
+
)
|
|
2880
|
+
if (result.backend === undefined) return []
|
|
2881
|
+
if (result.issues.length > 0) {
|
|
2882
|
+
throw new Error(`[nifra] ${file} was not split: ${result.issues.join("; ")}`)
|
|
2883
|
+
}
|
|
2884
|
+
const backendPath = join(root, backendFile)
|
|
2885
|
+
const existing = existsSync(backendPath) ? readFileSync(backendPath, "utf8") : ""
|
|
2886
|
+
writeFileSync(join(root, file), result.frontend)
|
|
2887
|
+
writeFileSync(backendPath, mergeModules(ts, existing, result.backend))
|
|
2888
|
+
if (result.shared === undefined) return [file, backendFile]
|
|
2889
|
+
writeFileSync(join(root, sharedFile), result.shared)
|
|
2890
|
+
return [file, backendFile, sharedFile]
|
|
2891
|
+
}
|
|
2892
|
+
|
|
1954
2893
|
/** The app's `"nifra": { "zones": ... }`, so folders it already zones are left where they are. */
|
|
1955
2894
|
function appZones(root: string): ZoneConfig {
|
|
1956
2895
|
const manifest = join(root, "package.json")
|
|
@@ -1968,53 +2907,271 @@ function appZones(root: string): ZoneConfig {
|
|
|
1968
2907
|
return zones === undefined ? {} : parseZoneConfig(zones, manifest)
|
|
1969
2908
|
}
|
|
1970
2909
|
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
if (
|
|
1977
|
-
|
|
1978
|
-
|
|
2910
|
+
const matchers = new Map<string, (file: string) => boolean>()
|
|
2911
|
+
|
|
2912
|
+
/** Whether a tsconfig `include` or `exclude` entry takes in `file` (app-relative), the way tsc reads it. */
|
|
2913
|
+
function matches(pattern: string, file: string): boolean {
|
|
2914
|
+
let match = matchers.get(pattern)
|
|
2915
|
+
if (match === undefined) {
|
|
2916
|
+
let spec = pattern.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "")
|
|
2917
|
+
if (spec === "" || spec === ".") match = () => true
|
|
2918
|
+
else if (!/[*?]/.test(spec)) {
|
|
2919
|
+
const folder = `${spec}/`
|
|
2920
|
+
match = (path) => path === spec || path.startsWith(folder)
|
|
2921
|
+
} else {
|
|
2922
|
+
if (spec.endsWith("**")) spec = `${spec}/*`
|
|
2923
|
+
else if (!/[*?.][^/]*$/.test(spec.slice(spec.lastIndexOf("/") + 1))) spec = `${spec}/**/*`
|
|
2924
|
+
const glob = new Bun.Glob(spec)
|
|
2925
|
+
match = (path) => glob.match(path)
|
|
2926
|
+
}
|
|
2927
|
+
matchers.set(pattern, match)
|
|
2928
|
+
}
|
|
2929
|
+
return match(file)
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
const TSCONFIG = /^tsconfig(?:\.[\w-]+)?\.json$/
|
|
2933
|
+
const TS_FILE = /\.(?:[cm]?ts|tsx)$/
|
|
2934
|
+
|
|
2935
|
+
interface ProgramLists {
|
|
2936
|
+
readonly include: readonly string[]
|
|
2937
|
+
readonly exclude: readonly string[]
|
|
2938
|
+
readonly files: readonly string[]
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
const inProgram = (lists: ProgramLists, file: string): boolean =>
|
|
2942
|
+
lists.files.includes(file) ||
|
|
2943
|
+
(lists.include.some((pattern) => matches(pattern, file)) &&
|
|
2944
|
+
!lists.exclude.some((pattern) => matches(pattern, file)))
|
|
2945
|
+
|
|
2946
|
+
/** `pattern` under the zone folder `to` landed in, when that reaches `to`; else `to` itself. */
|
|
2947
|
+
function twinEntry(pattern: string | undefined, to: string): string {
|
|
2948
|
+
const slash = to.indexOf("/")
|
|
2949
|
+
if (pattern === undefined || slash === -1) return to
|
|
2950
|
+
const bare = pattern.replace(/^\.\//, "")
|
|
2951
|
+
const twin = `${to.slice(0, slash)}/${bare}`
|
|
2952
|
+
if (!matches(twin, to)) return to
|
|
2953
|
+
return bare === pattern ? twin : `./${twin}`
|
|
1979
2954
|
}
|
|
1980
2955
|
|
|
1981
2956
|
/**
|
|
1982
|
-
*
|
|
1983
|
-
*
|
|
1984
|
-
*
|
|
2957
|
+
* Keep every TypeScript file in, or out of, each app-root `tsconfig*.json` program as it was. An entry
|
|
2958
|
+
* naming a moved file follows it; a folder or glob that took a moved file in gains its twin under the
|
|
2959
|
+
* zone folder (`src` -> `frontend/src`), and one that kept a file out does the same in `exclude`. So a
|
|
2960
|
+
* test run under its own config's types, or a folder left out on purpose, stays out after the move,
|
|
2961
|
+
* and code the config checked stays checked. An `include` or `exclude` a config inherits is not edited.
|
|
1985
2962
|
*/
|
|
1986
|
-
function
|
|
2963
|
+
function followTsconfigs(
|
|
1987
2964
|
ts: typeof TS,
|
|
1988
2965
|
root: string,
|
|
1989
|
-
moves:
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
const
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
const
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
const
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2966
|
+
moves: ReadonlyMap<string, string>,
|
|
2967
|
+
layout: ReadonlyArray<{ readonly from: string; readonly to: string }>,
|
|
2968
|
+
): {
|
|
2969
|
+
readonly files: Map<string, { readonly text: string; readonly changes: readonly string[] }>
|
|
2970
|
+
readonly issues: LayoutMigrationIssue[]
|
|
2971
|
+
} {
|
|
2972
|
+
const files = new Map<string, { readonly text: string; readonly changes: readonly string[] }>()
|
|
2973
|
+
const issues: LayoutMigrationIssue[] = []
|
|
2974
|
+
const typescript = layout.filter(({ to }) => TS_FILE.test(to))
|
|
2975
|
+
const names = readdirSync(root)
|
|
2976
|
+
.filter((name) => TSCONFIG.test(name) && isFile(join(root, name)))
|
|
2977
|
+
.sort(codeUnitOrder)
|
|
2978
|
+
// Each config's lists before and after the move, for the configs that extend it.
|
|
2979
|
+
const settled = new Map<string, { readonly before: ProgramLists; readonly after: ProgramLists }>()
|
|
2980
|
+
const pending = [...names]
|
|
2981
|
+
for (let progress = true; progress && pending.length > 0; ) {
|
|
2982
|
+
progress = false
|
|
2983
|
+
for (const name of [...pending]) {
|
|
2984
|
+
const text = readFileSync(join(root, name), "utf8")
|
|
2985
|
+
const source = ts.parseJsonText(name, text)
|
|
2986
|
+
const top = source.statements[0]?.expression
|
|
2987
|
+
if (top === undefined || !ts.isObjectLiteralExpression(top)) {
|
|
2988
|
+
pending.splice(pending.indexOf(name), 1)
|
|
2989
|
+
progress = true
|
|
2990
|
+
if (moves.size > 0)
|
|
2991
|
+
issues.push({
|
|
2992
|
+
file: name,
|
|
2993
|
+
reason: "is not a JSON object; keep the moved files in its include and exclude by hand",
|
|
2994
|
+
})
|
|
2995
|
+
continue
|
|
2996
|
+
}
|
|
2997
|
+
const base = jsonProperty(ts, top, "extends")?.initializer
|
|
2998
|
+
let parent: string | undefined
|
|
2999
|
+
if (base !== undefined && ts.isStringLiteral(base) && base.text.startsWith(".")) {
|
|
3000
|
+
const path = posix.normalize(base.text)
|
|
3001
|
+
const file = path.endsWith(".json") ? path : `${path}.json`
|
|
3002
|
+
if (names.includes(file) && file !== name) parent = file
|
|
3003
|
+
}
|
|
3004
|
+
if (parent !== undefined && pending.includes(parent)) continue
|
|
3005
|
+
pending.splice(pending.indexOf(name), 1)
|
|
3006
|
+
progress = true
|
|
3007
|
+
const inherited = parent === undefined ? undefined : settled.get(parent)
|
|
3008
|
+
const extendsOther = base !== undefined && inherited === undefined
|
|
3009
|
+
|
|
3010
|
+
const edits: Array<{ start: number; end: number; text: string }> = []
|
|
3011
|
+
const own = new Map<string, { before: string[]; after: string[] }>()
|
|
3012
|
+
let malformed: string | undefined
|
|
3013
|
+
for (const key of ["include", "exclude", "files"]) {
|
|
3014
|
+
const array = jsonProperty(ts, top, key)?.initializer
|
|
3015
|
+
if (array === undefined) continue
|
|
3016
|
+
if (!ts.isArrayLiteralExpression(array)) {
|
|
3017
|
+
malformed = key
|
|
3018
|
+
continue
|
|
3019
|
+
}
|
|
3020
|
+
const lists: { before: string[]; after: string[] } = { before: [], after: [] }
|
|
3021
|
+
for (const element of array.elements) {
|
|
3022
|
+
if (!ts.isStringLiteral(element)) continue
|
|
3023
|
+
const to = moves.get(posix.normalize(element.text))
|
|
3024
|
+
lists.before.push(element.text)
|
|
3025
|
+
if (to === undefined) {
|
|
3026
|
+
lists.after.push(element.text)
|
|
3027
|
+
continue
|
|
3028
|
+
}
|
|
3029
|
+
const spelled = element.text.startsWith("./") ? `./${to}` : to
|
|
3030
|
+
lists.after.push(spelled)
|
|
3031
|
+
edits.push({
|
|
3032
|
+
start: element.getStart(source) + 1,
|
|
3033
|
+
end: element.getEnd() - 1,
|
|
3034
|
+
text: spelled,
|
|
3035
|
+
})
|
|
3036
|
+
}
|
|
3037
|
+
own.set(key, lists)
|
|
3038
|
+
}
|
|
3039
|
+
let updated = text
|
|
3040
|
+
for (const edit of edits.sort((a, b) => b.start - a.start))
|
|
3041
|
+
updated = updated.slice(0, edit.start) + edit.text + updated.slice(edit.end)
|
|
3042
|
+
const changes: string[] = []
|
|
3043
|
+
if (edits.length > 0) changes.push(`${edits.length} file path(s) follow their moved files`)
|
|
3044
|
+
|
|
3045
|
+
const list = (key: "include" | "exclude", when: "before" | "after"): string[] | undefined => {
|
|
3046
|
+
const mine = own.get(key)
|
|
3047
|
+
if (mine !== undefined) return mine[when]
|
|
3048
|
+
if (inherited !== undefined) return [...inherited[when][key]]
|
|
3049
|
+
if (extendsOther) return undefined
|
|
3050
|
+
// tsc's default: everything below the config, unless `files` lists the program.
|
|
3051
|
+
return key === "exclude" ? [] : own.has("files") ? [] : ["**/*"]
|
|
3052
|
+
}
|
|
3053
|
+
const filesOf = (when: "before" | "after"): string[] =>
|
|
3054
|
+
(own.get("files")?.[when] ?? []).map((entry) => posix.normalize(entry))
|
|
3055
|
+
const beforeInclude = list("include", "before")
|
|
3056
|
+
const beforeExclude = list("exclude", "before")
|
|
3057
|
+
const afterInclude = list("include", "after")
|
|
3058
|
+
const afterExclude = list("exclude", "after")
|
|
3059
|
+
if (
|
|
3060
|
+
malformed !== undefined ||
|
|
3061
|
+
beforeInclude === undefined ||
|
|
3062
|
+
beforeExclude === undefined ||
|
|
3063
|
+
afterInclude === undefined ||
|
|
3064
|
+
afterExclude === undefined
|
|
3065
|
+
) {
|
|
3066
|
+
if (malformed !== undefined && moves.size > 0)
|
|
3067
|
+
issues.push({
|
|
3068
|
+
file: name,
|
|
3069
|
+
reason: `"${malformed}" is not an array; keep the moved files in its program by hand`,
|
|
3070
|
+
})
|
|
3071
|
+
if (edits.length > 0) files.set(name, { text: updated, changes })
|
|
3072
|
+
continue
|
|
3073
|
+
}
|
|
3074
|
+
const before: ProgramLists = {
|
|
3075
|
+
include: beforeInclude,
|
|
3076
|
+
exclude: beforeExclude,
|
|
3077
|
+
files: filesOf("before"),
|
|
3078
|
+
}
|
|
3079
|
+
const after = { include: afterInclude, exclude: afterExclude, files: filesOf("after") }
|
|
3080
|
+
const added: { include: string[]; exclude: string[] } = { include: [], exclude: [] }
|
|
3081
|
+
let stranded: { readonly from: string; readonly to: string; readonly key: string } | undefined
|
|
3082
|
+
let strandedCount = 0
|
|
3083
|
+
const quiet = new Map<string, boolean>()
|
|
3084
|
+
// The whole zone folder is the entry that keeps covering code added there later, but only when
|
|
3085
|
+
// it takes in no file the config left out.
|
|
3086
|
+
const takesInOnlyChecked = (folder: string): boolean => {
|
|
3087
|
+
let safe = quiet.get(folder)
|
|
3088
|
+
if (safe === undefined) {
|
|
3089
|
+
const wider = { ...after, include: [...after.include, folder] }
|
|
3090
|
+
safe = typescript.every(
|
|
3091
|
+
(file) =>
|
|
3092
|
+
!matches(folder, file.to) ||
|
|
3093
|
+
inProgram(before, file.from) ||
|
|
3094
|
+
!inProgram(wider, file.to),
|
|
3095
|
+
)
|
|
3096
|
+
quiet.set(folder, safe)
|
|
3097
|
+
}
|
|
3098
|
+
return safe
|
|
3099
|
+
}
|
|
3100
|
+
for (const { from, to } of typescript) {
|
|
3101
|
+
if (!inProgram(before, from) || inProgram(after, to)) continue
|
|
3102
|
+
if (after.include.some((pattern) => matches(pattern, to))) {
|
|
3103
|
+
// Its new place sits under an `exclude` that did not hold the old one.
|
|
3104
|
+
stranded ??= { from, to, key: "exclude" }
|
|
3105
|
+
strandedCount++
|
|
3106
|
+
continue
|
|
3107
|
+
}
|
|
3108
|
+
const zone = to.includes("/") ? to.slice(0, to.indexOf("/")) : undefined
|
|
3109
|
+
const entry =
|
|
3110
|
+
zone !== undefined && takesInOnlyChecked(zone)
|
|
3111
|
+
? zone
|
|
3112
|
+
: twinEntry(
|
|
3113
|
+
before.include.find((pattern) => matches(pattern, from)),
|
|
3114
|
+
to,
|
|
3115
|
+
)
|
|
3116
|
+
added.include.push(entry)
|
|
3117
|
+
after.include.push(entry)
|
|
3118
|
+
}
|
|
3119
|
+
for (const { from, to } of typescript) {
|
|
3120
|
+
if (inProgram(before, from) || !inProgram(after, to)) continue
|
|
3121
|
+
let entry = twinEntry(
|
|
3122
|
+
before.exclude.find((pattern) => matches(pattern, from)),
|
|
3123
|
+
to,
|
|
3124
|
+
)
|
|
3125
|
+
// A twin that would also drop a file the config checked is too wide: name the file.
|
|
3126
|
+
if (
|
|
3127
|
+
entry !== to &&
|
|
3128
|
+
typescript.some((other) => matches(entry, other.to) && inProgram(before, other.from))
|
|
3129
|
+
)
|
|
3130
|
+
entry = to
|
|
3131
|
+
added.exclude.push(entry)
|
|
3132
|
+
after.exclude.push(entry)
|
|
3133
|
+
}
|
|
3134
|
+
for (const key of ["include", "exclude"] as const) {
|
|
3135
|
+
const entries = [...new Set(added[key])].sort(codeUnitOrder)
|
|
3136
|
+
if (entries.length === 0) continue
|
|
3137
|
+
const reparsed = ts.parseJsonText(name, updated)
|
|
3138
|
+
const object = reparsed.statements[0]?.expression
|
|
3139
|
+
const array =
|
|
3140
|
+
object !== undefined && ts.isObjectLiteralExpression(object)
|
|
3141
|
+
? jsonProperty(ts, object, key)?.initializer
|
|
3142
|
+
: undefined
|
|
3143
|
+
if (array === undefined || !ts.isArrayLiteralExpression(array)) {
|
|
3144
|
+
stranded ??= { from: "", to: entries[0] ?? "", key }
|
|
3145
|
+
strandedCount += entries.length
|
|
3146
|
+
continue
|
|
3147
|
+
}
|
|
3148
|
+
updated = appendToArray(reparsed, updated, array, entries)
|
|
3149
|
+
changes.push(`${key} += ${entries.join(", ")}`)
|
|
3150
|
+
}
|
|
3151
|
+
if (stranded !== undefined)
|
|
3152
|
+
issues.push({
|
|
3153
|
+
file: name,
|
|
3154
|
+
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`,
|
|
3155
|
+
})
|
|
3156
|
+
settled.set(name, { before, after })
|
|
3157
|
+
if (changes.length > 0) files.set(name, { text: updated, changes })
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
return { files, issues }
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
/** `text` with `entries` appended to `array`: one per line in a multi-line array, else inline. */
|
|
3164
|
+
function appendToArray(
|
|
3165
|
+
source: TS.JsonSourceFile,
|
|
3166
|
+
text: string,
|
|
3167
|
+
array: TS.ArrayLiteralExpression,
|
|
3168
|
+
entries: readonly string[],
|
|
3169
|
+
): string {
|
|
3170
|
+
const quoted = entries.map((entry) => JSON.stringify(entry))
|
|
2014
3171
|
const last = array.elements[array.elements.length - 1]
|
|
2015
3172
|
if (last === undefined) {
|
|
2016
3173
|
const at = array.getStart(source) + 1
|
|
2017
|
-
return
|
|
3174
|
+
return `${text.slice(0, at)}${quoted.join(", ")}${text.slice(at)}`
|
|
2018
3175
|
}
|
|
2019
3176
|
let at = last.end
|
|
2020
3177
|
const trailing = /^\s*,/.exec(text.slice(at))
|
|
@@ -2031,15 +3188,12 @@ function coverMovedFiles(
|
|
|
2031
3188
|
trailing !== null
|
|
2032
3189
|
? text.slice(0, eol)
|
|
2033
3190
|
: `${text.slice(0, last.end)},${text.slice(last.end, eol)}`
|
|
2034
|
-
const
|
|
2035
|
-
return {
|
|
2036
|
-
text: `${head}\n${indent}${entries}${trailing !== null ? "," : ""}${text.slice(eol)}`,
|
|
2037
|
-
added,
|
|
2038
|
-
}
|
|
3191
|
+
const lines = quoted.join(`,\n${indent}`)
|
|
3192
|
+
return `${head}\n${indent}${lines}${trailing !== null ? "," : ""}${text.slice(eol)}`
|
|
2039
3193
|
}
|
|
2040
3194
|
}
|
|
2041
3195
|
const insert = trailing !== null ? ` ${quoted.join(", ")},` : `, ${quoted.join(", ")}`
|
|
2042
|
-
return
|
|
3196
|
+
return `${text.slice(0, at)}${insert}${text.slice(at)}`
|
|
2043
3197
|
}
|
|
2044
3198
|
|
|
2045
3199
|
const jsonProperty = (
|
|
@@ -2072,6 +3226,111 @@ function insertProperty(
|
|
|
2072
3226
|
return `${text.slice(0, last.end)},\n${indent}${entry}${text.slice(last.end)}`
|
|
2073
3227
|
}
|
|
2074
3228
|
|
|
3229
|
+
/**
|
|
3230
|
+
* The `nifra.zones` entries that keep `inPlace` files where they are: for each, the widest folder
|
|
3231
|
+
* above it whose zoned files all take its side (a nested package stops the climb), else the file
|
|
3232
|
+
* itself. A file another side's suffix or a server built-in claims counts against a folder.
|
|
3233
|
+
*/
|
|
3234
|
+
function zoneDeclarations(
|
|
3235
|
+
inPlace: ReadonlyMap<string, FolderSide>,
|
|
3236
|
+
files: readonly string[],
|
|
3237
|
+
text: (file: string) => string | undefined,
|
|
3238
|
+
configured: (file: string) => boolean,
|
|
3239
|
+
): Map<string, FolderSide> {
|
|
3240
|
+
const sides = new Map<string, Set<string>>()
|
|
3241
|
+
const packages = new Set<string>()
|
|
3242
|
+
for (const file of files) {
|
|
3243
|
+
const top = file.split("/")[0] ?? ""
|
|
3244
|
+
if (!file.includes("/") || ZONE_DIRS.has(top)) continue
|
|
3245
|
+
if (posix.basename(file) === "package.json") {
|
|
3246
|
+
for (let dir = posix.dirname(file); dir !== "."; dir = posix.dirname(dir)) packages.add(dir)
|
|
3247
|
+
continue
|
|
3248
|
+
}
|
|
3249
|
+
// A configured path is more specific than any folder above it, so it never counts against one.
|
|
3250
|
+
if (isTestFile(file) || file.endsWith(".d.ts") || configured(file)) continue
|
|
3251
|
+
const suffix = /\.(frontend|backend|shared|fn)\.[cm]?[jt]sx?$/.exec(file)?.[1]
|
|
3252
|
+
const side =
|
|
3253
|
+
inPlace.get(file) ??
|
|
3254
|
+
(suffix === "fn" ? "backend" : suffix) ??
|
|
3255
|
+
(SOURCE_FILE.test(file) && /from\s+["'](?:node:|bun:)/.test(text(file) ?? "")
|
|
3256
|
+
? "backend"
|
|
3257
|
+
: undefined)
|
|
3258
|
+
if (side === undefined) continue
|
|
3259
|
+
for (let dir = posix.dirname(file); dir !== "."; dir = posix.dirname(dir))
|
|
3260
|
+
sides.set(dir, (sides.get(dir) ?? new Set<string>()).add(side))
|
|
3261
|
+
}
|
|
3262
|
+
const declared = new Map<string, FolderSide>()
|
|
3263
|
+
for (const [file, side] of [...inPlace].sort(([a], [b]) => codeUnitOrder(a, b))) {
|
|
3264
|
+
const parts = file.split("/")
|
|
3265
|
+
let path = file
|
|
3266
|
+
for (let depth = 1; depth < parts.length; depth++) {
|
|
3267
|
+
const dir = parts.slice(0, depth).join("/")
|
|
3268
|
+
const found = sides.get(dir)
|
|
3269
|
+
if (!packages.has(dir) && found?.size === 1 && found.has(side)) {
|
|
3270
|
+
path = dir
|
|
3271
|
+
break
|
|
3272
|
+
}
|
|
3273
|
+
}
|
|
3274
|
+
declared.set(path, side)
|
|
3275
|
+
}
|
|
3276
|
+
return declared
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
/** The app's package.json with `paths` added to `"nifra": { "zones": ... }`, editing only where they go. */
|
|
3280
|
+
function declareZones(
|
|
3281
|
+
ts: typeof TS,
|
|
3282
|
+
root: string,
|
|
3283
|
+
paths: ReadonlyMap<string, FolderSide>,
|
|
3284
|
+
): { text?: string; issue?: string } {
|
|
3285
|
+
const listed = (side: FolderSide): string[] =>
|
|
3286
|
+
[...paths].flatMap(([path, of]) => (of === side ? [path] : []))
|
|
3287
|
+
const setting = `{ ${FOLDER_SIDES.filter((side) => listed(side).length > 0)
|
|
3288
|
+
.map((side) => `"${side}": ${JSON.stringify(listed(side))}`)
|
|
3289
|
+
.join(", ")} }`
|
|
3290
|
+
const path = join(root, "package.json")
|
|
3291
|
+
if (!existsSync(path))
|
|
3292
|
+
return { issue: `there is none in the app; declare "nifra": { "zones": ${setting} } in it` }
|
|
3293
|
+
let text = readFileSync(path, "utf8")
|
|
3294
|
+
// Parsed again for each edit: an edit moves every offset after it.
|
|
3295
|
+
const find = (keys: readonly string[]) => {
|
|
3296
|
+
const source = ts.parseJsonText(path, text)
|
|
3297
|
+
let node: TS.Expression | undefined = source.statements[0]?.expression
|
|
3298
|
+
for (const key of keys)
|
|
3299
|
+
node =
|
|
3300
|
+
node !== undefined && ts.isObjectLiteralExpression(node)
|
|
3301
|
+
? jsonProperty(ts, node, key)?.initializer
|
|
3302
|
+
: undefined
|
|
3303
|
+
return { source, node }
|
|
3304
|
+
}
|
|
3305
|
+
const top = find([])
|
|
3306
|
+
if (top.node === undefined || !ts.isObjectLiteralExpression(top.node))
|
|
3307
|
+
return { issue: `is not a JSON object; add "nifra": { "zones": ${setting} } to it by hand` }
|
|
3308
|
+
const nifra = find(["nifra"])
|
|
3309
|
+
if (nifra.node === undefined)
|
|
3310
|
+
return { text: insertProperty(top.source, text, top.node, `"nifra": { "zones": ${setting} }`) }
|
|
3311
|
+
if (!ts.isObjectLiteralExpression(nifra.node))
|
|
3312
|
+
return { issue: `"nifra" is not an object; add "zones": ${setting} to it by hand` }
|
|
3313
|
+
const zones = find(["nifra", "zones"])
|
|
3314
|
+
if (zones.node === undefined)
|
|
3315
|
+
return { text: insertProperty(nifra.source, text, nifra.node, `"zones": ${setting}`) }
|
|
3316
|
+
for (const side of FOLDER_SIDES) {
|
|
3317
|
+
const add = listed(side)
|
|
3318
|
+
if (add.length === 0) continue
|
|
3319
|
+
const { source, node } = find(["nifra", "zones"])
|
|
3320
|
+
if (node === undefined || !ts.isObjectLiteralExpression(node))
|
|
3321
|
+
return { issue: `"nifra.zones" is not an object; add ${setting} to it by hand` }
|
|
3322
|
+
const array = jsonProperty(ts, node, side)?.initializer
|
|
3323
|
+
if (array === undefined)
|
|
3324
|
+
text = insertProperty(source, text, node, `"${side}": ${JSON.stringify(add)}`)
|
|
3325
|
+
else if (ts.isArrayLiteralExpression(array)) text = appendToArray(source, text, array, add)
|
|
3326
|
+
else
|
|
3327
|
+
return {
|
|
3328
|
+
issue: `"nifra.zones.${side}" is not an array; add ${JSON.stringify(add)} to it by hand`,
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
return { text }
|
|
3332
|
+
}
|
|
3333
|
+
|
|
2075
3334
|
/** The app's package.json with `"nifra": { "kind": "api" }` added, editing only where it goes. */
|
|
2076
3335
|
function declareApiKind(ts: typeof TS, root: string): { text?: string; issue?: string } {
|
|
2077
3336
|
const path = join(root, "package.json")
|