@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/dist/migrate-layout.js
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 { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
27
29
|
import { builtinModules } from "node:module";
|
|
@@ -30,10 +32,13 @@ import { BACKEND_ROUTE_EXPORTS } from "@nifrajs/web/route-manifest";
|
|
|
30
32
|
import { parseZoneConfig } from "@nifrajs/web/zones";
|
|
31
33
|
import { BACKEND_APP_FILE, CONFIG_FILE, FRAMEWORK_FILE, retiredRootFiles } from "./app-files.js";
|
|
32
34
|
import { codeUnitOrder } from "./internal/code-unit-order.js";
|
|
35
|
+
import { configPathReader } from "./internal/config-paths.js";
|
|
33
36
|
import { createKindProbe, hasWebConfig } from "./internal/nifra-project.js";
|
|
34
|
-
import { createSpecifierResolver } from "./internal/project-specifiers.js";
|
|
37
|
+
import { createSpecifierResolver, isFile } from "./internal/project-specifiers.js";
|
|
35
38
|
import { importTypeScript } from "./internal/typescript-import.js";
|
|
39
|
+
import { planOutputSchemas } from "./output-schemas.js";
|
|
36
40
|
import { missingOutputSchemas } from "./rules/data-guard.js";
|
|
41
|
+
import { apiPrefixFix } from "./rules/pages.js";
|
|
37
42
|
/** Exports only the server or the build reads: `@nifrajs/web`'s route-pair contract. */
|
|
38
43
|
const BACKEND_EXPORTS = BACKEND_ROUTE_EXPORTS;
|
|
39
44
|
const ROUTE_FILE = /\.(?:tsx|jsx|svelte|vue|mdx)$/;
|
|
@@ -60,6 +65,7 @@ const TOOLING_FILE = /(?:\.(?:json|jsonc|ya?ml|toml|sh|bash|zsh|ps1)|(?:^|\/)(?:
|
|
|
60
65
|
const SELF_LOCATION = /\bimport\.meta\.(?:dirname|dir|filename|url)\b|\b__(?:dirname|filename)\b/;
|
|
61
66
|
const SKIP_DIRS = new Set(["node_modules", "dist", "build", "coverage", ".git", ".vite", "public"]);
|
|
62
67
|
const ZONE_DIRS = new Set(["frontend", "backend", "shared", "routes", "public"]);
|
|
68
|
+
const FOLDER_SIDES = ["frontend", "backend", "shared"];
|
|
63
69
|
/** Data other tools may read by path too; reported, never moved. Images and fonts still move. */
|
|
64
70
|
const DATA_FILE = /\.(?:json|jsonc|json5|ndjson|ya?ml|toml|csv|tsv|txt|xml|sql)$/;
|
|
65
71
|
/** Test-runner calls that name a module by path, as an import does. */
|
|
@@ -76,7 +82,28 @@ const MOCK_CALLS = new Set([
|
|
|
76
82
|
"jest.unstable_mockModule",
|
|
77
83
|
]);
|
|
78
84
|
const TAILWIND_CONFIG = /^tailwind\.config\.[cm]?[jt]s$/;
|
|
85
|
+
/** What a `x.test.ts` beside `x.<ext>` tests. */
|
|
86
|
+
const SUBJECT_EXTENSIONS = [
|
|
87
|
+
".ts",
|
|
88
|
+
".tsx",
|
|
89
|
+
".mts",
|
|
90
|
+
".cts",
|
|
91
|
+
".js",
|
|
92
|
+
".jsx",
|
|
93
|
+
".mjs",
|
|
94
|
+
".cjs",
|
|
95
|
+
".svelte",
|
|
96
|
+
".vue",
|
|
97
|
+
];
|
|
79
98
|
const toPosix = (path) => path.replaceAll("\\", "/");
|
|
99
|
+
/** A specifier without its `?query` or `#hash`; a leading `#` names a package.json import. Cut by
|
|
100
|
+
* index, since `/[?#].*$/` retries from every `#` and is quadratic on a long run of them. */
|
|
101
|
+
const withoutQuery = (specifier) => {
|
|
102
|
+
const query = specifier.indexOf("?", 1);
|
|
103
|
+
const hash = specifier.indexOf("#", 1);
|
|
104
|
+
const cut = query === -1 ? hash : hash === -1 ? query : Math.min(query, hash);
|
|
105
|
+
return cut === -1 ? specifier : specifier.slice(0, cut);
|
|
106
|
+
};
|
|
80
107
|
/** Every first-party file under `root`, app-relative and posix. */
|
|
81
108
|
function listFiles(root) {
|
|
82
109
|
const out = [];
|
|
@@ -164,6 +191,7 @@ function describeStatements(ts, sf) {
|
|
|
164
191
|
return sf.statements.map((node) => {
|
|
165
192
|
const declares = new Set();
|
|
166
193
|
const exports = new Map();
|
|
194
|
+
const listed = [];
|
|
167
195
|
const exported = hasModifier(ts, node, ts.SyntaxKind.ExportKeyword);
|
|
168
196
|
const isDefault = hasModifier(ts, node, ts.SyntaxKind.DefaultKeyword);
|
|
169
197
|
const addExport = (local, as) => {
|
|
@@ -209,7 +237,10 @@ function describeStatements(ts, sf) {
|
|
|
209
237
|
node.exportClause &&
|
|
210
238
|
ts.isNamedExports(node.exportClause)) {
|
|
211
239
|
for (const el of node.exportClause.elements) {
|
|
212
|
-
|
|
240
|
+
const local = (el.propertyName ?? el.name).text;
|
|
241
|
+
addExport(node.moduleSpecifier ? `\0${el.name.text}` : local, el.name.text);
|
|
242
|
+
if (node.moduleSpecifier === undefined)
|
|
243
|
+
listed.push({ exported: el.name.text, local, type: node.isTypeOnly || el.isTypeOnly });
|
|
213
244
|
}
|
|
214
245
|
}
|
|
215
246
|
const refs = ts.isImportDeclaration(node)
|
|
@@ -226,32 +257,58 @@ function describeStatements(ts, sf) {
|
|
|
226
257
|
exports,
|
|
227
258
|
valueRefs: refs.value,
|
|
228
259
|
typeRefs: refs.type,
|
|
260
|
+
listed,
|
|
229
261
|
};
|
|
230
262
|
});
|
|
231
263
|
}
|
|
232
|
-
/**
|
|
233
|
-
|
|
264
|
+
/**
|
|
265
|
+
* Rebuild an import keeping only the bindings `uses` names; `undefined` when none remain. Bindings
|
|
266
|
+
* read only in types become `import type`, so no bundler loads the module for them: always in a
|
|
267
|
+
* `fresh` module, and in the page only where the split narrowed the import.
|
|
268
|
+
*/
|
|
269
|
+
function filterImport(ts, sf, node, uses, fresh) {
|
|
234
270
|
const clause = node.importClause;
|
|
235
271
|
const spec = node.moduleSpecifier.getText(sf);
|
|
236
272
|
const attributes = node.attributes ? ` ${node.attributes.getText(sf)}` : "";
|
|
237
273
|
if (clause === undefined)
|
|
238
274
|
return node.getFullText(sf);
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
275
|
+
const heads = [];
|
|
276
|
+
const named = [];
|
|
277
|
+
let total = 0;
|
|
278
|
+
const add = (into, name, text, bare) => {
|
|
279
|
+
total++;
|
|
280
|
+
if (uses.value.has(name) || uses.type.has(name))
|
|
281
|
+
into.push({ text, bare, typeOnly: !uses.value.has(name) });
|
|
282
|
+
};
|
|
283
|
+
if (clause.name)
|
|
284
|
+
add(heads, clause.name.text, clause.name.text, clause.name.text);
|
|
242
285
|
const bindings = clause.namedBindings;
|
|
243
|
-
if (bindings && ts.isNamespaceImport(bindings)
|
|
244
|
-
|
|
286
|
+
if (bindings && ts.isNamespaceImport(bindings)) {
|
|
287
|
+
const text = `* as ${bindings.name.text}`;
|
|
288
|
+
add(heads, bindings.name.text, text, text);
|
|
289
|
+
}
|
|
245
290
|
if (bindings && ts.isNamedImports(bindings)) {
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
291
|
+
for (const el of bindings.elements) {
|
|
292
|
+
const renamed = el.propertyName ? `${el.propertyName.getText(sf)} as ` : "";
|
|
293
|
+
add(named, el.name.text, el.getText(sf), `${renamed}${el.name.text}`);
|
|
294
|
+
}
|
|
249
295
|
}
|
|
250
|
-
|
|
296
|
+
const kept = [...heads, ...named];
|
|
297
|
+
if (kept.length === 0)
|
|
251
298
|
return undefined;
|
|
252
|
-
const typeOnly = clause.isTypeOnly ? "type " : "";
|
|
253
299
|
const leading = node.getFullText(sf).slice(0, node.getStart(sf) - node.getFullStart());
|
|
254
|
-
|
|
300
|
+
const statement = (typeOnly, body) => `import ${typeOnly ? "type " : ""}${body} from ${spec}${attributes}${terminatorOf(sf, node)}`;
|
|
301
|
+
const list = (parts, text) => parts.length === 0 ? [] : [`{ ${parts.map(text).join(", ")} }`];
|
|
302
|
+
if (!clause.isTypeOnly &&
|
|
303
|
+
node.attributes === undefined &&
|
|
304
|
+
(fresh || kept.length < total) &&
|
|
305
|
+
kept.every((part) => part.typeOnly)) {
|
|
306
|
+
// `import type` takes one default, namespace or list, so each goes in its own statement.
|
|
307
|
+
const bodies = [...heads.map((part) => part.bare), ...list(named, (part) => part.bare)];
|
|
308
|
+
return `${leading}${bodies.map((body) => statement(true, body)).join("\n")}`;
|
|
309
|
+
}
|
|
310
|
+
const body = [...heads.map((part) => part.text), ...list(named, (part) => part.text)];
|
|
311
|
+
return `${leading}${statement(clause.isTypeOnly, body.join(", "))}`;
|
|
255
312
|
}
|
|
256
313
|
/** The `;` a statement was written with, so a rebuilt one keeps the file's style. */
|
|
257
314
|
const terminatorOf = (sf, node) => node.getText(sf).endsWith(";") ? ";" : "";
|
|
@@ -272,11 +329,108 @@ const joinStatements = (texts) => {
|
|
|
272
329
|
const body = texts.join("").replace(/^\s*\n/, "");
|
|
273
330
|
return body.endsWith("\n") ? body : `${body}\n`;
|
|
274
331
|
};
|
|
332
|
+
const PURE_OBJECT_CALLS = new Set(["keys", "values", "entries", "freeze", "fromEntries"]);
|
|
333
|
+
/**
|
|
334
|
+
* Whether `node` builds constant data: literals, arrays and objects of it, operators over it,
|
|
335
|
+
* `Object.keys`-style reads, and the file's own top-level names, which go into `reads`. `undefined`
|
|
336
|
+
* when it is data; else the import it reads, or `""` for anything else (a call, a function, a global).
|
|
337
|
+
*/
|
|
338
|
+
function dataBlocker(ts, node, isLocal, reads) {
|
|
339
|
+
const data = (n) => {
|
|
340
|
+
if (ts.isStringLiteral(n) ||
|
|
341
|
+
ts.isNumericLiteral(n) ||
|
|
342
|
+
ts.isBigIntLiteral(n) ||
|
|
343
|
+
ts.isNoSubstitutionTemplateLiteral(n) ||
|
|
344
|
+
n.kind === ts.SyntaxKind.TrueKeyword ||
|
|
345
|
+
n.kind === ts.SyntaxKind.FalseKeyword ||
|
|
346
|
+
n.kind === ts.SyntaxKind.NullKeyword)
|
|
347
|
+
return undefined;
|
|
348
|
+
if (ts.isIdentifier(n)) {
|
|
349
|
+
if (isLocal(n.text)) {
|
|
350
|
+
reads.add(n.text);
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
return n.text === "undefined" ? undefined : n.text;
|
|
354
|
+
}
|
|
355
|
+
const every = (nodes) => {
|
|
356
|
+
for (const child of nodes) {
|
|
357
|
+
const blocker = data(child);
|
|
358
|
+
if (blocker !== undefined)
|
|
359
|
+
return blocker;
|
|
360
|
+
}
|
|
361
|
+
return undefined;
|
|
362
|
+
};
|
|
363
|
+
if (ts.isTemplateExpression(n))
|
|
364
|
+
return every(n.templateSpans.map((span) => span.expression));
|
|
365
|
+
if (ts.isPrefixUnaryExpression(n))
|
|
366
|
+
return n.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
367
|
+
n.operator === ts.SyntaxKind.MinusMinusToken
|
|
368
|
+
? ""
|
|
369
|
+
: data(n.operand);
|
|
370
|
+
if (ts.isBinaryExpression(n)) {
|
|
371
|
+
const operator = n.operatorToken.kind;
|
|
372
|
+
const assigns = operator >= ts.SyntaxKind.FirstAssignment && operator <= ts.SyntaxKind.LastAssignment;
|
|
373
|
+
if (assigns ||
|
|
374
|
+
operator === ts.SyntaxKind.CommaToken ||
|
|
375
|
+
operator === ts.SyntaxKind.InKeyword ||
|
|
376
|
+
operator === ts.SyntaxKind.InstanceOfKeyword)
|
|
377
|
+
return "";
|
|
378
|
+
return every([n.left, n.right]);
|
|
379
|
+
}
|
|
380
|
+
if (ts.isConditionalExpression(n))
|
|
381
|
+
return every([n.condition, n.whenTrue, n.whenFalse]);
|
|
382
|
+
if (ts.isArrayLiteralExpression(n))
|
|
383
|
+
return every(n.elements.flatMap((element) => ts.isOmittedExpression(element)
|
|
384
|
+
? []
|
|
385
|
+
: [ts.isSpreadElement(element) ? element.expression : element]));
|
|
386
|
+
if (ts.isObjectLiteralExpression(n)) {
|
|
387
|
+
for (const property of n.properties) {
|
|
388
|
+
const blocker = ts.isPropertyAssignment(property)
|
|
389
|
+
? ((ts.isComputedPropertyName(property.name)
|
|
390
|
+
? data(property.name.expression)
|
|
391
|
+
: undefined) ?? data(property.initializer))
|
|
392
|
+
: ts.isShorthandPropertyAssignment(property)
|
|
393
|
+
? property.objectAssignmentInitializer === undefined
|
|
394
|
+
? data(property.name)
|
|
395
|
+
: ""
|
|
396
|
+
: ts.isSpreadAssignment(property)
|
|
397
|
+
? data(property.expression)
|
|
398
|
+
: "";
|
|
399
|
+
if (blocker !== undefined)
|
|
400
|
+
return blocker;
|
|
401
|
+
}
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
if (ts.isParenthesizedExpression(n) ||
|
|
405
|
+
ts.isAsExpression(n) ||
|
|
406
|
+
ts.isSatisfiesExpression(n) ||
|
|
407
|
+
ts.isNonNullExpression(n) ||
|
|
408
|
+
ts.isTypeAssertionExpression(n))
|
|
409
|
+
return data(n.expression);
|
|
410
|
+
if (ts.isPropertyAccessExpression(n))
|
|
411
|
+
return ts.isPrivateIdentifier(n.name) ? "" : data(n.expression);
|
|
412
|
+
if (ts.isElementAccessExpression(n))
|
|
413
|
+
return every([n.expression, n.argumentExpression]);
|
|
414
|
+
if (ts.isCallExpression(n)) {
|
|
415
|
+
const callee = n.expression;
|
|
416
|
+
const objectRead = ts.isPropertyAccessExpression(callee) &&
|
|
417
|
+
ts.isIdentifier(callee.expression) &&
|
|
418
|
+
callee.expression.text === "Object" &&
|
|
419
|
+
!isLocal("Object") &&
|
|
420
|
+
PURE_OBJECT_CALLS.has(callee.name.text) &&
|
|
421
|
+
n.questionDotToken === undefined &&
|
|
422
|
+
n.typeArguments === undefined;
|
|
423
|
+
return objectRead ? every(n.arguments) : "";
|
|
424
|
+
}
|
|
425
|
+
return "";
|
|
426
|
+
};
|
|
427
|
+
return data(node);
|
|
428
|
+
}
|
|
275
429
|
/**
|
|
276
430
|
* Split one route module's source into its frontend and backend halves. `backendSpecifier` is how the
|
|
277
431
|
* frontend refers to the backend half in an `import type` (`./index.backend.ts`).
|
|
278
432
|
*/
|
|
279
|
-
export function splitRouteSource(ts, source, fileName, backendSpecifier) {
|
|
433
|
+
export function splitRouteSource(ts, source, fileName, backendSpecifier, options = {}) {
|
|
280
434
|
const kind = fileName.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
281
435
|
const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, kind);
|
|
282
436
|
const statements = describeStatements(ts, sf);
|
|
@@ -287,72 +441,175 @@ export function splitRouteSource(ts, source, fileName, backendSpecifier) {
|
|
|
287
441
|
if (moved.length === 0)
|
|
288
442
|
return { frontend: source, backend: undefined, moved: [], issues: [] };
|
|
289
443
|
const declaredBy = new Map();
|
|
444
|
+
const importedBy = new Map();
|
|
290
445
|
for (const info of statements) {
|
|
291
|
-
|
|
292
|
-
continue;
|
|
446
|
+
const into = ts.isImportDeclaration(info.node) ? importedBy : declaredBy;
|
|
293
447
|
for (const name of info.declares)
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
// The backend half: its export statements plus every top-level declaration they reach.
|
|
297
|
-
const backendSet = new Set();
|
|
298
|
-
const queue = statements.filter(isBackendStatement);
|
|
299
|
-
for (const info of queue)
|
|
300
|
-
backendSet.add(info);
|
|
301
|
-
while (queue.length > 0) {
|
|
302
|
-
const info = queue.pop();
|
|
303
|
-
for (const name of [...info.valueRefs, ...info.typeRefs]) {
|
|
304
|
-
const target = declaredBy.get(name);
|
|
305
|
-
if (target !== undefined && !backendSet.has(target)) {
|
|
306
|
-
backendSet.add(target);
|
|
307
|
-
queue.push(target);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
// An exported-name statement (`export { loader }`) reaches the declaration it names.
|
|
311
|
-
for (const local of info.exports.keys()) {
|
|
312
|
-
const target = declaredBy.get(local);
|
|
313
|
-
if (target !== undefined && !backendSet.has(target)) {
|
|
314
|
-
backendSet.add(target);
|
|
315
|
-
queue.push(target);
|
|
316
|
-
}
|
|
317
|
-
}
|
|
448
|
+
into.set(name, info);
|
|
318
449
|
}
|
|
319
|
-
|
|
320
|
-
|
|
450
|
+
const isTypeDeclaration = (info) => ts.isInterfaceDeclaration(info.node) || ts.isTypeAliasDeclaration(info.node);
|
|
451
|
+
/** What `info` reads for one half: an export list reads only the locals it exports there. */
|
|
452
|
+
const readsFor = (info, backend) => {
|
|
453
|
+
if (info.listed.length === 0)
|
|
454
|
+
return { value: info.valueRefs, type: info.typeRefs };
|
|
455
|
+
const mine = info.listed.filter((entry) => BACKEND_EXPORTS.has(entry.exported) === backend);
|
|
456
|
+
return {
|
|
457
|
+
value: new Set(mine.filter((entry) => !entry.type).map((entry) => entry.local)),
|
|
458
|
+
type: new Set(mine.filter((entry) => entry.type).map((entry) => entry.local)),
|
|
459
|
+
};
|
|
460
|
+
};
|
|
321
461
|
const movedLocals = new Set();
|
|
322
462
|
for (const info of statements) {
|
|
323
463
|
for (const [local, names] of info.exports)
|
|
324
464
|
if (names.some((name) => BACKEND_EXPORTS.has(name)))
|
|
325
465
|
movedLocals.add(local);
|
|
326
466
|
}
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
(!isBackendStatement(info) && exportsOf(info).length > 0) ||
|
|
331
|
-
// a statement with no declaration (a side effect) stays where it ran
|
|
332
|
-
(info.declares.size === 0 && info.exports.size === 0));
|
|
333
|
-
const fqueue = [...seed];
|
|
334
|
-
for (const info of fqueue)
|
|
335
|
-
frontendKeep.add(info);
|
|
336
|
-
while (fqueue.length > 0) {
|
|
337
|
-
const info = fqueue.pop();
|
|
338
|
-
for (const name of info.valueRefs) {
|
|
339
|
-
if (movedLocals.has(name) && !info.declares.has(name))
|
|
340
|
-
continue;
|
|
467
|
+
/** Each half's statements. What `shared` holds, both import, so neither half keeps a copy. */
|
|
468
|
+
const partition = (shared) => {
|
|
469
|
+
const local = (name) => {
|
|
341
470
|
const target = declaredBy.get(name);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
471
|
+
return target === undefined || shared.has(target) ? undefined : target;
|
|
472
|
+
};
|
|
473
|
+
// The backend half: its export statements plus every top-level declaration they reach.
|
|
474
|
+
const backendSet = new Set();
|
|
475
|
+
const queue = statements.filter(isBackendStatement);
|
|
476
|
+
for (const info of queue)
|
|
477
|
+
backendSet.add(info);
|
|
478
|
+
while (queue.length > 0) {
|
|
479
|
+
const info = queue.pop();
|
|
480
|
+
const reads = readsFor(info, true);
|
|
481
|
+
for (const name of [...reads.value, ...reads.type]) {
|
|
482
|
+
const target = local(name);
|
|
483
|
+
if (target !== undefined && !backendSet.has(target)) {
|
|
484
|
+
backendSet.add(target);
|
|
485
|
+
queue.push(target);
|
|
486
|
+
}
|
|
345
487
|
}
|
|
346
488
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
489
|
+
// The frontend half: everything that is not a backend export statement, minus declarations that
|
|
490
|
+
// only the backend half needs. An export list that also names backend exports keeps the rest.
|
|
491
|
+
const frontendKeep = new Set();
|
|
492
|
+
const seed = statements.filter((info) => !ts.isImportDeclaration(info.node) &&
|
|
493
|
+
!shared.has(info) &&
|
|
494
|
+
(!backendSet.has(info) ||
|
|
495
|
+
exportsOf(info).some((name) => !BACKEND_EXPORTS.has(name)) ||
|
|
496
|
+
// a statement with no declaration (a side effect) stays where it ran
|
|
497
|
+
(info.declares.size === 0 && info.exports.size === 0)));
|
|
498
|
+
const fqueue = [...seed];
|
|
499
|
+
for (const info of fqueue)
|
|
500
|
+
frontendKeep.add(info);
|
|
501
|
+
while (fqueue.length > 0) {
|
|
502
|
+
const info = fqueue.pop();
|
|
503
|
+
const reads = readsFor(info, false);
|
|
504
|
+
for (const name of reads.value) {
|
|
505
|
+
if (movedLocals.has(name) && !info.declares.has(name))
|
|
506
|
+
continue;
|
|
507
|
+
const target = local(name);
|
|
508
|
+
if (target !== undefined && !frontendKeep.has(target) && !isBackendStatement(target)) {
|
|
509
|
+
frontendKeep.add(target);
|
|
510
|
+
fqueue.push(target);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
for (const name of reads.type) {
|
|
514
|
+
if (movedLocals.has(name))
|
|
515
|
+
continue;
|
|
516
|
+
const target = local(name);
|
|
517
|
+
if (target !== undefined && !frontendKeep.has(target) && !isBackendStatement(target)) {
|
|
518
|
+
frontendKeep.add(target);
|
|
519
|
+
fqueue.push(target);
|
|
520
|
+
}
|
|
354
521
|
}
|
|
355
522
|
}
|
|
523
|
+
return { backendSet, frontendKeep };
|
|
524
|
+
};
|
|
525
|
+
let { backendSet, frontendKeep } = partition(new Set());
|
|
526
|
+
const copies = () => [...frontendKeep].filter((info) => backendSet.has(info) &&
|
|
527
|
+
!isBackendStatement(info) &&
|
|
528
|
+
info.declares.size > 0 &&
|
|
529
|
+
!isTypeDeclaration(info));
|
|
530
|
+
// Constant data both halves read moves to a module beside the route, with the declarations it
|
|
531
|
+
// needs: one definition, which neither half's bundle can drift from.
|
|
532
|
+
const shared = new Set();
|
|
533
|
+
const sharedTypes = new Set();
|
|
534
|
+
const sharedImports = new Set();
|
|
535
|
+
const unshared = new Map();
|
|
536
|
+
if (options.sharedFile !== undefined) {
|
|
537
|
+
/** Why `info` cannot move to the shared module, or `undefined` once it and what it needs are in `closure`. */
|
|
538
|
+
const gather = (info, closure) => {
|
|
539
|
+
if (closure.values.has(info) || shared.has(info))
|
|
540
|
+
return undefined;
|
|
541
|
+
const node = info.node;
|
|
542
|
+
const reads = new Set();
|
|
543
|
+
let blocker = "";
|
|
544
|
+
if (!isBackendStatement(info) &&
|
|
545
|
+
ts.isVariableStatement(node) &&
|
|
546
|
+
(node.declarationList.flags & ts.NodeFlags.Const) !== 0 &&
|
|
547
|
+
!hasModifier(ts, node, ts.SyntaxKind.DeclareKeyword)) {
|
|
548
|
+
blocker = undefined;
|
|
549
|
+
for (const declaration of node.declarationList.declarations) {
|
|
550
|
+
blocker =
|
|
551
|
+
ts.isIdentifier(declaration.name) && declaration.initializer !== undefined
|
|
552
|
+
? dataBlocker(ts, declaration.initializer, (name) => declaredBy.has(name), reads)
|
|
553
|
+
: "";
|
|
554
|
+
if (blocker !== undefined)
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (blocker !== undefined)
|
|
559
|
+
return blocker !== "" && importedBy.has(blocker)
|
|
560
|
+
? `it reads the import "${blocker}"`
|
|
561
|
+
: "it is not constant data";
|
|
562
|
+
closure.values.add(info);
|
|
563
|
+
for (const name of reads) {
|
|
564
|
+
const target = declaredBy.get(name);
|
|
565
|
+
const why = target === undefined ? undefined : gather(target, closure);
|
|
566
|
+
if (why !== undefined)
|
|
567
|
+
return `it reads "${name}", which ${why.slice(3)}`;
|
|
568
|
+
}
|
|
569
|
+
for (const name of info.typeRefs) {
|
|
570
|
+
const why = gatherType(name, closure);
|
|
571
|
+
if (why !== undefined)
|
|
572
|
+
return why;
|
|
573
|
+
}
|
|
574
|
+
return undefined;
|
|
575
|
+
};
|
|
576
|
+
const gatherType = (name, closure) => {
|
|
577
|
+
const local = declaredBy.get(name);
|
|
578
|
+
if (local === undefined) {
|
|
579
|
+
if (importedBy.has(name))
|
|
580
|
+
closure.imports.add(name);
|
|
581
|
+
return undefined;
|
|
582
|
+
}
|
|
583
|
+
if (!isTypeDeclaration(local)) {
|
|
584
|
+
const why = gather(local, closure);
|
|
585
|
+
return why === undefined ? undefined : `its type reads "${name}", which ${why.slice(3)}`;
|
|
586
|
+
}
|
|
587
|
+
if (closure.types.has(local) || sharedTypes.has(local))
|
|
588
|
+
return undefined;
|
|
589
|
+
closure.types.add(local);
|
|
590
|
+
for (const ref of [...local.typeRefs, ...local.valueRefs]) {
|
|
591
|
+
const why = gatherType(ref, closure);
|
|
592
|
+
if (why !== undefined)
|
|
593
|
+
return why;
|
|
594
|
+
}
|
|
595
|
+
return undefined;
|
|
596
|
+
};
|
|
597
|
+
for (const info of copies()) {
|
|
598
|
+
const closure = { values: new Set(), types: new Set(), imports: new Set() };
|
|
599
|
+
const why = gather(info, closure);
|
|
600
|
+
if (why !== undefined) {
|
|
601
|
+
unshared.set(info, why);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
for (const value of closure.values)
|
|
605
|
+
shared.add(value);
|
|
606
|
+
for (const type of closure.types)
|
|
607
|
+
sharedTypes.add(type);
|
|
608
|
+
for (const name of closure.imports)
|
|
609
|
+
sharedImports.add(name);
|
|
610
|
+
}
|
|
611
|
+
if (shared.size > 0)
|
|
612
|
+
({ backendSet, frontendKeep } = partition(shared));
|
|
356
613
|
}
|
|
357
614
|
// A moved name the frontend still mentions: in a type it becomes `import type`; at runtime it is an
|
|
358
615
|
// error the author has to resolve.
|
|
@@ -360,44 +617,83 @@ export function splitRouteSource(ts, source, fileName, backendSpecifier) {
|
|
|
360
617
|
for (const info of frontendKeep) {
|
|
361
618
|
if (isBackendStatement(info))
|
|
362
619
|
continue;
|
|
620
|
+
const reads = readsFor(info, false);
|
|
363
621
|
for (const name of movedLocals) {
|
|
364
622
|
if (info.declares.has(name))
|
|
365
623
|
continue;
|
|
366
|
-
if (
|
|
624
|
+
if (reads.value.has(name))
|
|
367
625
|
issues.push(`the frontend uses "${name}" at runtime, but it runs on the server only`);
|
|
368
|
-
if (
|
|
626
|
+
if (reads.type.has(name) || reads.value.has(name))
|
|
369
627
|
typeImports.add(name);
|
|
370
628
|
}
|
|
371
629
|
}
|
|
372
|
-
for (const info of
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
630
|
+
for (const info of copies()) {
|
|
631
|
+
const why = unshared.get(info);
|
|
632
|
+
issues.push(`"${[...info.declares].join(", ")}" is needed by both halves and was copied into each${why === undefined ? "" : `, since ${why}`}; consider moving it to shared/`);
|
|
633
|
+
}
|
|
634
|
+
const uses = (infos, backend) => {
|
|
635
|
+
const value = new Set();
|
|
636
|
+
const type = new Set();
|
|
637
|
+
for (const info of infos) {
|
|
638
|
+
const reads = readsFor(info, backend);
|
|
639
|
+
for (const name of reads.value)
|
|
640
|
+
value.add(name);
|
|
641
|
+
for (const name of reads.type)
|
|
642
|
+
type.add(name);
|
|
643
|
+
}
|
|
644
|
+
return { value, type };
|
|
645
|
+
};
|
|
646
|
+
const backendUses = uses(backendSet, true);
|
|
647
|
+
const frontendUses = uses(frontendKeep, false);
|
|
648
|
+
const semi = statements.some((info) => terminatorOf(sf, info.node) !== "") ? ";" : "";
|
|
649
|
+
// The imports the split writes quote the way the file's own imports do.
|
|
650
|
+
const firstImport = statements.find((info) => ts.isImportDeclaration(info.node))?.node;
|
|
651
|
+
const quote = firstImport !== undefined && ts.isImportDeclaration(firstImport)
|
|
652
|
+
? (firstImport.moduleSpecifier.getText(sf)[0] ?? '"')
|
|
653
|
+
: '"';
|
|
654
|
+
const sharedNames = [...shared].flatMap((info) => [...info.declares]);
|
|
655
|
+
const sharedSpecifier = (() => {
|
|
656
|
+
const file = options.sharedFile ?? "";
|
|
657
|
+
// The halves name the module the way the route names its other relative imports.
|
|
658
|
+
const relative = statements.flatMap((info) => ts.isImportDeclaration(info.node) &&
|
|
659
|
+
ts.isStringLiteral(info.node.moduleSpecifier) &&
|
|
660
|
+
info.node.moduleSpecifier.text.startsWith(".")
|
|
661
|
+
? [info.node.moduleSpecifier.text]
|
|
662
|
+
: []);
|
|
663
|
+
const extension = relative
|
|
664
|
+
.map((specifier) => /\.(ts|tsx|js|jsx)$/.exec(specifier)?.[1])
|
|
665
|
+
.find((found) => found !== undefined);
|
|
666
|
+
const suffix = extension === undefined ? "" : extension.startsWith("t") ? ".ts" : ".js";
|
|
667
|
+
return `./${file.replace(/\.ts$/, "")}${suffix}`;
|
|
668
|
+
})();
|
|
669
|
+
const sharedImport = (half) => {
|
|
670
|
+
const names = sharedNames
|
|
671
|
+
.filter((name) => half.value.has(name) || half.type.has(name))
|
|
672
|
+
.sort(codeUnitOrder);
|
|
673
|
+
if (names.length === 0)
|
|
674
|
+
return [];
|
|
675
|
+
const typeOnly = names.every((name) => !half.value.has(name));
|
|
676
|
+
return [
|
|
677
|
+
`\nimport ${typeOnly ? "type " : ""}{ ${names.join(", ")} } from ${quote}${sharedSpecifier}${quote}${semi}`,
|
|
678
|
+
];
|
|
385
679
|
};
|
|
386
|
-
|
|
387
|
-
const
|
|
680
|
+
// The page still exports what it exported, now from the shared module.
|
|
681
|
+
const reexported = [...shared].flatMap((info) => [...info.exports].flatMap(([local, names]) => names.map((name) => (name === local ? local : `${local} as ${name}`))));
|
|
388
682
|
const backendTexts = [];
|
|
389
683
|
const frontendTexts = [];
|
|
390
684
|
let afterImports = 0;
|
|
685
|
+
let backendAfterImports = 0;
|
|
391
686
|
for (const info of statements) {
|
|
392
687
|
const node = info.node;
|
|
393
688
|
if (ts.isImportDeclaration(node)) {
|
|
394
|
-
const back = filterImport(ts, sf, node,
|
|
689
|
+
const back = filterImport(ts, sf, node, backendUses, true);
|
|
395
690
|
if (back !== undefined)
|
|
396
691
|
backendTexts.push(node.importClause ? back : "");
|
|
397
|
-
const front = filterImport(ts, sf, node,
|
|
692
|
+
const front = filterImport(ts, sf, node, frontendUses, false);
|
|
398
693
|
if (front !== undefined)
|
|
399
694
|
frontendTexts.push(front);
|
|
400
695
|
afterImports = frontendTexts.length;
|
|
696
|
+
backendAfterImports = backendTexts.length;
|
|
401
697
|
continue;
|
|
402
698
|
}
|
|
403
699
|
if (isBackendStatement(info)) {
|
|
@@ -419,22 +715,49 @@ export function splitRouteSource(ts, source, fileName, backendSpecifier) {
|
|
|
419
715
|
frontendTexts.push(info.text);
|
|
420
716
|
}
|
|
421
717
|
// After the other imports: the nearest relative path sorts last.
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
718
|
+
const frontendImports = sharedImport(frontendUses);
|
|
719
|
+
if (reexported.length > 0)
|
|
720
|
+
frontendImports.push(`\nexport { ${reexported.join(", ")} } from ${quote}${sharedSpecifier}${quote}${semi}`);
|
|
721
|
+
if (typeImports.size > 0)
|
|
722
|
+
frontendImports.push(`\nimport type { ${[...typeImports].sort().join(", ")} } from ${quote}${backendSpecifier}${quote}${semi}`);
|
|
723
|
+
frontendTexts.splice(afterImports, 0, ...frontendImports);
|
|
724
|
+
backendTexts.splice(backendAfterImports, 0, ...sharedImport(backendUses));
|
|
426
725
|
// A JSX pragma counts only in the file's leading comments, and the backend half has no JSX.
|
|
427
726
|
const pragmas = (ts.getLeadingCommentRanges(source, 0) ?? [])
|
|
428
727
|
.map((range) => source.slice(range.pos, range.end))
|
|
429
728
|
.filter((comment) => /@jsx/.test(comment));
|
|
430
729
|
const lift = (text) => pragmas.reduce((out, pragma) => out.replace(new RegExp(`${escapeRegExp(pragma)}[ \\t]*\\n?`), ""), text);
|
|
431
730
|
const frontend = lift(joinStatements(frontendTexts));
|
|
432
|
-
|
|
731
|
+
let sharedText;
|
|
732
|
+
if (shared.size > 0) {
|
|
733
|
+
const texts = [];
|
|
734
|
+
const typeUses = { value: new Set(), type: sharedImports };
|
|
735
|
+
for (const info of statements) {
|
|
736
|
+
if (ts.isImportDeclaration(info.node)) {
|
|
737
|
+
const kept = filterImport(ts, sf, info.node, typeUses, true);
|
|
738
|
+
if (kept !== undefined && info.node.importClause !== undefined)
|
|
739
|
+
texts.push(kept);
|
|
740
|
+
}
|
|
741
|
+
else if (shared.has(info) || sharedTypes.has(info)) {
|
|
742
|
+
texts.push(withExport(ts, sf, info));
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
sharedText = lift(joinStatements(texts));
|
|
746
|
+
}
|
|
747
|
+
const result = {
|
|
433
748
|
frontend: pragmas.length > 0 ? `${pragmas.join("\n")}\n${frontend}` : frontend,
|
|
434
749
|
backend: lift(joinStatements(backendTexts)),
|
|
435
750
|
moved: [...new Set(moved)].sort(),
|
|
436
751
|
issues,
|
|
437
752
|
};
|
|
753
|
+
return sharedText === undefined ? result : { ...result, shared: sharedText };
|
|
754
|
+
}
|
|
755
|
+
/** A declaration the shared module holds is exported, so either half can import it. */
|
|
756
|
+
function withExport(ts, sf, info) {
|
|
757
|
+
if (hasModifier(ts, info.node, ts.SyntaxKind.ExportKeyword))
|
|
758
|
+
return info.text;
|
|
759
|
+
const at = info.node.getStart(sf) - info.node.getFullStart();
|
|
760
|
+
return `${info.text.slice(0, at)}export ${info.text.slice(at)}`;
|
|
438
761
|
}
|
|
439
762
|
/** A helper the backend half copies keeps its declaration; an `export` on it would add a backend export. */
|
|
440
763
|
function stripExport(ts, sf, info) {
|
|
@@ -600,7 +923,7 @@ function importsOf(ts, file, text) {
|
|
|
600
923
|
function resolveIn(files, from, specifier) {
|
|
601
924
|
if (!specifier.startsWith("."))
|
|
602
925
|
return undefined;
|
|
603
|
-
const clean = specifier
|
|
926
|
+
const clean = withoutQuery(specifier);
|
|
604
927
|
const base = posix.normalize(posix.join(posix.dirname(from), clean));
|
|
605
928
|
const candidates = [
|
|
606
929
|
base,
|
|
@@ -647,15 +970,19 @@ function rebaseOwnPaths(ts, from, to, text, locate) {
|
|
|
647
970
|
const quote = node.getText(sf)[0] ?? '"';
|
|
648
971
|
return `${quote}${value}${quote}`;
|
|
649
972
|
};
|
|
650
|
-
// The path `relative` names from the module's old folder, named from its new folder
|
|
973
|
+
// The path `relative` names from the module's old folder, named from its new folder; `undefined`
|
|
974
|
+
// when neither moved, so a module that stays keeps its spelling.
|
|
651
975
|
const rebased = (relative) => {
|
|
652
|
-
const
|
|
976
|
+
const before = posix.normalize(posix.join(posix.dirname(from), relative));
|
|
977
|
+
const target = locate(before);
|
|
978
|
+
if (from === to && target === before)
|
|
979
|
+
return undefined;
|
|
653
980
|
const next = posix.relative(posix.dirname(to), target) || ".";
|
|
654
981
|
return relative.endsWith("/") && next !== "." ? `${next}/` : next;
|
|
655
982
|
};
|
|
656
983
|
const follow = (anchor, start, end, replacement) => {
|
|
657
984
|
followed.push([anchor.getStart(sf), anchor.getEnd()]);
|
|
658
|
-
if (text.slice(start, end) !== replacement)
|
|
985
|
+
if (replacement !== undefined && text.slice(start, end) !== replacement)
|
|
659
986
|
edits.push({ start, end, text: replacement });
|
|
660
987
|
};
|
|
661
988
|
const visit = (node) => {
|
|
@@ -671,8 +998,11 @@ function rebaseOwnPaths(ts, from, to, text, locate) {
|
|
|
671
998
|
parts.every(isLiteral) &&
|
|
672
999
|
!(callee.endsWith("resolve") && parts.some((part) => part.text.startsWith("/")))) {
|
|
673
1000
|
const next = rebased(posix.join(...parts.map((part) => part.text)));
|
|
674
|
-
const
|
|
675
|
-
|
|
1001
|
+
const replacement = next === undefined
|
|
1002
|
+
? undefined
|
|
1003
|
+
: (parts.length === 1 ? [next] : next.split("/").filter((s) => s !== ""))
|
|
1004
|
+
.map((segment) => quoted(first, segment))
|
|
1005
|
+
.join(", ");
|
|
676
1006
|
follow(dir, first.getStart(sf), last.getEnd(), replacement);
|
|
677
1007
|
return;
|
|
678
1008
|
}
|
|
@@ -683,14 +1013,14 @@ function rebaseOwnPaths(ts, from, to, text, locate) {
|
|
|
683
1013
|
isLiteral(node.right) &&
|
|
684
1014
|
node.right.text.startsWith("/")) {
|
|
685
1015
|
const next = rebased(node.right.text.slice(1) || ".");
|
|
686
|
-
follow(node.left, node.right.getStart(sf), node.right.getEnd(), quoted(node.right, `/${next}`));
|
|
1016
|
+
follow(node.left, node.right.getStart(sf), node.right.getEnd(), next === undefined ? undefined : quoted(node.right, `/${next}`));
|
|
687
1017
|
return;
|
|
688
1018
|
}
|
|
689
1019
|
if (ts.isTemplateExpression(node) && node.head.text === "" && node.templateSpans.length === 1) {
|
|
690
1020
|
const span = node.templateSpans[0];
|
|
691
1021
|
if (span !== undefined && isOwnDir(span.expression) && span.literal.text.startsWith("/")) {
|
|
692
1022
|
const next = rebased(span.literal.text.slice(1) || ".");
|
|
693
|
-
const replacement = `\`\${${span.expression.getText(sf)}}/${next}\``;
|
|
1023
|
+
const replacement = next === undefined ? undefined : `\`\${${span.expression.getText(sf)}}/${next}\``;
|
|
694
1024
|
follow(span.expression, node.getStart(sf), node.getEnd(), replacement);
|
|
695
1025
|
return;
|
|
696
1026
|
}
|
|
@@ -702,7 +1032,7 @@ function rebaseOwnPaths(ts, from, to, text, locate) {
|
|
|
702
1032
|
const path = node.arguments[0];
|
|
703
1033
|
if (isLiteral(path) && !/^(?:\/|[a-z][a-z\d+.-]*:)/i.test(path.text)) {
|
|
704
1034
|
const next = rebased(path.text);
|
|
705
|
-
const replacement = quoted(path, next.startsWith("..") ? next : `./${next}`);
|
|
1035
|
+
const replacement = next === undefined ? undefined : quoted(path, next.startsWith("..") ? next : `./${next}`);
|
|
706
1036
|
follow(node.arguments[1], path.getStart(sf), path.getEnd(), replacement);
|
|
707
1037
|
return;
|
|
708
1038
|
}
|
|
@@ -719,7 +1049,7 @@ function rebaseOwnPaths(ts, from, to, text, locate) {
|
|
|
719
1049
|
/** Rewrite one relative specifier from `from`'s new location to `target`'s new location, keeping the
|
|
720
1050
|
* author's style: extension kept when written, dropped when not, `/index` collapsed when it was. */
|
|
721
1051
|
function respecify(original, oldTarget, newFrom, newTarget) {
|
|
722
|
-
const query =
|
|
1052
|
+
const query = original.slice(withoutQuery(original).length);
|
|
723
1053
|
const clean = original.slice(0, original.length - query.length);
|
|
724
1054
|
let target = newTarget;
|
|
725
1055
|
const writtenExt = extname(clean);
|
|
@@ -749,7 +1079,7 @@ function rebase(specifier, from, newFrom) {
|
|
|
749
1079
|
}
|
|
750
1080
|
/** `specifier` re-pointed from a split route to its backend half, in the author's extension style. */
|
|
751
1081
|
function backendSpecifier(specifier, route, backend, from) {
|
|
752
|
-
const query =
|
|
1082
|
+
const query = specifier.slice(withoutQuery(specifier).length);
|
|
753
1083
|
const written = extname(specifier.slice(0, specifier.length - query.length));
|
|
754
1084
|
const stem = backend.slice(0, -extname(backend).length);
|
|
755
1085
|
const target = written === ""
|
|
@@ -763,8 +1093,7 @@ function backendSpecifier(specifier, route, backend, from) {
|
|
|
763
1093
|
/**
|
|
764
1094
|
* Point an importer's named imports of a split route's moved exports (`loader`, `action`, ...) at the
|
|
765
1095
|
* backend half, splitting a statement that also names what stayed. What cannot be rewritten (a
|
|
766
|
-
* namespace or dynamic import that reads a moved name, `export
|
|
767
|
-
* returned as an issue.
|
|
1096
|
+
* namespace or dynamic import that reads a moved name, `export *`) is returned as an issue.
|
|
768
1097
|
*/
|
|
769
1098
|
function redirectMovedImports(ts, file, text, splitOf) {
|
|
770
1099
|
const redirects = [];
|
|
@@ -799,10 +1128,6 @@ function redirectMovedImports(ts, file, text, splitOf) {
|
|
|
799
1128
|
const moved = elements.filter(isMoved);
|
|
800
1129
|
if (moved.length === 0)
|
|
801
1130
|
return;
|
|
802
|
-
if (split.specifier === undefined) {
|
|
803
|
-
issues.push(`imports ${quoted(moved.map((el) => (el.propertyName ?? el.name).text))} from "${from}", but they moved to "${split.backend}" and no target of that alias covers it; point the import there by hand`);
|
|
804
|
-
return;
|
|
805
|
-
}
|
|
806
1131
|
redirects.push({
|
|
807
1132
|
names: moved.map((el) => (el.propertyName ?? el.name).text),
|
|
808
1133
|
from,
|
|
@@ -832,11 +1157,25 @@ function redirectMovedImports(ts, file, text, splitOf) {
|
|
|
832
1157
|
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
|
833
1158
|
node.arguments[0] !== undefined &&
|
|
834
1159
|
ts.isStringLiteralLike(node.arguments[0])) {
|
|
835
|
-
const
|
|
1160
|
+
const literal = node.arguments[0];
|
|
1161
|
+
const from = literal.text;
|
|
836
1162
|
const split = splitOf(from);
|
|
837
1163
|
const reads = split === undefined ? [] : mentioned(split, (name) => new RegExp(`\\b${name}\\b`));
|
|
838
|
-
if (split !== undefined && reads.length > 0)
|
|
839
|
-
|
|
1164
|
+
if (split !== undefined && reads.length > 0) {
|
|
1165
|
+
const names = dynamicReads(ts, sf, node);
|
|
1166
|
+
if (names !== undefined &&
|
|
1167
|
+
names.length > 0 &&
|
|
1168
|
+
names.every((name) => split.moved.has(name))) {
|
|
1169
|
+
edits.push({
|
|
1170
|
+
start: literal.getStart(sf) + 1,
|
|
1171
|
+
end: literal.getEnd() - 1,
|
|
1172
|
+
text: split.specifier,
|
|
1173
|
+
});
|
|
1174
|
+
redirects.push({ names, from, to: split.specifier });
|
|
1175
|
+
}
|
|
1176
|
+
else
|
|
1177
|
+
issues.push(`loads "${from}" with import() and mentions ${quoted(reads)}, which moved to "${split.backend}"; load the backend half for them by hand`);
|
|
1178
|
+
}
|
|
840
1179
|
}
|
|
841
1180
|
ts.forEachChild(node, visit);
|
|
842
1181
|
};
|
|
@@ -846,6 +1185,178 @@ function redirectMovedImports(ts, file, text, splitOf) {
|
|
|
846
1185
|
out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
|
|
847
1186
|
return { text: out, issues, redirects };
|
|
848
1187
|
}
|
|
1188
|
+
/**
|
|
1189
|
+
* The exports code reads from what `import()` loads: off the awaited module (`(await import(x)).a`),
|
|
1190
|
+
* through a destructuring, or through a variable used only as `mod.a`. `undefined` when the module
|
|
1191
|
+
* goes anywhere else, so its reads cannot be listed.
|
|
1192
|
+
*/
|
|
1193
|
+
function dynamicReads(ts, sf, call) {
|
|
1194
|
+
let node = call;
|
|
1195
|
+
if (!ts.isAwaitExpression(node.parent))
|
|
1196
|
+
return undefined;
|
|
1197
|
+
node = node.parent;
|
|
1198
|
+
while (ts.isParenthesizedExpression(node.parent))
|
|
1199
|
+
node = node.parent;
|
|
1200
|
+
const parent = node.parent;
|
|
1201
|
+
if (ts.isPropertyAccessExpression(parent) && parent.expression === node)
|
|
1202
|
+
return [parent.name.text];
|
|
1203
|
+
if (!ts.isVariableDeclaration(parent) || parent.initializer !== node)
|
|
1204
|
+
return undefined;
|
|
1205
|
+
const binding = parent.name;
|
|
1206
|
+
if (ts.isObjectBindingPattern(binding)) {
|
|
1207
|
+
const names = [];
|
|
1208
|
+
for (const element of binding.elements) {
|
|
1209
|
+
const key = element.propertyName ?? element.name;
|
|
1210
|
+
if (element.dotDotDotToken !== undefined ||
|
|
1211
|
+
!(ts.isIdentifier(key) || ts.isStringLiteral(key)))
|
|
1212
|
+
return undefined;
|
|
1213
|
+
names.push(key.text);
|
|
1214
|
+
}
|
|
1215
|
+
return names;
|
|
1216
|
+
}
|
|
1217
|
+
if (!ts.isIdentifier(binding))
|
|
1218
|
+
return undefined;
|
|
1219
|
+
const names = new Set();
|
|
1220
|
+
let escapes = false;
|
|
1221
|
+
const visit = (current) => {
|
|
1222
|
+
if (ts.isIdentifier(current) && current.text === binding.text && current !== binding) {
|
|
1223
|
+
const owner = current.parent;
|
|
1224
|
+
if (ts.isPropertyAccessExpression(owner) && owner.expression === current)
|
|
1225
|
+
names.add(owner.name.text);
|
|
1226
|
+
else if (!((ts.isPropertyAccessExpression(owner) ||
|
|
1227
|
+
ts.isPropertyAssignment(owner) ||
|
|
1228
|
+
ts.isPropertyDeclaration(owner) ||
|
|
1229
|
+
ts.isMethodDeclaration(owner)) &&
|
|
1230
|
+
owner.name === current))
|
|
1231
|
+
escapes = true;
|
|
1232
|
+
}
|
|
1233
|
+
ts.forEachChild(current, visit);
|
|
1234
|
+
};
|
|
1235
|
+
visit(sf);
|
|
1236
|
+
return escapes ? undefined : [...names].sort(codeUnitOrder);
|
|
1237
|
+
}
|
|
1238
|
+
/** A `workspaces` glob as a matcher over package-relative folder paths. */
|
|
1239
|
+
function workspacePattern(glob) {
|
|
1240
|
+
const source = glob
|
|
1241
|
+
.replace(/^\.\//, "")
|
|
1242
|
+
.replace(/\/+$/, "")
|
|
1243
|
+
.split("/")
|
|
1244
|
+
.map((segment) => segment === "**"
|
|
1245
|
+
? "(?:[^/]+/)*?[^/]*"
|
|
1246
|
+
: segment.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replaceAll("*", "[^/]*"))
|
|
1247
|
+
.join("/");
|
|
1248
|
+
return new RegExp(`^${source}$`);
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* The workspace an app belongs to: the nearest folder above it whose package.json declares
|
|
1252
|
+
* `workspaces` that include the app, and the package folders those globs (less any `!` exclusion)
|
|
1253
|
+
* name, other than the app and any folder that holds it. `undefined` outside one.
|
|
1254
|
+
*/
|
|
1255
|
+
function workspaceSiblings(root) {
|
|
1256
|
+
for (let dir = dirname(root), depth = 0; depth < 8; dir = dirname(dir), depth += 1) {
|
|
1257
|
+
let declared;
|
|
1258
|
+
try {
|
|
1259
|
+
declared = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).workspaces;
|
|
1260
|
+
}
|
|
1261
|
+
catch {
|
|
1262
|
+
declared = undefined;
|
|
1263
|
+
}
|
|
1264
|
+
const listed = Array.isArray(declared)
|
|
1265
|
+
? declared
|
|
1266
|
+
: typeof declared === "object" && declared !== null
|
|
1267
|
+
? Reflect.get(declared, "packages")
|
|
1268
|
+
: undefined;
|
|
1269
|
+
if (Array.isArray(listed)) {
|
|
1270
|
+
const globs = listed.filter((glob) => typeof glob === "string");
|
|
1271
|
+
const include = globs.filter((glob) => !glob.startsWith("!")).map(workspacePattern);
|
|
1272
|
+
const exclude = globs
|
|
1273
|
+
.filter((glob) => glob.startsWith("!"))
|
|
1274
|
+
.map((glob) => workspacePattern(glob.slice(1)));
|
|
1275
|
+
const member = (rel) => include.some((pattern) => pattern.test(rel)) &&
|
|
1276
|
+
!exclude.some((pattern) => pattern.test(rel));
|
|
1277
|
+
// The nearest declaration decides, as package managers read it.
|
|
1278
|
+
if (!member(toPosix(relative(dir, root))))
|
|
1279
|
+
return undefined;
|
|
1280
|
+
const packages = [];
|
|
1281
|
+
const walk = (folder, depth) => {
|
|
1282
|
+
for (const entry of readdirSync(folder, { withFileTypes: true })) {
|
|
1283
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || SKIP_DIRS.has(entry.name))
|
|
1284
|
+
continue;
|
|
1285
|
+
const full = join(folder, entry.name);
|
|
1286
|
+
const rel = toPosix(relative(dir, full));
|
|
1287
|
+
if (exclude.some((pattern) => pattern.test(rel)))
|
|
1288
|
+
continue;
|
|
1289
|
+
if (member(rel) && existsSync(join(full, "package.json"))) {
|
|
1290
|
+
const inside = toPosix(relative(full, root));
|
|
1291
|
+
if (inside.startsWith("../") || isAbsolute(inside))
|
|
1292
|
+
packages.push(full);
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
if (depth < 4)
|
|
1296
|
+
walk(full, depth + 1);
|
|
1297
|
+
}
|
|
1298
|
+
};
|
|
1299
|
+
walk(dir, 0);
|
|
1300
|
+
return packages.sort();
|
|
1301
|
+
}
|
|
1302
|
+
if (dirname(dir) === dir)
|
|
1303
|
+
return undefined;
|
|
1304
|
+
}
|
|
1305
|
+
return undefined;
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Where code in a workspace sibling names an app file that moves: a relative import into the app, or
|
|
1309
|
+
* the file's path in a string or command (`bun -e 'import("./src/x.ts")'` with the app as its working
|
|
1310
|
+
* directory). Only files that mention the app's folder or package name are read further, and a path
|
|
1311
|
+
* the sibling's own package holds is its own file. Report-only: the migration never edits a sibling.
|
|
1312
|
+
*/
|
|
1313
|
+
function siblingReferences(ts, root, pkg, file, text, moves, known, ownName) {
|
|
1314
|
+
const abs = join(pkg, file);
|
|
1315
|
+
// A process started in the app (`cwd: \`${repo}apps/web\``) reads its paths from the app root.
|
|
1316
|
+
const runsInApp = new RegExp(`\\bcwd\\s*:[^,\\n]*\\b${escapeRegExp(posix.basename(toPosix(root)))}\\b`).test(text);
|
|
1317
|
+
const found = new Map();
|
|
1318
|
+
const report = (from, index) => {
|
|
1319
|
+
const to = moves.get(from);
|
|
1320
|
+
if (to === undefined || found.has(from))
|
|
1321
|
+
return;
|
|
1322
|
+
found.set(from, { from, to, line: text.slice(0, index).split("\n").length });
|
|
1323
|
+
};
|
|
1324
|
+
if (/\.(?:[cm]?[jt]sx?)$/.test(file)) {
|
|
1325
|
+
for (const { specifier } of importsOf(ts, file, text)) {
|
|
1326
|
+
const clean = withoutQuery(specifier);
|
|
1327
|
+
const inApp = clean.startsWith(".")
|
|
1328
|
+
? toPosix(relative(root, resolve(dirname(abs), clean)))
|
|
1329
|
+
: ownName !== undefined && clean.startsWith(`${ownName}/`)
|
|
1330
|
+
? clean.slice(ownName.length + 1)
|
|
1331
|
+
: undefined;
|
|
1332
|
+
if (inApp === undefined || inApp.startsWith("../") || isAbsolute(inApp))
|
|
1333
|
+
continue;
|
|
1334
|
+
const target = resolveIn(known, "index.ts", `./${inApp}`);
|
|
1335
|
+
if (target !== undefined)
|
|
1336
|
+
report(target, text.indexOf(specifier));
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
for (const from of moves.keys()) {
|
|
1340
|
+
const stem = SOURCE_FILE.test(from)
|
|
1341
|
+
? from.slice(0, from.length - extname(from).length)
|
|
1342
|
+
: undefined;
|
|
1343
|
+
const pattern = new RegExp(`(?<![\\w.-])(?:\\./)?(?:${escapeRegExp(from)}(?![\\w])${stem === undefined ? "" : `|${escapeRegExp(stem)}(?=["'\`])`})`, "g");
|
|
1344
|
+
for (const match of text.matchAll(pattern)) {
|
|
1345
|
+
// `packages/x/src/a.ts` names another package's `src/a.ts`; only a prefix that reaches the
|
|
1346
|
+
// app counts, and a path the sibling holds itself is its own.
|
|
1347
|
+
const prefix = /(?:[\w.@-]+\/)+$/.exec(text.slice(0, match.index))?.[0] ?? "";
|
|
1348
|
+
const bases = prefix === "" ? [] : [dirname(abs), pkg, dirname(pkg), dirname(dirname(pkg))];
|
|
1349
|
+
if (prefix !== "" && !bases.some((base) => resolve(base, prefix) === root))
|
|
1350
|
+
continue;
|
|
1351
|
+
const own = existsSync(join(pkg, from)) || existsSync(join(dirname(abs), from));
|
|
1352
|
+
if (prefix === "" && own && !runsInApp)
|
|
1353
|
+
continue;
|
|
1354
|
+
report(from, match.index);
|
|
1355
|
+
break;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
return [...found.values()];
|
|
1359
|
+
}
|
|
849
1360
|
/** The package folder an app without its own package.json sits in (`--dir src`), at most two levels
|
|
850
1361
|
* up and never a workspace root, so the scan beside it stays inside one package. */
|
|
851
1362
|
function enclosingPackage(root) {
|
|
@@ -991,7 +1502,7 @@ function renameRetiredTypes(ts, file, text) {
|
|
|
991
1502
|
/** The config files the loader reads, old layout included; a path field may live in any of them. */
|
|
992
1503
|
const CONFIG_FILES = ["nifra.config.ts", "framework.ts", "backend/framework.ts"];
|
|
993
1504
|
/**
|
|
994
|
-
* The module paths a config exports
|
|
1505
|
+
* The module paths a config exports, read statically: `clientModule` and the component pipeline's
|
|
995
1506
|
* `entries`. The browser loads `clientModule` and `entries.client`; `ssr` and `rsc` run on the server.
|
|
996
1507
|
*/
|
|
997
1508
|
function configEntries(ts, file, text) {
|
|
@@ -1005,18 +1516,12 @@ function configEntries(ts, file, text) {
|
|
|
1005
1516
|
current = current.expression;
|
|
1006
1517
|
return current;
|
|
1007
1518
|
};
|
|
1519
|
+
const read = configPathReader(ts, sf);
|
|
1008
1520
|
const out = [];
|
|
1009
1521
|
const literal = (field, role, node) => {
|
|
1010
|
-
const
|
|
1011
|
-
if (
|
|
1012
|
-
|
|
1013
|
-
out.push({
|
|
1014
|
-
field,
|
|
1015
|
-
role,
|
|
1016
|
-
value: value.text,
|
|
1017
|
-
start: value.getStart(sf) + 1,
|
|
1018
|
-
end: value.getEnd() - 1,
|
|
1019
|
-
});
|
|
1522
|
+
const path = read(node);
|
|
1523
|
+
if (path !== undefined)
|
|
1524
|
+
out.push({ ...path, field, role });
|
|
1020
1525
|
};
|
|
1021
1526
|
for (const statement of sf.statements) {
|
|
1022
1527
|
if (!ts.isVariableStatement(statement) ||
|
|
@@ -1147,13 +1652,18 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1147
1652
|
continue;
|
|
1148
1653
|
const backendFile = file.replace(ROUTE_FILE, ".backend.ts");
|
|
1149
1654
|
const specifier = `./${posix.basename(backendFile)}`;
|
|
1655
|
+
const sharedFile = file.replace(ROUTE_FILE, ".shared.ts");
|
|
1656
|
+
// A taken name leaves data both halves read copied into each, as reported.
|
|
1657
|
+
const splitOptions = next.has(sharedFile) || existsSync(join(root, sharedFile))
|
|
1658
|
+
? {}
|
|
1659
|
+
: { sharedFile: posix.basename(sharedFile) };
|
|
1150
1660
|
let result;
|
|
1151
1661
|
if (/\.(?:svelte|vue)$/.test(file)) {
|
|
1152
1662
|
const blocks = scriptBlocks(text);
|
|
1153
1663
|
const block = blocks.find((b) => b.module) ?? blocks.find((b) => !b.setup && file.endsWith(".vue"));
|
|
1154
1664
|
if (block === undefined)
|
|
1155
1665
|
continue;
|
|
1156
|
-
const inner = splitRouteSource(ts, text.slice(block.start, block.end), "block.ts", specifier);
|
|
1666
|
+
const inner = splitRouteSource(ts, text.slice(block.start, block.end), "block.ts", specifier, splitOptions);
|
|
1157
1667
|
result = {
|
|
1158
1668
|
...inner,
|
|
1159
1669
|
frontend: `${text.slice(0, block.start)}\n${inner.frontend}${text.slice(block.end)}`,
|
|
@@ -1169,13 +1679,18 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1169
1679
|
continue;
|
|
1170
1680
|
}
|
|
1171
1681
|
else {
|
|
1172
|
-
result = splitRouteSource(ts, text, file, specifier);
|
|
1682
|
+
result = splitRouteSource(ts, text, file, specifier, splitOptions);
|
|
1173
1683
|
}
|
|
1174
1684
|
if (result.backend === undefined)
|
|
1175
1685
|
continue;
|
|
1176
1686
|
next.set(file, result.frontend);
|
|
1177
1687
|
backendHalves.set(backendFile, mergeModules(ts, backendHalves.get(backendFile) ?? "", result.backend));
|
|
1178
|
-
|
|
1688
|
+
if (result.shared === undefined)
|
|
1689
|
+
splits.push({ file, backend: backendFile, moved: [...result.moved] });
|
|
1690
|
+
else {
|
|
1691
|
+
next.set(sharedFile, result.shared);
|
|
1692
|
+
splits.push({ file, backend: backendFile, moved: [...result.moved], shared: sharedFile });
|
|
1693
|
+
}
|
|
1179
1694
|
for (const reason of result.issues)
|
|
1180
1695
|
issues.push({ file, reason });
|
|
1181
1696
|
}
|
|
@@ -1189,7 +1704,11 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1189
1704
|
moveMap.set(from, to);
|
|
1190
1705
|
// Assets (images, fonts) resolve too, so they move with the modules that import them.
|
|
1191
1706
|
const known = new Set([...next.keys(), ...everyFile]);
|
|
1192
|
-
const
|
|
1707
|
+
const knownPaths = new Set([...known].map((file) => join(root, file)));
|
|
1708
|
+
// Route halves the split creates resolve before they are written.
|
|
1709
|
+
const resolver = createSpecifierResolver({
|
|
1710
|
+
moduleExists: (path) => knownPaths.has(path) || isFile(path),
|
|
1711
|
+
});
|
|
1193
1712
|
const realRoot = (() => {
|
|
1194
1713
|
try {
|
|
1195
1714
|
return realpathSync(root);
|
|
@@ -1198,16 +1717,24 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1198
1717
|
return root;
|
|
1199
1718
|
}
|
|
1200
1719
|
})();
|
|
1720
|
+
/** An app file as the migration names it, from a path the resolver found (real, or as planned). */
|
|
1721
|
+
const appFile = (path) => {
|
|
1722
|
+
if (path === undefined)
|
|
1723
|
+
return undefined;
|
|
1724
|
+
for (const base of [realRoot, root]) {
|
|
1725
|
+
const rel = toPosix(relative(base, path));
|
|
1726
|
+
if (!rel.startsWith("../") && !isAbsolute(rel))
|
|
1727
|
+
return rel;
|
|
1728
|
+
}
|
|
1729
|
+
return undefined;
|
|
1730
|
+
};
|
|
1201
1731
|
/** A non-relative specifier the app's tsconfig `paths`/`baseUrl` or package.json `imports` maps to
|
|
1202
|
-
* one of its files,
|
|
1732
|
+
* one of its files, as the bundler reads it. */
|
|
1203
1733
|
const viaAlias = (file, specifier) => {
|
|
1204
1734
|
if (specifier.startsWith(".") || isAbsolute(specifier))
|
|
1205
1735
|
return undefined;
|
|
1206
|
-
const
|
|
1207
|
-
|
|
1208
|
-
return undefined;
|
|
1209
|
-
const rel = toPosix(relative(realRoot, found));
|
|
1210
|
-
return rel.startsWith("../") || isAbsolute(rel) || !known.has(rel) ? undefined : rel;
|
|
1736
|
+
const rel = appFile(resolver(join(root, file), withoutQuery(specifier)));
|
|
1737
|
+
return rel === undefined || !known.has(rel) ? undefined : rel;
|
|
1211
1738
|
};
|
|
1212
1739
|
const resolveAny = (file, specifier) => resolveIn(known, file, specifier) ?? viaAlias(file, specifier);
|
|
1213
1740
|
const ownName = (() => {
|
|
@@ -1225,7 +1752,7 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1225
1752
|
const external = (specifier) => {
|
|
1226
1753
|
if (/^[a-z][a-z\d+.-]*:/i.test(specifier) || specifier === "bun")
|
|
1227
1754
|
return true;
|
|
1228
|
-
const name = packageName(specifier
|
|
1755
|
+
const name = packageName(withoutQuery(specifier));
|
|
1229
1756
|
if (builtinModules.includes(name) || name === ownName)
|
|
1230
1757
|
return true;
|
|
1231
1758
|
let found = installed.get(name);
|
|
@@ -1258,9 +1785,10 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1258
1785
|
if (route === undefined || split === undefined)
|
|
1259
1786
|
return undefined;
|
|
1260
1787
|
const inBase = (path) => toPosix(relative(base, join(root, path)));
|
|
1788
|
+
const relativePath = backendSpecifier(specifier, inBase(route), inBase(split.backend), file);
|
|
1261
1789
|
const respelled = specifier.startsWith(".")
|
|
1262
|
-
?
|
|
1263
|
-
: resolver.respell(join(base, file), specifier, join(root, split.backend));
|
|
1790
|
+
? relativePath
|
|
1791
|
+
: (resolver.respell(join(base, file), specifier, join(root, split.backend)) ?? relativePath);
|
|
1264
1792
|
return { backend: split.backend, moved: split.moved, specifier: respelled };
|
|
1265
1793
|
};
|
|
1266
1794
|
const redirectedFiles = new Set();
|
|
@@ -1308,15 +1836,18 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1308
1836
|
const entries = CONFIG_FILES.filter((file) => next.has(file)).flatMap((file) => configEntries(ts, file, next.get(file) ?? "").map((entry) => ({
|
|
1309
1837
|
...entry,
|
|
1310
1838
|
file,
|
|
1311
|
-
//
|
|
1312
|
-
|
|
1839
|
+
// A string is read from the app root, whichever config file holds it; a joined path from the
|
|
1840
|
+
// config's own folder.
|
|
1841
|
+
target: entry.base === "app"
|
|
1842
|
+
? resolveAny("nifra.config.ts", entry.path)
|
|
1843
|
+
: resolveIn(known, file, entry.path.startsWith("../") ? entry.path : `./${entry.path}`),
|
|
1313
1844
|
})));
|
|
1314
1845
|
const entryTargets = (role) => new Set(entries.flatMap((entry) => entry.role === role && entry.target !== undefined ? [entry.target] : []));
|
|
1315
1846
|
const browserEntries = entryTargets("frontend");
|
|
1316
1847
|
const serverEntries = entryTargets("backend");
|
|
1317
1848
|
const files = [...new Set([...next.keys(), ...everyFile.filter((f) => !original.has(f))])];
|
|
1318
1849
|
const frontendRoots = [
|
|
1319
|
-
...files.filter((f) => f.startsWith("routes/") && ROUTE_FILE.test(f) && !f.includes(".backend.")),
|
|
1850
|
+
...files.filter((f) => f.startsWith("routes/") && ROUTE_FILE.test(f) && !f.includes(".backend.") && !isTestFile(f)),
|
|
1320
1851
|
...browserEntries,
|
|
1321
1852
|
];
|
|
1322
1853
|
// A route page the server imports - a generated `server-manifest.ts` imports them all - is rendered
|
|
@@ -1369,6 +1900,8 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1369
1900
|
});
|
|
1370
1901
|
}
|
|
1371
1902
|
}
|
|
1903
|
+
/** With `zones`, the files that stay where they are and the side package.json gives them. */
|
|
1904
|
+
const inPlace = new Map();
|
|
1372
1905
|
const zoneOf = (file) => browser.has(file) && server.has(file) ? "shared" : browser.has(file) ? "frontend" : "backend";
|
|
1373
1906
|
for (const file of api ? [] : files) {
|
|
1374
1907
|
if (moveMap.has(file) ||
|
|
@@ -1387,6 +1920,10 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1387
1920
|
else if (!browser.has(file) && !appServer.has(file))
|
|
1388
1921
|
continue;
|
|
1389
1922
|
const zone = zoneOf(file);
|
|
1923
|
+
if (DATA_FILE.test(file) && options.zones === true) {
|
|
1924
|
+
inPlace.set(file, zone);
|
|
1925
|
+
continue;
|
|
1926
|
+
}
|
|
1390
1927
|
if (DATA_FILE.test(file)) {
|
|
1391
1928
|
const folder = file.includes("/") ? posix.dirname(file) : undefined;
|
|
1392
1929
|
issues.push({
|
|
@@ -1397,6 +1934,52 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1397
1934
|
}
|
|
1398
1935
|
moveMap.set(file, `${zone}/${file}`);
|
|
1399
1936
|
}
|
|
1937
|
+
// A folder whose reached files all move into one zone takes its other files along: a file nothing
|
|
1938
|
+
// imports at runtime (a component not used yet, an asset) has no zone of its own, and leaving it
|
|
1939
|
+
// behind splits the folder for every tool that lists it. Server built-ins never go to the browser.
|
|
1940
|
+
const zonesUnder = new Map();
|
|
1941
|
+
for (const [from, to] of moveMap) {
|
|
1942
|
+
if (isTestFile(from))
|
|
1943
|
+
continue;
|
|
1944
|
+
for (let dir = posix.dirname(from); dir !== "."; dir = posix.dirname(dir)) {
|
|
1945
|
+
zonesUnder.set(dir, (zonesUnder.get(dir) ?? new Set()).add(to.split("/")[0] ?? ""));
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
for (const file of api ? [] : files) {
|
|
1949
|
+
if (moveMap.has(file) || browser.has(file) || server.has(file))
|
|
1950
|
+
continue;
|
|
1951
|
+
if (ZONE_DIRS.has(file.split("/")[0] ?? "") || inConfiguredFolder(file))
|
|
1952
|
+
continue;
|
|
1953
|
+
// A test and its snapshot follow their subject below.
|
|
1954
|
+
if (isTestFile(file) ||
|
|
1955
|
+
file.endsWith(".snap") ||
|
|
1956
|
+
file.endsWith(".d.ts") ||
|
|
1957
|
+
DATA_FILE.test(file))
|
|
1958
|
+
continue;
|
|
1959
|
+
let zones;
|
|
1960
|
+
for (let dir = posix.dirname(file); dir !== "." && zones === undefined; dir = posix.dirname(dir)) {
|
|
1961
|
+
// A nested package is its own install, never part of the app's folder.
|
|
1962
|
+
if (existsSync(join(root, dir, "package.json")))
|
|
1963
|
+
break;
|
|
1964
|
+
zones = zonesUnder.get(dir);
|
|
1965
|
+
}
|
|
1966
|
+
const [zone] = zones?.size === 1 ? zones : [];
|
|
1967
|
+
if (zone === undefined)
|
|
1968
|
+
continue;
|
|
1969
|
+
if (zone !== "backend" && /from\s+["'](?:node:|bun:)/.test(next.get(file) ?? ""))
|
|
1970
|
+
continue;
|
|
1971
|
+
moveMap.set(file, `${zone}/${file}`);
|
|
1972
|
+
}
|
|
1973
|
+
if (options.zones === true) {
|
|
1974
|
+
for (const [from, to] of moveMap) {
|
|
1975
|
+
const side = FOLDER_SIDES.find((side) => to === `${side}/${from}`);
|
|
1976
|
+
if (side === undefined)
|
|
1977
|
+
continue;
|
|
1978
|
+
inPlace.set(from, side);
|
|
1979
|
+
moveMap.delete(from);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
const declared = zoneDeclarations(inPlace, files, (file) => next.get(file), inConfiguredFolder);
|
|
1400
1983
|
const claimed = new Map();
|
|
1401
1984
|
for (const [from, to] of moveMap) {
|
|
1402
1985
|
const holder = claimed.get(to) ?? (known.has(to) && !moveMap.has(to) ? to : undefined);
|
|
@@ -1410,32 +1993,111 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1410
1993
|
});
|
|
1411
1994
|
moveMap.delete(from);
|
|
1412
1995
|
}
|
|
1413
|
-
|
|
1414
|
-
|
|
1996
|
+
const newPath = (file) => moveMap.get(file) ?? file;
|
|
1997
|
+
// A test beside its subject (`x.test.ts` by `x.ts`, or in a `__tests__/` under its folder) moves
|
|
1998
|
+
// with it, snapshots included, once the subject's place is settled; a test never decides a zone.
|
|
1999
|
+
const present = new Set(files);
|
|
2000
|
+
for (const test of files) {
|
|
2001
|
+
const stem = /^(.+?)\.(?:test|spec)\.[cm]?[jt]sx?$/.exec(posix.basename(test))?.[1];
|
|
2002
|
+
if (stem === undefined || moveMap.has(test) || !next.has(test))
|
|
2003
|
+
continue;
|
|
2004
|
+
const dir = posix.dirname(test);
|
|
2005
|
+
const nested = posix.basename(dir) === "__tests__";
|
|
2006
|
+
const home = nested ? posix.dirname(dir) : dir;
|
|
2007
|
+
const subjects = SUBJECT_EXTENSIONS.map((ext) => posix.join(home, `${stem}${ext}`)).filter((file) => present.has(file) && !isTestFile(file));
|
|
2008
|
+
const subject = subjects.find((file) => moveMap.has(file));
|
|
2009
|
+
if (subject === undefined)
|
|
2010
|
+
continue;
|
|
2011
|
+
if (new Set(subjects.map((file) => posix.dirname(newPath(file)))).size > 1) {
|
|
2012
|
+
issues.push({
|
|
2013
|
+
file: test,
|
|
2014
|
+
reason: `tests ${subjects.map((file) => `"${file}"`).join(" and ")}, which end up in different folders, so it stays here; move it by hand`,
|
|
2015
|
+
});
|
|
2016
|
+
continue;
|
|
2017
|
+
}
|
|
2018
|
+
const to = posix.join(posix.dirname(newPath(subject)), nested ? "__tests__" : "", posix.basename(test));
|
|
2019
|
+
const pairs = [[test, to]];
|
|
2020
|
+
const snapshot = posix.join(dir, "__snapshots__", `${posix.basename(test)}.snap`);
|
|
2021
|
+
if (present.has(snapshot))
|
|
2022
|
+
pairs.push([
|
|
2023
|
+
snapshot,
|
|
2024
|
+
posix.join(posix.dirname(to), "__snapshots__", posix.basename(snapshot)),
|
|
2025
|
+
]);
|
|
2026
|
+
const taken = pairs.find(([, to]) => claimed.has(to) || (known.has(to) && !moveMap.has(to)));
|
|
2027
|
+
if (taken !== undefined) {
|
|
2028
|
+
issues.push({
|
|
2029
|
+
file: test,
|
|
2030
|
+
reason: `would follow "${subject}" to "${taken[1]}", which already exists or another file moves to; move it by hand`,
|
|
2031
|
+
});
|
|
2032
|
+
continue;
|
|
2033
|
+
}
|
|
2034
|
+
for (const [from, to] of pairs) {
|
|
2035
|
+
moveMap.set(from, to);
|
|
2036
|
+
claimed.set(to, from);
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
for (const [from, to] of [
|
|
2040
|
+
...moveMap,
|
|
2041
|
+
...[...inPlace].map(([file, side]) => [file, side]),
|
|
2042
|
+
]) {
|
|
2043
|
+
// A test is never bundled, so its own server imports decide nothing.
|
|
2044
|
+
if (isTestFile(from))
|
|
2045
|
+
continue;
|
|
2046
|
+
if ((to === "shared" || to.startsWith("shared/")) &&
|
|
2047
|
+
/from\s+["'](?:node:|bun:)/.test(next.get(from) ?? "")) {
|
|
1415
2048
|
issues.push({
|
|
1416
2049
|
file: from,
|
|
1417
|
-
reason: `both halves import it at runtime, but it uses a server built-in; split it before moving to ${to}`,
|
|
2050
|
+
reason: `both halves import it at runtime, but it uses a server built-in; split it before ${to === "shared" ? "zoning it shared" : `moving to ${to}`}`,
|
|
1418
2051
|
});
|
|
1419
2052
|
}
|
|
1420
2053
|
}
|
|
1421
2054
|
// 5. Rewrite imports to the new locations.
|
|
1422
|
-
const newPath = (file) => moveMap.get(file) ?? file;
|
|
1423
2055
|
const final = new Map();
|
|
1424
2056
|
const rewritten = [];
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
const
|
|
1428
|
-
|
|
2057
|
+
// The app as the migration leaves it, where every rewritten specifier must reach its target.
|
|
2058
|
+
const planned = new Set([...known].filter((file) => next.has(file) || !original.has(file)).map(newPath));
|
|
2059
|
+
const plannedResolver = createSpecifierResolver({
|
|
2060
|
+
moduleExists: (path) => {
|
|
2061
|
+
const file = appFile(path);
|
|
2062
|
+
if (file !== undefined && (planned.has(file) || known.has(file)))
|
|
2063
|
+
return planned.has(file);
|
|
2064
|
+
return isFile(path);
|
|
2065
|
+
},
|
|
2066
|
+
});
|
|
2067
|
+
/** Whether `specifier`, written in `from` after the moves, loads `target` there. */
|
|
2068
|
+
const reaches = (from, specifier, target) => specifier.startsWith(".")
|
|
2069
|
+
? resolveIn(planned, from, specifier) === target
|
|
2070
|
+
: appFile(plannedResolver(join(root, from), withoutQuery(specifier))) === target;
|
|
2071
|
+
const unreached = new Set();
|
|
2072
|
+
const unreachable = (file, specifier, target, from) => {
|
|
2073
|
+
if (unreached.has(`${file}\0${specifier}`))
|
|
2074
|
+
return;
|
|
2075
|
+
unreached.add(`${file}\0${specifier}`);
|
|
2076
|
+
issues.push({
|
|
2077
|
+
file,
|
|
2078
|
+
reason: `"${specifier}" names "${target}", which moves to "${newPath(target)}", and no specifier the migration can write from "${from}" reaches it there; point it there by hand`,
|
|
2079
|
+
});
|
|
2080
|
+
};
|
|
2081
|
+
/**
|
|
2082
|
+
* How `from` names `target` after the moves, for the alias `specifier` that `file` writes: as written
|
|
2083
|
+
* while it still reaches it, through the same alias when one of its targets covers the new place,
|
|
2084
|
+
* else as a relative path. `undefined` leaves it as written.
|
|
2085
|
+
*/
|
|
2086
|
+
const aliasTo = (file, specifier, target, from = newPath(file)) => {
|
|
2087
|
+
const to = newPath(target);
|
|
2088
|
+
if (to === target && from === file)
|
|
1429
2089
|
return undefined;
|
|
1430
|
-
const
|
|
1431
|
-
if (
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
2090
|
+
const written = withoutQuery(specifier);
|
|
2091
|
+
if (reaches(from, written, to))
|
|
2092
|
+
return undefined;
|
|
2093
|
+
const respelled = resolver.respell(join(root, file), written, join(root, to));
|
|
2094
|
+
if (respelled !== undefined && reaches(from, respelled, to))
|
|
2095
|
+
return `${respelled}${specifier.slice(written.length)}`;
|
|
2096
|
+
const relativePath = respecify(specifier, target, from, to);
|
|
2097
|
+
if (reaches(from, relativePath, to))
|
|
2098
|
+
return relativePath;
|
|
2099
|
+
unreachable(file, specifier, target, from);
|
|
2100
|
+
return undefined;
|
|
1439
2101
|
};
|
|
1440
2102
|
for (const [file, text] of next) {
|
|
1441
2103
|
const imports = rewriteImports(ts, file, text, (specifier) => {
|
|
@@ -1447,10 +2109,13 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1447
2109
|
if (target === undefined)
|
|
1448
2110
|
return rebase(specifier, file, newPath(file));
|
|
1449
2111
|
if (!specifier.startsWith("."))
|
|
1450
|
-
return
|
|
2112
|
+
return aliasTo(file, specifier, target);
|
|
1451
2113
|
if (newPath(target) === target && newPath(file) === file)
|
|
1452
2114
|
return undefined;
|
|
1453
|
-
|
|
2115
|
+
const relativePath = respecify(specifier, target, newPath(file), newPath(target));
|
|
2116
|
+
if (!reaches(newPath(file), relativePath, newPath(target)))
|
|
2117
|
+
unreachable(file, specifier, target, newPath(file));
|
|
2118
|
+
return relativePath;
|
|
1454
2119
|
});
|
|
1455
2120
|
const updated = renameRetiredTypes(ts, file, imports);
|
|
1456
2121
|
final.set(newPath(file), updated);
|
|
@@ -1458,7 +2123,8 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1458
2123
|
(redirectedFiles.has(file) || (updated !== text && original.get(file) === text)))
|
|
1459
2124
|
rewritten.push(file);
|
|
1460
2125
|
}
|
|
1461
|
-
// A config
|
|
2126
|
+
// A config string names its module from the app root, so it follows the module, never the config
|
|
2127
|
+
// file; a path joined onto the config's folder is re-pointed with the module's own paths below.
|
|
1462
2128
|
for (const configFile of CONFIG_FILES) {
|
|
1463
2129
|
const at = newPath(configFile);
|
|
1464
2130
|
const text = final.get(at);
|
|
@@ -1466,14 +2132,16 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1466
2132
|
continue;
|
|
1467
2133
|
const edits = [];
|
|
1468
2134
|
for (const entry of configEntries(ts, at, text)) {
|
|
1469
|
-
|
|
2135
|
+
if (entry.span === undefined)
|
|
2136
|
+
continue;
|
|
2137
|
+
const target = resolveAny("nifra.config.ts", entry.path);
|
|
1470
2138
|
if (target === undefined || newPath(target) === target)
|
|
1471
2139
|
continue;
|
|
1472
|
-
const value = entry.
|
|
1473
|
-
? respecify(entry.
|
|
1474
|
-
:
|
|
2140
|
+
const value = entry.path.startsWith(".")
|
|
2141
|
+
? respecify(entry.path, target, "nifra.config.ts", newPath(target))
|
|
2142
|
+
: aliasTo(configFile, entry.path, target, "nifra.config.ts");
|
|
1475
2143
|
if (value !== undefined)
|
|
1476
|
-
edits.push({ start: entry.
|
|
2144
|
+
edits.push({ start: entry.span[0], end: entry.span[1], text: value });
|
|
1477
2145
|
}
|
|
1478
2146
|
if (edits.length === 0)
|
|
1479
2147
|
continue;
|
|
@@ -1503,16 +2171,40 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1503
2171
|
if (result.unfollowed !== undefined)
|
|
1504
2172
|
unfollowed.set(to, result.unfollowed);
|
|
1505
2173
|
}
|
|
2174
|
+
// A module that stays still names files from its own folder (a config's `clientModule` joined onto
|
|
2175
|
+
// `import.meta.dir`): each that moved is re-pointed.
|
|
2176
|
+
const landed = new Set(moveMap.values());
|
|
2177
|
+
for (const [file, text] of final) {
|
|
2178
|
+
if (landed.has(file) || !/\.[cm]?[jt]sx?$/.test(file) || !SELF_LOCATION.test(text))
|
|
2179
|
+
continue;
|
|
2180
|
+
const result = rebaseOwnPaths(ts, file, file, text, (target) => known.has(target) ? newPath(target) : target);
|
|
2181
|
+
if (result.text === text)
|
|
2182
|
+
continue;
|
|
2183
|
+
final.set(file, result.text);
|
|
2184
|
+
if (original.has(file) && !rewritten.includes(file))
|
|
2185
|
+
rewritten.push(file);
|
|
2186
|
+
}
|
|
1506
2187
|
// A path in a string or comment is not an import, so nothing above rewrote it: a build script that
|
|
1507
2188
|
// writes `data/x.json` would keep writing to the old place, and so would a deploy script or
|
|
1508
2189
|
// `package.json` beside the code.
|
|
1509
2190
|
const settled = new Set([...known].map(newPath));
|
|
2191
|
+
// A new backend half starts where its route was, so it keeps that route's place in each program.
|
|
2192
|
+
const splitFrom = new Map(splits
|
|
2193
|
+
.filter((split) => !original.has(split.backend))
|
|
2194
|
+
.map((split) => [split.backend, split.file]));
|
|
2195
|
+
const layout = [...known]
|
|
2196
|
+
.filter((file) => next.has(file) || !original.has(file))
|
|
2197
|
+
.map((file) => ({ from: splitFrom.get(file) ?? file, to: newPath(file) }));
|
|
2198
|
+
const tsconfigs = followTsconfigs(ts, root, moveMap, layout);
|
|
1510
2199
|
const scanned = [...final];
|
|
1511
2200
|
for (const file of everyFile) {
|
|
1512
2201
|
if (!original.has(file) &&
|
|
1513
2202
|
TOOLING_FILE.test(file) &&
|
|
1514
2203
|
statSync(join(root, file)).size <= 256_000)
|
|
1515
|
-
scanned.push([
|
|
2204
|
+
scanned.push([
|
|
2205
|
+
newPath(file),
|
|
2206
|
+
tsconfigs.files.get(file)?.text ?? readFileSync(join(root, file), "utf8"),
|
|
2207
|
+
]);
|
|
1516
2208
|
}
|
|
1517
2209
|
for (const [file, text] of scanned) {
|
|
1518
2210
|
let live;
|
|
@@ -1567,6 +2259,28 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1567
2259
|
issues.push({ file: at, reason });
|
|
1568
2260
|
}
|
|
1569
2261
|
}
|
|
2262
|
+
// A workspace sibling is not the migration's to edit either: each file that names a moved app file,
|
|
2263
|
+
// by import or by path, is reported with the line and both paths.
|
|
2264
|
+
const appName = posix.basename(toPosix(root));
|
|
2265
|
+
const mentionsApp = new RegExp(`(?:^|[/'"\`])${escapeRegExp(appName)}(?:[/'"\`]|$)`, "m");
|
|
2266
|
+
for (const sibling of moveMap.size === 0 ? [] : (workspaceSiblings(root) ?? [])) {
|
|
2267
|
+
for (const file of listFiles(sibling)) {
|
|
2268
|
+
if (!/\.[cm]?[jt]sx?$/.test(file) && !TOOLING_FILE.test(file))
|
|
2269
|
+
continue;
|
|
2270
|
+
if (statSync(join(sibling, file)).size > 256_000)
|
|
2271
|
+
continue;
|
|
2272
|
+
const text = readFileSync(join(sibling, file), "utf8");
|
|
2273
|
+
if (!mentionsApp.test(text) && (ownName === undefined || !text.includes(`${ownName}/`)))
|
|
2274
|
+
continue;
|
|
2275
|
+
const at = toPosix(relative(root, join(sibling, file)));
|
|
2276
|
+
for (const { from, to, line } of siblingReferences(ts, root, sibling, file, text, moveMap, known, ownName)) {
|
|
2277
|
+
issues.push({
|
|
2278
|
+
file: at,
|
|
2279
|
+
reason: `names "${from}" on line ${line}, and it moves to "${to}" - it is outside the app, so the migration does not rewrite it`,
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
1570
2284
|
// Paths a module finds at run time are not imports either: one built from the module's own location
|
|
1571
2285
|
// now starts from its new folder, and a folder listed at run time loses whatever moved out of it.
|
|
1572
2286
|
const leaving = new Map();
|
|
@@ -1597,28 +2311,56 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1597
2311
|
reason: `moves to "${first.to}"${others} but ${left} in ${dir}/; code or tooling that lists ${dir}/ no longer finds what moved - move the folder together by hand`,
|
|
1598
2312
|
});
|
|
1599
2313
|
}
|
|
1600
|
-
// The server refuses data a loader or action returns without an output schema
|
|
1601
|
-
//
|
|
2314
|
+
// The server refuses data a loader or action returns without an output schema. Each half the split
|
|
2315
|
+
// wrote gets a draft from its return type, read from the layout as planned so the dry run shows
|
|
2316
|
+
// what --write adds; only the author knows which fields the browser may see, so it is a draft.
|
|
2317
|
+
const halves = [...new Set(splits.map((split) => split.backend))].filter((file) => final.has(file));
|
|
2318
|
+
const unwritten = new Map();
|
|
2319
|
+
for (const [file, text] of final)
|
|
2320
|
+
if (original.get(file) !== text)
|
|
2321
|
+
unwritten.set(file, text);
|
|
2322
|
+
const schemaPlan = halves.length === 0
|
|
2323
|
+
? undefined
|
|
2324
|
+
: await planOutputSchemas(root, halves, (file) => final.get(file), {
|
|
2325
|
+
overlay: unwritten,
|
|
2326
|
+
// What NF-C030 would flag: a schema for data the function returns, not for `return null`.
|
|
2327
|
+
draft: (file, schema) => missingOutputSchemas(final.get(file) ?? "").some((found) => found.schema === schema),
|
|
2328
|
+
});
|
|
2329
|
+
for (const [file, text] of schemaPlan?.edits ?? [])
|
|
2330
|
+
final.set(file, text);
|
|
2331
|
+
const schemas = (schemaPlan?.entries ?? []).filter((entry) => entry.status === "missing");
|
|
2332
|
+
const followUps = [];
|
|
1602
2333
|
for (const [file, text] of final) {
|
|
1603
2334
|
if (!file.startsWith("routes/") || !/\.backend\.[cm]?[jt]s$/.test(file))
|
|
1604
2335
|
continue;
|
|
1605
2336
|
for (const { name, schema } of missingOutputSchemas(text)) {
|
|
2337
|
+
const why = schemaPlan?.entries.find((entry) => entry.file === file && entry.schema === schema && entry.reason !== undefined)?.reason;
|
|
2338
|
+
const missing = `exports ${name === "action" ? "an" : "a"} ${name} but no ${schema}, so data it returns fails the request`;
|
|
2339
|
+
// A half this run did not write keeps the schema it had: drafting one is the next command, not a
|
|
2340
|
+
// decision the migration left open.
|
|
2341
|
+
if (why === undefined && (schemaPlan === undefined || schemaPlan.warnings.length === 0)) {
|
|
2342
|
+
followUps.push({
|
|
2343
|
+
file,
|
|
2344
|
+
reason: `${missing}; draft it from the ${name}'s return type`,
|
|
2345
|
+
command: "nifra schemas --write",
|
|
2346
|
+
});
|
|
2347
|
+
continue;
|
|
2348
|
+
}
|
|
1606
2349
|
issues.push({
|
|
1607
2350
|
file,
|
|
1608
|
-
reason:
|
|
2351
|
+
reason: `${missing}. Declare what the browser may see: export const ${schema} = t.object({ ... }); no draft was written: ${why ?? schemaPlan?.warnings.join("; ")}`,
|
|
1609
2352
|
});
|
|
1610
2353
|
}
|
|
1611
2354
|
}
|
|
1612
2355
|
// Tooling that lists folders or globs, not files: the import rewrite above never sees it.
|
|
1613
2356
|
const tooling = [];
|
|
1614
2357
|
const toolingWrites = new Map();
|
|
1615
|
-
const
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
toolingWrites.set("tsconfig.json", tsconfig.text);
|
|
1620
|
-
tooling.push({ file: "tsconfig.json", change: `include += ${tsconfig.added.join(", ")}` });
|
|
2358
|
+
for (const [file, { text, changes }] of tsconfigs.files) {
|
|
2359
|
+
toolingWrites.set(file, text);
|
|
2360
|
+
for (const change of changes)
|
|
2361
|
+
tooling.push({ file, change });
|
|
1621
2362
|
}
|
|
2363
|
+
issues.push(...tsconfigs.issues);
|
|
1622
2364
|
for (const file of everyFile) {
|
|
1623
2365
|
if (!TAILWIND_CONFIG.test(file))
|
|
1624
2366
|
continue;
|
|
@@ -1631,6 +2373,35 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1631
2373
|
toolingWrites.set(".gitignore", gitignore);
|
|
1632
2374
|
tooling.push({ file: ".gitignore", change: "ignore .nifra/ (dev and build state)" });
|
|
1633
2375
|
}
|
|
2376
|
+
// A root backend.ts was served at /api by default until 5.0, which mounts it only where apiPrefix says.
|
|
2377
|
+
if (!api && rootMoves["backend.ts"] !== undefined) {
|
|
2378
|
+
const mount = apiPrefixFix({
|
|
2379
|
+
framework: final.get(FRAMEWORK_FILE),
|
|
2380
|
+
config: final.get(CONFIG_FILE),
|
|
2381
|
+
});
|
|
2382
|
+
for (const [file, text] of [
|
|
2383
|
+
[FRAMEWORK_FILE, mount.framework],
|
|
2384
|
+
[CONFIG_FILE, mount.config],
|
|
2385
|
+
]) {
|
|
2386
|
+
if (text === undefined)
|
|
2387
|
+
continue;
|
|
2388
|
+
final.set(file, text);
|
|
2389
|
+
tooling.push({
|
|
2390
|
+
file,
|
|
2391
|
+
change: 'export const apiPrefix = "/api", the mount 4.x made by default',
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
if (declared.size > 0) {
|
|
2396
|
+
const zoned = declareZones(ts, root, declared);
|
|
2397
|
+
if (zoned.issue !== undefined)
|
|
2398
|
+
issues.push({ file: "package.json", reason: zoned.issue });
|
|
2399
|
+
if (zoned.text !== undefined) {
|
|
2400
|
+
toolingWrites.set("package.json", zoned.text);
|
|
2401
|
+
for (const [path, side] of declared)
|
|
2402
|
+
tooling.push({ file: "package.json", change: `"nifra.zones" lists "${path}" as ${side}` });
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
1634
2405
|
// `nifra check` reads the declaration statically; without it, a later routes/ or config would zone the app.
|
|
1635
2406
|
const kind = api && createKindProbe(root)(root).kind === undefined ? declareApiKind(ts, root) : {};
|
|
1636
2407
|
if (kind.issue !== undefined)
|
|
@@ -1669,13 +2440,46 @@ export async function migrateLayout(appRoot, options = {}) {
|
|
|
1669
2440
|
ok: issues.length === 0,
|
|
1670
2441
|
write: options.write === true,
|
|
1671
2442
|
mode,
|
|
2443
|
+
followUps,
|
|
1672
2444
|
splits: splits.sort((a, b) => codeUnitOrder(a.file, b.file)),
|
|
1673
2445
|
moves,
|
|
1674
2446
|
rewritten: rewritten.sort(),
|
|
1675
2447
|
tooling,
|
|
2448
|
+
schemas: schemas.map((entry) => options.write === true && entry.reason === undefined
|
|
2449
|
+
? { ...entry, status: "generated" }
|
|
2450
|
+
: entry),
|
|
1676
2451
|
issues,
|
|
1677
2452
|
};
|
|
1678
2453
|
}
|
|
2454
|
+
/**
|
|
2455
|
+
* Split one route file in place: its server-only exports, and the code only they use, move into its
|
|
2456
|
+
* `x.backend.ts`, created or merged into. Returns the files written, app-relative; nothing for a file
|
|
2457
|
+
* that exports nothing for the server. Refuses, naming why, a split that would need a decision.
|
|
2458
|
+
*/
|
|
2459
|
+
export async function splitRouteFile(appRoot, file, options = {}) {
|
|
2460
|
+
if (!/\.[jt]sx$/.test(file))
|
|
2461
|
+
return [];
|
|
2462
|
+
const ts = options.typescript ?? (await importTypeScript());
|
|
2463
|
+
if (ts === undefined)
|
|
2464
|
+
throw new Error("[nifra] splitting a route needs TypeScript: bun add -d typescript");
|
|
2465
|
+
const root = resolve(appRoot);
|
|
2466
|
+
const backendFile = file.replace(ROUTE_FILE, ".backend.ts");
|
|
2467
|
+
const sharedFile = file.replace(ROUTE_FILE, ".shared.ts");
|
|
2468
|
+
const result = splitRouteSource(ts, readFileSync(join(root, file), "utf8"), file, `./${posix.basename(backendFile)}`, existsSync(join(root, sharedFile)) ? {} : { sharedFile: posix.basename(sharedFile) });
|
|
2469
|
+
if (result.backend === undefined)
|
|
2470
|
+
return [];
|
|
2471
|
+
if (result.issues.length > 0) {
|
|
2472
|
+
throw new Error(`[nifra] ${file} was not split: ${result.issues.join("; ")}`);
|
|
2473
|
+
}
|
|
2474
|
+
const backendPath = join(root, backendFile);
|
|
2475
|
+
const existing = existsSync(backendPath) ? readFileSync(backendPath, "utf8") : "";
|
|
2476
|
+
writeFileSync(join(root, file), result.frontend);
|
|
2477
|
+
writeFileSync(backendPath, mergeModules(ts, existing, result.backend));
|
|
2478
|
+
if (result.shared === undefined)
|
|
2479
|
+
return [file, backendFile];
|
|
2480
|
+
writeFileSync(join(root, sharedFile), result.shared);
|
|
2481
|
+
return [file, backendFile, sharedFile];
|
|
2482
|
+
}
|
|
1679
2483
|
/** The app's `"nifra": { "zones": ... }`, so folders it already zones are left where they are. */
|
|
1680
2484
|
function appZones(root) {
|
|
1681
2485
|
const manifest = join(root, "package.json");
|
|
@@ -1692,57 +2496,246 @@ function appZones(root) {
|
|
|
1692
2496
|
const zones = typeof nifra === "object" && nifra !== null ? Reflect.get(nifra, "zones") : undefined;
|
|
1693
2497
|
return zones === undefined ? {} : parseZoneConfig(zones, manifest);
|
|
1694
2498
|
}
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
spec
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
2499
|
+
const matchers = new Map();
|
|
2500
|
+
/** Whether a tsconfig `include` or `exclude` entry takes in `file` (app-relative), the way tsc reads it. */
|
|
2501
|
+
function matches(pattern, file) {
|
|
2502
|
+
let match = matchers.get(pattern);
|
|
2503
|
+
if (match === undefined) {
|
|
2504
|
+
let spec = pattern.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
|
2505
|
+
if (spec === "" || spec === ".")
|
|
2506
|
+
match = () => true;
|
|
2507
|
+
else if (!/[*?]/.test(spec)) {
|
|
2508
|
+
const folder = `${spec}/`;
|
|
2509
|
+
match = (path) => path === spec || path.startsWith(folder);
|
|
2510
|
+
}
|
|
2511
|
+
else {
|
|
2512
|
+
if (spec.endsWith("**"))
|
|
2513
|
+
spec = `${spec}/*`;
|
|
2514
|
+
else if (!/[*?.][^/]*$/.test(spec.slice(spec.lastIndexOf("/") + 1)))
|
|
2515
|
+
spec = `${spec}/**/*`;
|
|
2516
|
+
const glob = new Bun.Glob(spec);
|
|
2517
|
+
match = (path) => glob.match(path);
|
|
2518
|
+
}
|
|
2519
|
+
matchers.set(pattern, match);
|
|
2520
|
+
}
|
|
2521
|
+
return match(file);
|
|
2522
|
+
}
|
|
2523
|
+
const TSCONFIG = /^tsconfig(?:\.[\w-]+)?\.json$/;
|
|
2524
|
+
const TS_FILE = /\.(?:[cm]?ts|tsx)$/;
|
|
2525
|
+
const inProgram = (lists, file) => lists.files.includes(file) ||
|
|
2526
|
+
(lists.include.some((pattern) => matches(pattern, file)) &&
|
|
2527
|
+
!lists.exclude.some((pattern) => matches(pattern, file)));
|
|
2528
|
+
/** `pattern` under the zone folder `to` landed in, when that reaches `to`; else `to` itself. */
|
|
2529
|
+
function twinEntry(pattern, to) {
|
|
2530
|
+
const slash = to.indexOf("/");
|
|
2531
|
+
if (pattern === undefined || slash === -1)
|
|
2532
|
+
return to;
|
|
2533
|
+
const bare = pattern.replace(/^\.\//, "");
|
|
2534
|
+
const twin = `${to.slice(0, slash)}/${bare}`;
|
|
2535
|
+
if (!matches(twin, to))
|
|
2536
|
+
return to;
|
|
2537
|
+
return bare === pattern ? twin : `./${twin}`;
|
|
1707
2538
|
}
|
|
1708
2539
|
/**
|
|
1709
|
-
*
|
|
1710
|
-
*
|
|
1711
|
-
*
|
|
2540
|
+
* Keep every TypeScript file in, or out of, each app-root `tsconfig*.json` program as it was. An entry
|
|
2541
|
+
* naming a moved file follows it; a folder or glob that took a moved file in gains its twin under the
|
|
2542
|
+
* zone folder (`src` -> `frontend/src`), and one that kept a file out does the same in `exclude`. So a
|
|
2543
|
+
* test run under its own config's types, or a folder left out on purpose, stays out after the move,
|
|
2544
|
+
* and code the config checked stays checked. An `include` or `exclude` a config inherits is not edited.
|
|
1712
2545
|
*/
|
|
1713
|
-
function
|
|
1714
|
-
const
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
const
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
const
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
2546
|
+
function followTsconfigs(ts, root, moves, layout) {
|
|
2547
|
+
const files = new Map();
|
|
2548
|
+
const issues = [];
|
|
2549
|
+
const typescript = layout.filter(({ to }) => TS_FILE.test(to));
|
|
2550
|
+
const names = readdirSync(root)
|
|
2551
|
+
.filter((name) => TSCONFIG.test(name) && isFile(join(root, name)))
|
|
2552
|
+
.sort(codeUnitOrder);
|
|
2553
|
+
// Each config's lists before and after the move, for the configs that extend it.
|
|
2554
|
+
const settled = new Map();
|
|
2555
|
+
const pending = [...names];
|
|
2556
|
+
for (let progress = true; progress && pending.length > 0;) {
|
|
2557
|
+
progress = false;
|
|
2558
|
+
for (const name of [...pending]) {
|
|
2559
|
+
const text = readFileSync(join(root, name), "utf8");
|
|
2560
|
+
const source = ts.parseJsonText(name, text);
|
|
2561
|
+
const top = source.statements[0]?.expression;
|
|
2562
|
+
if (top === undefined || !ts.isObjectLiteralExpression(top)) {
|
|
2563
|
+
pending.splice(pending.indexOf(name), 1);
|
|
2564
|
+
progress = true;
|
|
2565
|
+
if (moves.size > 0)
|
|
2566
|
+
issues.push({
|
|
2567
|
+
file: name,
|
|
2568
|
+
reason: "is not a JSON object; keep the moved files in its include and exclude by hand",
|
|
2569
|
+
});
|
|
2570
|
+
continue;
|
|
2571
|
+
}
|
|
2572
|
+
const base = jsonProperty(ts, top, "extends")?.initializer;
|
|
2573
|
+
let parent;
|
|
2574
|
+
if (base !== undefined && ts.isStringLiteral(base) && base.text.startsWith(".")) {
|
|
2575
|
+
const path = posix.normalize(base.text);
|
|
2576
|
+
const file = path.endsWith(".json") ? path : `${path}.json`;
|
|
2577
|
+
if (names.includes(file) && file !== name)
|
|
2578
|
+
parent = file;
|
|
2579
|
+
}
|
|
2580
|
+
if (parent !== undefined && pending.includes(parent))
|
|
2581
|
+
continue;
|
|
2582
|
+
pending.splice(pending.indexOf(name), 1);
|
|
2583
|
+
progress = true;
|
|
2584
|
+
const inherited = parent === undefined ? undefined : settled.get(parent);
|
|
2585
|
+
const extendsOther = base !== undefined && inherited === undefined;
|
|
2586
|
+
const edits = [];
|
|
2587
|
+
const own = new Map();
|
|
2588
|
+
let malformed;
|
|
2589
|
+
for (const key of ["include", "exclude", "files"]) {
|
|
2590
|
+
const array = jsonProperty(ts, top, key)?.initializer;
|
|
2591
|
+
if (array === undefined)
|
|
2592
|
+
continue;
|
|
2593
|
+
if (!ts.isArrayLiteralExpression(array)) {
|
|
2594
|
+
malformed = key;
|
|
2595
|
+
continue;
|
|
2596
|
+
}
|
|
2597
|
+
const lists = { before: [], after: [] };
|
|
2598
|
+
for (const element of array.elements) {
|
|
2599
|
+
if (!ts.isStringLiteral(element))
|
|
2600
|
+
continue;
|
|
2601
|
+
const to = moves.get(posix.normalize(element.text));
|
|
2602
|
+
lists.before.push(element.text);
|
|
2603
|
+
if (to === undefined) {
|
|
2604
|
+
lists.after.push(element.text);
|
|
2605
|
+
continue;
|
|
2606
|
+
}
|
|
2607
|
+
const spelled = element.text.startsWith("./") ? `./${to}` : to;
|
|
2608
|
+
lists.after.push(spelled);
|
|
2609
|
+
edits.push({
|
|
2610
|
+
start: element.getStart(source) + 1,
|
|
2611
|
+
end: element.getEnd() - 1,
|
|
2612
|
+
text: spelled,
|
|
2613
|
+
});
|
|
2614
|
+
}
|
|
2615
|
+
own.set(key, lists);
|
|
2616
|
+
}
|
|
2617
|
+
let updated = text;
|
|
2618
|
+
for (const edit of edits.sort((a, b) => b.start - a.start))
|
|
2619
|
+
updated = updated.slice(0, edit.start) + edit.text + updated.slice(edit.end);
|
|
2620
|
+
const changes = [];
|
|
2621
|
+
if (edits.length > 0)
|
|
2622
|
+
changes.push(`${edits.length} file path(s) follow their moved files`);
|
|
2623
|
+
const list = (key, when) => {
|
|
2624
|
+
const mine = own.get(key);
|
|
2625
|
+
if (mine !== undefined)
|
|
2626
|
+
return mine[when];
|
|
2627
|
+
if (inherited !== undefined)
|
|
2628
|
+
return [...inherited[when][key]];
|
|
2629
|
+
if (extendsOther)
|
|
2630
|
+
return undefined;
|
|
2631
|
+
// tsc's default: everything below the config, unless `files` lists the program.
|
|
2632
|
+
return key === "exclude" ? [] : own.has("files") ? [] : ["**/*"];
|
|
2633
|
+
};
|
|
2634
|
+
const filesOf = (when) => (own.get("files")?.[when] ?? []).map((entry) => posix.normalize(entry));
|
|
2635
|
+
const beforeInclude = list("include", "before");
|
|
2636
|
+
const beforeExclude = list("exclude", "before");
|
|
2637
|
+
const afterInclude = list("include", "after");
|
|
2638
|
+
const afterExclude = list("exclude", "after");
|
|
2639
|
+
if (malformed !== undefined ||
|
|
2640
|
+
beforeInclude === undefined ||
|
|
2641
|
+
beforeExclude === undefined ||
|
|
2642
|
+
afterInclude === undefined ||
|
|
2643
|
+
afterExclude === undefined) {
|
|
2644
|
+
if (malformed !== undefined && moves.size > 0)
|
|
2645
|
+
issues.push({
|
|
2646
|
+
file: name,
|
|
2647
|
+
reason: `"${malformed}" is not an array; keep the moved files in its program by hand`,
|
|
2648
|
+
});
|
|
2649
|
+
if (edits.length > 0)
|
|
2650
|
+
files.set(name, { text: updated, changes });
|
|
2651
|
+
continue;
|
|
2652
|
+
}
|
|
2653
|
+
const before = {
|
|
2654
|
+
include: beforeInclude,
|
|
2655
|
+
exclude: beforeExclude,
|
|
2656
|
+
files: filesOf("before"),
|
|
2657
|
+
};
|
|
2658
|
+
const after = { include: afterInclude, exclude: afterExclude, files: filesOf("after") };
|
|
2659
|
+
const added = { include: [], exclude: [] };
|
|
2660
|
+
let stranded;
|
|
2661
|
+
let strandedCount = 0;
|
|
2662
|
+
const quiet = new Map();
|
|
2663
|
+
// The whole zone folder is the entry that keeps covering code added there later, but only when
|
|
2664
|
+
// it takes in no file the config left out.
|
|
2665
|
+
const takesInOnlyChecked = (folder) => {
|
|
2666
|
+
let safe = quiet.get(folder);
|
|
2667
|
+
if (safe === undefined) {
|
|
2668
|
+
const wider = { ...after, include: [...after.include, folder] };
|
|
2669
|
+
safe = typescript.every((file) => !matches(folder, file.to) ||
|
|
2670
|
+
inProgram(before, file.from) ||
|
|
2671
|
+
!inProgram(wider, file.to));
|
|
2672
|
+
quiet.set(folder, safe);
|
|
2673
|
+
}
|
|
2674
|
+
return safe;
|
|
2675
|
+
};
|
|
2676
|
+
for (const { from, to } of typescript) {
|
|
2677
|
+
if (!inProgram(before, from) || inProgram(after, to))
|
|
2678
|
+
continue;
|
|
2679
|
+
if (after.include.some((pattern) => matches(pattern, to))) {
|
|
2680
|
+
// Its new place sits under an `exclude` that did not hold the old one.
|
|
2681
|
+
stranded ??= { from, to, key: "exclude" };
|
|
2682
|
+
strandedCount++;
|
|
2683
|
+
continue;
|
|
2684
|
+
}
|
|
2685
|
+
const zone = to.includes("/") ? to.slice(0, to.indexOf("/")) : undefined;
|
|
2686
|
+
const entry = zone !== undefined && takesInOnlyChecked(zone)
|
|
2687
|
+
? zone
|
|
2688
|
+
: twinEntry(before.include.find((pattern) => matches(pattern, from)), to);
|
|
2689
|
+
added.include.push(entry);
|
|
2690
|
+
after.include.push(entry);
|
|
2691
|
+
}
|
|
2692
|
+
for (const { from, to } of typescript) {
|
|
2693
|
+
if (inProgram(before, from) || !inProgram(after, to))
|
|
2694
|
+
continue;
|
|
2695
|
+
let entry = twinEntry(before.exclude.find((pattern) => matches(pattern, from)), to);
|
|
2696
|
+
// A twin that would also drop a file the config checked is too wide: name the file.
|
|
2697
|
+
if (entry !== to &&
|
|
2698
|
+
typescript.some((other) => matches(entry, other.to) && inProgram(before, other.from)))
|
|
2699
|
+
entry = to;
|
|
2700
|
+
added.exclude.push(entry);
|
|
2701
|
+
after.exclude.push(entry);
|
|
2702
|
+
}
|
|
2703
|
+
for (const key of ["include", "exclude"]) {
|
|
2704
|
+
const entries = [...new Set(added[key])].sort(codeUnitOrder);
|
|
2705
|
+
if (entries.length === 0)
|
|
2706
|
+
continue;
|
|
2707
|
+
const reparsed = ts.parseJsonText(name, updated);
|
|
2708
|
+
const object = reparsed.statements[0]?.expression;
|
|
2709
|
+
const array = object !== undefined && ts.isObjectLiteralExpression(object)
|
|
2710
|
+
? jsonProperty(ts, object, key)?.initializer
|
|
2711
|
+
: undefined;
|
|
2712
|
+
if (array === undefined || !ts.isArrayLiteralExpression(array)) {
|
|
2713
|
+
stranded ??= { from: "", to: entries[0] ?? "", key };
|
|
2714
|
+
strandedCount += entries.length;
|
|
2715
|
+
continue;
|
|
2716
|
+
}
|
|
2717
|
+
updated = appendToArray(reparsed, updated, array, entries);
|
|
2718
|
+
changes.push(`${key} += ${entries.join(", ")}`);
|
|
2719
|
+
}
|
|
2720
|
+
if (stranded !== undefined)
|
|
2721
|
+
issues.push({
|
|
2722
|
+
file: name,
|
|
2723
|
+
reason: `cannot keep ${strandedCount} moved file(s) in or out of its program as before (first: "${stranded.to}"); its "${stranded.key}" is inherited or would hide them, so set it by hand`,
|
|
2724
|
+
});
|
|
2725
|
+
settled.set(name, { before, after });
|
|
2726
|
+
if (changes.length > 0)
|
|
2727
|
+
files.set(name, { text: updated, changes });
|
|
2728
|
+
}
|
|
1737
2729
|
}
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
2730
|
+
return { files, issues };
|
|
2731
|
+
}
|
|
2732
|
+
/** `text` with `entries` appended to `array`: one per line in a multi-line array, else inline. */
|
|
2733
|
+
function appendToArray(source, text, array, entries) {
|
|
2734
|
+
const quoted = entries.map((entry) => JSON.stringify(entry));
|
|
1742
2735
|
const last = array.elements[array.elements.length - 1];
|
|
1743
2736
|
if (last === undefined) {
|
|
1744
2737
|
const at = array.getStart(source) + 1;
|
|
1745
|
-
return
|
|
2738
|
+
return `${text.slice(0, at)}${quoted.join(", ")}${text.slice(at)}`;
|
|
1746
2739
|
}
|
|
1747
2740
|
let at = last.end;
|
|
1748
2741
|
const trailing = /^\s*,/.exec(text.slice(at));
|
|
@@ -1759,15 +2752,12 @@ function coverMovedFiles(ts, root, moves) {
|
|
|
1759
2752
|
const head = trailing !== null
|
|
1760
2753
|
? text.slice(0, eol)
|
|
1761
2754
|
: `${text.slice(0, last.end)},${text.slice(last.end, eol)}`;
|
|
1762
|
-
const
|
|
1763
|
-
return {
|
|
1764
|
-
text: `${head}\n${indent}${entries}${trailing !== null ? "," : ""}${text.slice(eol)}`,
|
|
1765
|
-
added,
|
|
1766
|
-
};
|
|
2755
|
+
const lines = quoted.join(`,\n${indent}`);
|
|
2756
|
+
return `${head}\n${indent}${lines}${trailing !== null ? "," : ""}${text.slice(eol)}`;
|
|
1767
2757
|
}
|
|
1768
2758
|
}
|
|
1769
2759
|
const insert = trailing !== null ? ` ${quoted.join(", ")},` : `, ${quoted.join(", ")}`;
|
|
1770
|
-
return
|
|
2760
|
+
return `${text.slice(0, at)}${insert}${text.slice(at)}`;
|
|
1771
2761
|
}
|
|
1772
2762
|
const jsonProperty = (ts, object, key) => object.properties.find((property) => ts.isPropertyAssignment(property) &&
|
|
1773
2763
|
ts.isStringLiteral(property.name) &&
|
|
@@ -1785,6 +2775,104 @@ function insertProperty(source, text, object, entry) {
|
|
|
1785
2775
|
const indent = /^[ \t]*/.exec(text.slice(text.lastIndexOf("\n", start) + 1, start))?.[0] ?? "";
|
|
1786
2776
|
return `${text.slice(0, last.end)},\n${indent}${entry}${text.slice(last.end)}`;
|
|
1787
2777
|
}
|
|
2778
|
+
/**
|
|
2779
|
+
* The `nifra.zones` entries that keep `inPlace` files where they are: for each, the widest folder
|
|
2780
|
+
* above it whose zoned files all take its side (a nested package stops the climb), else the file
|
|
2781
|
+
* itself. A file another side's suffix or a server built-in claims counts against a folder.
|
|
2782
|
+
*/
|
|
2783
|
+
function zoneDeclarations(inPlace, files, text, configured) {
|
|
2784
|
+
const sides = new Map();
|
|
2785
|
+
const packages = new Set();
|
|
2786
|
+
for (const file of files) {
|
|
2787
|
+
const top = file.split("/")[0] ?? "";
|
|
2788
|
+
if (!file.includes("/") || ZONE_DIRS.has(top))
|
|
2789
|
+
continue;
|
|
2790
|
+
if (posix.basename(file) === "package.json") {
|
|
2791
|
+
for (let dir = posix.dirname(file); dir !== "."; dir = posix.dirname(dir))
|
|
2792
|
+
packages.add(dir);
|
|
2793
|
+
continue;
|
|
2794
|
+
}
|
|
2795
|
+
// A configured path is more specific than any folder above it, so it never counts against one.
|
|
2796
|
+
if (isTestFile(file) || file.endsWith(".d.ts") || configured(file))
|
|
2797
|
+
continue;
|
|
2798
|
+
const suffix = /\.(frontend|backend|shared|fn)\.[cm]?[jt]sx?$/.exec(file)?.[1];
|
|
2799
|
+
const side = inPlace.get(file) ??
|
|
2800
|
+
(suffix === "fn" ? "backend" : suffix) ??
|
|
2801
|
+
(SOURCE_FILE.test(file) && /from\s+["'](?:node:|bun:)/.test(text(file) ?? "")
|
|
2802
|
+
? "backend"
|
|
2803
|
+
: undefined);
|
|
2804
|
+
if (side === undefined)
|
|
2805
|
+
continue;
|
|
2806
|
+
for (let dir = posix.dirname(file); dir !== "."; dir = posix.dirname(dir))
|
|
2807
|
+
sides.set(dir, (sides.get(dir) ?? new Set()).add(side));
|
|
2808
|
+
}
|
|
2809
|
+
const declared = new Map();
|
|
2810
|
+
for (const [file, side] of [...inPlace].sort(([a], [b]) => codeUnitOrder(a, b))) {
|
|
2811
|
+
const parts = file.split("/");
|
|
2812
|
+
let path = file;
|
|
2813
|
+
for (let depth = 1; depth < parts.length; depth++) {
|
|
2814
|
+
const dir = parts.slice(0, depth).join("/");
|
|
2815
|
+
const found = sides.get(dir);
|
|
2816
|
+
if (!packages.has(dir) && found?.size === 1 && found.has(side)) {
|
|
2817
|
+
path = dir;
|
|
2818
|
+
break;
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
declared.set(path, side);
|
|
2822
|
+
}
|
|
2823
|
+
return declared;
|
|
2824
|
+
}
|
|
2825
|
+
/** The app's package.json with `paths` added to `"nifra": { "zones": ... }`, editing only where they go. */
|
|
2826
|
+
function declareZones(ts, root, paths) {
|
|
2827
|
+
const listed = (side) => [...paths].flatMap(([path, of]) => (of === side ? [path] : []));
|
|
2828
|
+
const setting = `{ ${FOLDER_SIDES.filter((side) => listed(side).length > 0)
|
|
2829
|
+
.map((side) => `"${side}": ${JSON.stringify(listed(side))}`)
|
|
2830
|
+
.join(", ")} }`;
|
|
2831
|
+
const path = join(root, "package.json");
|
|
2832
|
+
if (!existsSync(path))
|
|
2833
|
+
return { issue: `there is none in the app; declare "nifra": { "zones": ${setting} } in it` };
|
|
2834
|
+
let text = readFileSync(path, "utf8");
|
|
2835
|
+
// Parsed again for each edit: an edit moves every offset after it.
|
|
2836
|
+
const find = (keys) => {
|
|
2837
|
+
const source = ts.parseJsonText(path, text);
|
|
2838
|
+
let node = source.statements[0]?.expression;
|
|
2839
|
+
for (const key of keys)
|
|
2840
|
+
node =
|
|
2841
|
+
node !== undefined && ts.isObjectLiteralExpression(node)
|
|
2842
|
+
? jsonProperty(ts, node, key)?.initializer
|
|
2843
|
+
: undefined;
|
|
2844
|
+
return { source, node };
|
|
2845
|
+
};
|
|
2846
|
+
const top = find([]);
|
|
2847
|
+
if (top.node === undefined || !ts.isObjectLiteralExpression(top.node))
|
|
2848
|
+
return { issue: `is not a JSON object; add "nifra": { "zones": ${setting} } to it by hand` };
|
|
2849
|
+
const nifra = find(["nifra"]);
|
|
2850
|
+
if (nifra.node === undefined)
|
|
2851
|
+
return { text: insertProperty(top.source, text, top.node, `"nifra": { "zones": ${setting} }`) };
|
|
2852
|
+
if (!ts.isObjectLiteralExpression(nifra.node))
|
|
2853
|
+
return { issue: `"nifra" is not an object; add "zones": ${setting} to it by hand` };
|
|
2854
|
+
const zones = find(["nifra", "zones"]);
|
|
2855
|
+
if (zones.node === undefined)
|
|
2856
|
+
return { text: insertProperty(nifra.source, text, nifra.node, `"zones": ${setting}`) };
|
|
2857
|
+
for (const side of FOLDER_SIDES) {
|
|
2858
|
+
const add = listed(side);
|
|
2859
|
+
if (add.length === 0)
|
|
2860
|
+
continue;
|
|
2861
|
+
const { source, node } = find(["nifra", "zones"]);
|
|
2862
|
+
if (node === undefined || !ts.isObjectLiteralExpression(node))
|
|
2863
|
+
return { issue: `"nifra.zones" is not an object; add ${setting} to it by hand` };
|
|
2864
|
+
const array = jsonProperty(ts, node, side)?.initializer;
|
|
2865
|
+
if (array === undefined)
|
|
2866
|
+
text = insertProperty(source, text, node, `"${side}": ${JSON.stringify(add)}`);
|
|
2867
|
+
else if (ts.isArrayLiteralExpression(array))
|
|
2868
|
+
text = appendToArray(source, text, array, add);
|
|
2869
|
+
else
|
|
2870
|
+
return {
|
|
2871
|
+
issue: `"nifra.zones.${side}" is not an array; add ${JSON.stringify(add)} to it by hand`,
|
|
2872
|
+
};
|
|
2873
|
+
}
|
|
2874
|
+
return { text };
|
|
2875
|
+
}
|
|
1788
2876
|
/** The app's package.json with `"nifra": { "kind": "api" }` added, editing only where it goes. */
|
|
1789
2877
|
function declareApiKind(ts, root) {
|
|
1790
2878
|
const path = join(root, "package.json");
|