@nifrajs/ts-plugin 3.3.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/index.cjs +146 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/zones.d.ts +9 -0
- package/dist/zones.d.ts.map +1 -0
- package/dist/zones.js +146 -0
- package/dist/zones.js.map +1 -0
- package/package.json +7 -4
- package/src/index.ts +18 -4
- package/src/zones.ts +196 -0
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@ A TypeScript language-service plugin for [nifra](https://github.com/nifrajs/nifr
|
|
|
4
4
|
|
|
5
5
|
The routing rules are nifra's own - it discovers routes with `@nifrajs/web` and matches them with `@nifrajs/core`'s pattern matcher - so a path resolves to exactly the file it would serve at runtime, dynamic segments included (`/users/42` → `routes/users/[id].tsx`).
|
|
6
6
|
|
|
7
|
+
It also shows the frontend/backend zone rules as you type: a value import that the build would refuse - backend code in a page, frontend code in `backend/`, anything but shared code in `shared/`, a Node built-in or a server package (`pg`, a mailer, a server SDK) in browser code - is an error on the import itself. `import type` is always allowed. The rules come from `@nifrajs/web/zones`, the same classifier the builds and `nifra check` use; the build stays authoritative and reports the full import chain.
|
|
8
|
+
|
|
7
9
|
## Install
|
|
8
10
|
|
|
9
11
|
```sh
|
package/dist/index.cjs
CHANGED
|
@@ -39,13 +39,13 @@ var __export = (target, all) => {
|
|
|
39
39
|
// src/index.ts
|
|
40
40
|
var exports_src = {};
|
|
41
41
|
__export(exports_src, {
|
|
42
|
-
|
|
42
|
+
default: () => src_default,
|
|
43
43
|
findRoutePathLiteral: () => findRoutePathLiteral,
|
|
44
|
-
|
|
44
|
+
findRoutesDir: () => findRoutesDir
|
|
45
45
|
});
|
|
46
46
|
module.exports = __toCommonJS(exports_src);
|
|
47
|
-
var
|
|
48
|
-
var
|
|
47
|
+
var import_node_fs2 = require("node:fs");
|
|
48
|
+
var import_node_path2 = require("node:path");
|
|
49
49
|
var import_fs = require("@nifrajs/web/fs");
|
|
50
50
|
|
|
51
51
|
// src/resolve.ts
|
|
@@ -70,13 +70,128 @@ function resolveRouteFile(path, routes) {
|
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// src/zones.ts
|
|
74
|
+
var import_node_fs = require("node:fs");
|
|
75
|
+
var import_node_path = require("node:path");
|
|
76
|
+
var import_zones = require("@nifrajs/web/zones");
|
|
77
|
+
var ZONE_DIAGNOSTIC_CODE = 990028;
|
|
78
|
+
var BROWSER_CODE = new Set(["route-frontend", "frontend", "shared"]);
|
|
79
|
+
var allTypes = (elements) => elements.length > 0 && elements.every((element) => element.isTypeOnly);
|
|
80
|
+
function valueImports(tsm, source) {
|
|
81
|
+
const edges = [];
|
|
82
|
+
const add = (node) => {
|
|
83
|
+
if (node !== undefined && tsm.isStringLiteralLike(node)) {
|
|
84
|
+
edges.push({ specifier: node.text, literal: node });
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const visit = (node) => {
|
|
88
|
+
if (tsm.isImportDeclaration(node)) {
|
|
89
|
+
const clause = node.importClause;
|
|
90
|
+
const bindings = clause?.namedBindings;
|
|
91
|
+
const typeOnly = clause !== undefined && (clause.isTypeOnly || clause.name === undefined && bindings !== undefined && tsm.isNamedImports(bindings) && allTypes(bindings.elements));
|
|
92
|
+
if (!typeOnly)
|
|
93
|
+
add(node.moduleSpecifier);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (tsm.isExportDeclaration(node)) {
|
|
97
|
+
const clause = node.exportClause;
|
|
98
|
+
const typeOnly = node.isTypeOnly || clause !== undefined && tsm.isNamedExports(clause) && allTypes(clause.elements);
|
|
99
|
+
if (!typeOnly)
|
|
100
|
+
add(node.moduleSpecifier);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (tsm.isImportEqualsDeclaration(node) && !node.isTypeOnly && tsm.isExternalModuleReference(node.moduleReference)) {
|
|
104
|
+
add(node.moduleReference.expression);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (tsm.isCallExpression(node) && node.expression.kind === tsm.SyntaxKind.ImportKeyword) {
|
|
108
|
+
add(node.arguments[0]);
|
|
109
|
+
}
|
|
110
|
+
tsm.forEachChild(node, visit);
|
|
111
|
+
};
|
|
112
|
+
visit(source);
|
|
113
|
+
return edges;
|
|
114
|
+
}
|
|
115
|
+
function resolveImport(tsm, source, edge, options) {
|
|
116
|
+
const specifier = edge.specifier.replace(/[?#].*$/, "");
|
|
117
|
+
const mode = tsm.getModeForUsageLocation(source, edge.literal, options);
|
|
118
|
+
const resolved = tsm.resolveModuleName(specifier, source.fileName, options, tsm.sys, undefined, undefined, mode).resolvedModule?.resolvedFileName;
|
|
119
|
+
if (resolved !== undefined)
|
|
120
|
+
return resolved;
|
|
121
|
+
if (!specifier.startsWith("./") && !specifier.startsWith("../"))
|
|
122
|
+
return;
|
|
123
|
+
const file = import_node_path.resolve(import_node_path.dirname(source.fileName), specifier);
|
|
124
|
+
return import_node_fs.existsSync(file) ? file : undefined;
|
|
125
|
+
}
|
|
126
|
+
var real = (file) => {
|
|
127
|
+
try {
|
|
128
|
+
return import_node_fs.realpathSync.native(file);
|
|
129
|
+
} catch {
|
|
130
|
+
return file;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
function libraryAsImported(target, specifier) {
|
|
134
|
+
if (target.zone !== "library")
|
|
135
|
+
return target;
|
|
136
|
+
const named = import_zones.specifierPackage(specifier);
|
|
137
|
+
if (named === undefined || named === target.packageName)
|
|
138
|
+
return target;
|
|
139
|
+
return { zone: "library", packageName: named };
|
|
140
|
+
}
|
|
141
|
+
function edgeProblem(classifier, display, fileName, from, specifier, target) {
|
|
142
|
+
const browser = BROWSER_CODE.has(from);
|
|
143
|
+
if (browser && /^(?:node|bun):/.test(specifier)) {
|
|
144
|
+
return `"${specifier}" may not reach the browser: Node and Bun built-ins run on the server only; use them in a route's backend half or under backend/`;
|
|
145
|
+
}
|
|
146
|
+
if (browser && specifier === import_zones.BACKEND_ONLY_MARKER) {
|
|
147
|
+
return `"${import_zones.BACKEND_ONLY_MARKER}" marks a module backend-only, but ${display(fileName)} reaches the browser. Move it under backend/ or into a route's backend half`;
|
|
148
|
+
}
|
|
149
|
+
if (target === undefined)
|
|
150
|
+
return;
|
|
151
|
+
const to = classifier.classify(target);
|
|
152
|
+
if (to.zone === "error")
|
|
153
|
+
return `${display(fileName)} imports ${display(target)}: ${to.reason}`;
|
|
154
|
+
if (to.zone === "library") {
|
|
155
|
+
const reason = browser ? import_zones.browserDenial(libraryAsImported(to, specifier), specifier) : undefined;
|
|
156
|
+
return reason === undefined ? undefined : `"${specifier}" may not reach the browser: ${reason}`;
|
|
157
|
+
}
|
|
158
|
+
return import_zones.importAllowed(from, to.zone) ? undefined : import_zones.importRuleMessage(display(fileName), from, display(target), to.zone);
|
|
159
|
+
}
|
|
160
|
+
function zoneDiagnostics(tsm, source, appRoot, options) {
|
|
161
|
+
const classifier = import_zones.createZoneClassifier({ appRoot });
|
|
162
|
+
const from = classifier.classify(source.fileName);
|
|
163
|
+
if (from.zone === "error" || from.zone === "library" || from.zone === "generated")
|
|
164
|
+
return [];
|
|
165
|
+
const display = (file) => {
|
|
166
|
+
const rel = import_node_path.relative(classifier.appRoot, real(file)).replaceAll("\\", "/");
|
|
167
|
+
return rel.startsWith("../") || import_node_path.isAbsolute(rel) ? file : rel;
|
|
168
|
+
};
|
|
169
|
+
const diagnostics = [];
|
|
170
|
+
for (const edge of valueImports(tsm, source)) {
|
|
171
|
+
const target = resolveImport(tsm, source, edge, options);
|
|
172
|
+
const message = edgeProblem(classifier, display, source.fileName, from.zone, edge.specifier, target);
|
|
173
|
+
if (message === undefined)
|
|
174
|
+
continue;
|
|
175
|
+
diagnostics.push({
|
|
176
|
+
file: source,
|
|
177
|
+
start: edge.literal.getStart(source),
|
|
178
|
+
length: edge.literal.getWidth(source),
|
|
179
|
+
messageText: message,
|
|
180
|
+
category: tsm.DiagnosticCategory.Error,
|
|
181
|
+
code: ZONE_DIAGNOSTIC_CODE,
|
|
182
|
+
source: "nifra"
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return diagnostics;
|
|
186
|
+
}
|
|
187
|
+
|
|
73
188
|
// src/index.ts
|
|
74
|
-
function findRoutesDir(fromFile, exists =
|
|
75
|
-
let dir =
|
|
189
|
+
function findRoutesDir(fromFile, exists = import_node_fs2.existsSync) {
|
|
190
|
+
let dir = import_node_path2.dirname(fromFile);
|
|
76
191
|
for (let i = 0;i < 64; i++) {
|
|
77
|
-
if (exists(
|
|
78
|
-
return
|
|
79
|
-
const parent =
|
|
192
|
+
if (exists(import_node_path2.join(dir, "routes")))
|
|
193
|
+
return import_node_path2.join(dir, "routes");
|
|
194
|
+
const parent = import_node_path2.dirname(dir);
|
|
80
195
|
if (parent === dir)
|
|
81
196
|
return;
|
|
82
197
|
dir = parent;
|
|
@@ -89,13 +204,13 @@ function normalizeProgramPath(fileName) {
|
|
|
89
204
|
return process.platform === "win32" ? withoutDevicePrefix.toLowerCase() : withoutDevicePrefix;
|
|
90
205
|
}
|
|
91
206
|
function canonicalProgramPaths(tsm, fileName) {
|
|
92
|
-
const absolute =
|
|
207
|
+
const absolute = import_node_path2.resolve(fileName);
|
|
93
208
|
const paths = new Set([normalizeProgramPath(absolute)]);
|
|
94
209
|
const tsmRealpath = tsm.sys.realpath;
|
|
95
210
|
const resolvers = [
|
|
96
211
|
...typeof tsmRealpath === "function" ? [tsmRealpath] : [],
|
|
97
|
-
|
|
98
|
-
|
|
212
|
+
import_node_fs2.realpathSync.native,
|
|
213
|
+
import_node_fs2.realpathSync
|
|
99
214
|
];
|
|
100
215
|
for (const resolver of resolvers) {
|
|
101
216
|
try {
|
|
@@ -110,20 +225,20 @@ function typeScriptProgramPath(tsm, program, fileName) {
|
|
|
110
225
|
return;
|
|
111
226
|
const getCanonicalFileName = tsm.sys.useCaseSensitiveFileNames ? (value) => value : (value) => value.toLowerCase();
|
|
112
227
|
try {
|
|
113
|
-
return toPath(
|
|
228
|
+
return toPath(import_node_path2.resolve(fileName), program.getCurrentDirectory(), getCanonicalFileName);
|
|
114
229
|
} catch {
|
|
115
230
|
return;
|
|
116
231
|
}
|
|
117
232
|
}
|
|
118
233
|
function filesystemIdentity(fileName) {
|
|
119
234
|
try {
|
|
120
|
-
const info =
|
|
235
|
+
const info = import_node_fs2.statSync(import_node_path2.resolve(fileName), { bigint: true });
|
|
121
236
|
if (info.ino === 0n)
|
|
122
237
|
return;
|
|
123
238
|
return `bigint:${info.dev}:${info.ino}`;
|
|
124
239
|
} catch {
|
|
125
240
|
try {
|
|
126
|
-
const info =
|
|
241
|
+
const info = import_node_fs2.statSync(import_node_path2.resolve(fileName));
|
|
127
242
|
if (info.ino === 0)
|
|
128
243
|
return;
|
|
129
244
|
return `number:${info.dev}:${info.ino}`;
|
|
@@ -167,7 +282,7 @@ function hostSourceFile(tsm, host, program, fileName) {
|
|
|
167
282
|
function diskSourceFile(tsm, program, fileName) {
|
|
168
283
|
let text;
|
|
169
284
|
try {
|
|
170
|
-
text =
|
|
285
|
+
text = import_node_fs2.readFileSync(fileName, "utf8");
|
|
171
286
|
} catch {
|
|
172
287
|
return;
|
|
173
288
|
}
|
|
@@ -213,8 +328,8 @@ function routeDefinitionAt(tsm, ls, host, fileName, position) {
|
|
|
213
328
|
}
|
|
214
329
|
if (file === undefined)
|
|
215
330
|
return;
|
|
216
|
-
const target =
|
|
217
|
-
if (!
|
|
331
|
+
const target = import_node_path2.isAbsolute(file) ? file : import_node_path2.join(routesDir, file);
|
|
332
|
+
if (!import_node_fs2.existsSync(target))
|
|
218
333
|
return;
|
|
219
334
|
return {
|
|
220
335
|
definitions: [
|
|
@@ -251,6 +366,19 @@ function init(modules) {
|
|
|
251
366
|
const source = programSourceFile(tsm, ls.getProgram(), fileName);
|
|
252
367
|
return source === undefined ? undefined : ls.getDefinitionAndBoundSpan(source.fileName, position);
|
|
253
368
|
})();
|
|
369
|
+
proxy.getSemanticDiagnostics = (fileName) => {
|
|
370
|
+
const source = programSourceFile(tsm, ls.getProgram(), fileName);
|
|
371
|
+
const own = ls.getSemanticDiagnostics(source?.fileName ?? fileName);
|
|
372
|
+
const routesDir = source === undefined ? undefined : findRoutesDir(source.fileName);
|
|
373
|
+
if (source === undefined || routesDir === undefined)
|
|
374
|
+
return own;
|
|
375
|
+
try {
|
|
376
|
+
const options = ls.getProgram()?.getCompilerOptions() ?? {};
|
|
377
|
+
return [...own, ...zoneDiagnostics(tsm, source, import_node_path2.dirname(routesDir), options)];
|
|
378
|
+
} catch {
|
|
379
|
+
return own;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
254
382
|
return proxy;
|
|
255
383
|
}
|
|
256
384
|
};
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAkBA,OAAO,KAAK,KAAK,EAAE,MAAM,YAAY,CAAA;AAIrC,sGAAsG;AACtG,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,MAAM,EAChB,MAAM,GAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAoB,GAC1C,MAAM,GAAG,SAAS,CASpB;AAgMD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,OAAO,EAAE,EACd,MAAM,EAAE,EAAE,CAAC,UAAU,EACrB,QAAQ,EAAE,MAAM,GACf;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,EAAE,CAAC,QAAQ,CAAA;CAAE,GAAG,SAAS,CAMjD;AA4CD,iBAAS,IAAI,CAAC,OAAO,EAAE;IAAE,UAAU,EAAE,OAAO,EAAE,CAAA;CAAE,GAAG,EAAE,CAAC,MAAM,CAAC,YAAY,CAyDxE;AAED,eAAe,IAAI,CAAA"}
|
package/dist/zones.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type * as ts from "typescript";
|
|
2
|
+
/** The code every zone diagnostic carries; TypeScript requires a number, and none of its own is this. */
|
|
3
|
+
export declare const ZONE_DIAGNOSTIC_CODE = 990028;
|
|
4
|
+
/**
|
|
5
|
+
* The zone diagnostics for one file of the app rooted at `appRoot`. A fresh classifier per call keeps a
|
|
6
|
+
* moved file or an edited package declaration from going stale; it costs a few `stat`s per import.
|
|
7
|
+
*/
|
|
8
|
+
export declare function zoneDiagnostics(tsm: typeof ts, source: ts.SourceFile, appRoot: string, options: ts.CompilerOptions): ts.Diagnostic[];
|
|
9
|
+
//# sourceMappingURL=zones.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zones.d.ts","sourceRoot":"","sources":["../src/zones.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,KAAK,EAAE,MAAM,YAAY,CAAA;AAErC,yGAAyG;AACzG,eAAO,MAAM,oBAAoB,SAAS,CAAA;AAmI1C;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,GAAG,EAAE,OAAO,EAAE,EACd,MAAM,EAAE,EAAE,CAAC,UAAU,EACrB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,EAAE,CAAC,eAAe,GAC1B,EAAE,CAAC,UAAU,EAAE,CA+BjB"}
|
package/dist/zones.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zone diagnostics: the frontend/backend import rules of `@nifrajs/web/zones`, in the editor.
|
|
3
|
+
*
|
|
4
|
+
* Assist only. The browser builds, the dev servers and `nifra check` enforce the same rules with the
|
|
5
|
+
* full import chain; this reports the direct edge as it is typed, so a backend import in a page is
|
|
6
|
+
* red before anything is built. Files in no zone (tests, scripts, config) are never reported: no
|
|
7
|
+
* build loads them.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
10
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
11
|
+
import { BACKEND_ONLY_MARKER, browserDenial, createZoneClassifier, importAllowed, importRuleMessage, specifierPackage, } from "@nifrajs/web/zones";
|
|
12
|
+
/** The code every zone diagnostic carries; TypeScript requires a number, and none of its own is this. */
|
|
13
|
+
export const ZONE_DIAGNOSTIC_CODE = 990028;
|
|
14
|
+
/** Zones whose own code runs in a browser. */
|
|
15
|
+
const BROWSER_CODE = new Set(["route-frontend", "frontend", "shared"]);
|
|
16
|
+
const allTypes = (elements) => elements.length > 0 && elements.every((element) => element.isTypeOnly);
|
|
17
|
+
/** Every value import in a file: static, re-export and literal dynamic `import()`. Type-only ones are
|
|
18
|
+
* erased before bundling, so they may cross zones. */
|
|
19
|
+
function valueImports(tsm, source) {
|
|
20
|
+
const edges = [];
|
|
21
|
+
const add = (node) => {
|
|
22
|
+
if (node !== undefined && tsm.isStringLiteralLike(node)) {
|
|
23
|
+
edges.push({ specifier: node.text, literal: node });
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const visit = (node) => {
|
|
27
|
+
if (tsm.isImportDeclaration(node)) {
|
|
28
|
+
const clause = node.importClause;
|
|
29
|
+
const bindings = clause?.namedBindings;
|
|
30
|
+
const typeOnly = clause !== undefined &&
|
|
31
|
+
(clause.isTypeOnly ||
|
|
32
|
+
(clause.name === undefined &&
|
|
33
|
+
bindings !== undefined &&
|
|
34
|
+
tsm.isNamedImports(bindings) &&
|
|
35
|
+
allTypes(bindings.elements)));
|
|
36
|
+
if (!typeOnly)
|
|
37
|
+
add(node.moduleSpecifier);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (tsm.isExportDeclaration(node)) {
|
|
41
|
+
const clause = node.exportClause;
|
|
42
|
+
const typeOnly = node.isTypeOnly ||
|
|
43
|
+
(clause !== undefined && tsm.isNamedExports(clause) && allTypes(clause.elements));
|
|
44
|
+
if (!typeOnly)
|
|
45
|
+
add(node.moduleSpecifier);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (tsm.isImportEqualsDeclaration(node) &&
|
|
49
|
+
!node.isTypeOnly &&
|
|
50
|
+
tsm.isExternalModuleReference(node.moduleReference)) {
|
|
51
|
+
add(node.moduleReference.expression);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (tsm.isCallExpression(node) && node.expression.kind === tsm.SyntaxKind.ImportKeyword) {
|
|
55
|
+
add(node.arguments[0]);
|
|
56
|
+
}
|
|
57
|
+
tsm.forEachChild(node, visit);
|
|
58
|
+
};
|
|
59
|
+
visit(source);
|
|
60
|
+
return edges;
|
|
61
|
+
}
|
|
62
|
+
/** Where an import lands: TypeScript's resolution (tsconfig `paths` included), else a relative path
|
|
63
|
+
* that exists on disk (an `.svelte` or `.vue` file TypeScript only knows through a shim). */
|
|
64
|
+
function resolveImport(tsm, source, edge, options) {
|
|
65
|
+
const specifier = edge.specifier.replace(/[?#].*$/, "");
|
|
66
|
+
const mode = tsm.getModeForUsageLocation(source, edge.literal, options);
|
|
67
|
+
const resolved = tsm.resolveModuleName(specifier, source.fileName, options, tsm.sys, undefined, undefined, mode).resolvedModule?.resolvedFileName;
|
|
68
|
+
if (resolved !== undefined)
|
|
69
|
+
return resolved;
|
|
70
|
+
if (!specifier.startsWith("./") && !specifier.startsWith("../"))
|
|
71
|
+
return undefined;
|
|
72
|
+
const file = resolve(dirname(source.fileName), specifier);
|
|
73
|
+
return existsSync(file) ? file : undefined;
|
|
74
|
+
}
|
|
75
|
+
const real = (file) => {
|
|
76
|
+
try {
|
|
77
|
+
return realpathSync.native(file);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return file;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
/** A third-party target as the specifier names it: TypeScript resolves `pg` to `@types/pg`, and the
|
|
84
|
+
* server-package list knows `pg`. */
|
|
85
|
+
function libraryAsImported(target, specifier) {
|
|
86
|
+
if (target.zone !== "library")
|
|
87
|
+
return target;
|
|
88
|
+
const named = specifierPackage(specifier);
|
|
89
|
+
if (named === undefined || named === target.packageName)
|
|
90
|
+
return target;
|
|
91
|
+
return { zone: "library", packageName: named };
|
|
92
|
+
}
|
|
93
|
+
/** Why one import breaks the zone rules, or `undefined` when it does not. */
|
|
94
|
+
function edgeProblem(classifier, display, fileName, from, specifier, target) {
|
|
95
|
+
const browser = BROWSER_CODE.has(from);
|
|
96
|
+
if (browser && /^(?:node|bun):/.test(specifier)) {
|
|
97
|
+
return `"${specifier}" may not reach the browser: Node and Bun built-ins run on the server only; use them in a route's backend half or under backend/`;
|
|
98
|
+
}
|
|
99
|
+
if (browser && specifier === BACKEND_ONLY_MARKER) {
|
|
100
|
+
return `"${BACKEND_ONLY_MARKER}" marks a module backend-only, but ${display(fileName)} reaches the browser. Move it under backend/ or into a route's backend half`;
|
|
101
|
+
}
|
|
102
|
+
if (target === undefined)
|
|
103
|
+
return undefined;
|
|
104
|
+
const to = classifier.classify(target);
|
|
105
|
+
if (to.zone === "error")
|
|
106
|
+
return `${display(fileName)} imports ${display(target)}: ${to.reason}`;
|
|
107
|
+
if (to.zone === "library") {
|
|
108
|
+
const reason = browser ? browserDenial(libraryAsImported(to, specifier), specifier) : undefined;
|
|
109
|
+
return reason === undefined ? undefined : `"${specifier}" may not reach the browser: ${reason}`;
|
|
110
|
+
}
|
|
111
|
+
return importAllowed(from, to.zone)
|
|
112
|
+
? undefined
|
|
113
|
+
: importRuleMessage(display(fileName), from, display(target), to.zone);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The zone diagnostics for one file of the app rooted at `appRoot`. A fresh classifier per call keeps a
|
|
117
|
+
* moved file or an edited package declaration from going stale; it costs a few `stat`s per import.
|
|
118
|
+
*/
|
|
119
|
+
export function zoneDiagnostics(tsm, source, appRoot, options) {
|
|
120
|
+
const classifier = createZoneClassifier({ appRoot });
|
|
121
|
+
const from = classifier.classify(source.fileName);
|
|
122
|
+
if (from.zone === "error" || from.zone === "library" || from.zone === "generated")
|
|
123
|
+
return [];
|
|
124
|
+
const display = (file) => {
|
|
125
|
+
const rel = relative(classifier.appRoot, real(file)).replaceAll("\\", "/");
|
|
126
|
+
return rel.startsWith("../") || isAbsolute(rel) ? file : rel;
|
|
127
|
+
};
|
|
128
|
+
const diagnostics = [];
|
|
129
|
+
for (const edge of valueImports(tsm, source)) {
|
|
130
|
+
const target = resolveImport(tsm, source, edge, options);
|
|
131
|
+
const message = edgeProblem(classifier, display, source.fileName, from.zone, edge.specifier, target);
|
|
132
|
+
if (message === undefined)
|
|
133
|
+
continue;
|
|
134
|
+
diagnostics.push({
|
|
135
|
+
file: source,
|
|
136
|
+
start: edge.literal.getStart(source),
|
|
137
|
+
length: edge.literal.getWidth(source),
|
|
138
|
+
messageText: message,
|
|
139
|
+
category: tsm.DiagnosticCategory.Error,
|
|
140
|
+
code: ZONE_DIAGNOSTIC_CODE,
|
|
141
|
+
source: "nifra",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return diagnostics;
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=zones.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"zones.js","sourceRoot":"","sources":["../src/zones.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/ts-plugin",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "TypeScript language-service plugin for nifra - go-to-definition from a route-path string literal to the routes/ file that serves it.",
|
|
3
|
+
"version": "4.0.0",
|
|
4
|
+
"description": "TypeScript language-service plugin for nifra - go-to-definition from a route-path string literal to the routes/ file that serves it, and the frontend/backend zone rules as editor errors.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
7
7
|
"typescript",
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
"editor"
|
|
14
14
|
],
|
|
15
15
|
"type": "module",
|
|
16
|
+
"nifra": {
|
|
17
|
+
"environment": "library"
|
|
18
|
+
},
|
|
16
19
|
"main": "./dist/index.cjs",
|
|
17
20
|
"types": "./dist/index.d.ts",
|
|
18
21
|
"exports": {
|
|
@@ -44,8 +47,8 @@
|
|
|
44
47
|
"build": "bun run build.ts"
|
|
45
48
|
},
|
|
46
49
|
"dependencies": {
|
|
47
|
-
"@nifrajs/core": "
|
|
48
|
-
"@nifrajs/web": "
|
|
50
|
+
"@nifrajs/core": "4.0.0",
|
|
51
|
+
"@nifrajs/web": "4.0.0"
|
|
49
52
|
},
|
|
50
53
|
"peerDependencies": {
|
|
51
54
|
"typescript": ">=5.5.0"
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@nifrajs/ts-plugin` - a TypeScript language-service plugin. It adds
|
|
3
|
-
* go-to-definition on a route-path string literal jumps to the `routes/` file that serves it, so
|
|
4
|
-
*
|
|
2
|
+
* `@nifrajs/ts-plugin` - a TypeScript language-service plugin. It adds two things to the editor:
|
|
3
|
+
* - go-to-definition on a route-path string literal jumps to the `routes/` file that serves it, so
|
|
4
|
+
* `navigate({ to: "/orders" })`, `<Link to="/orders">`, or `href="/orders"` are click-through;
|
|
5
|
+
* - the frontend/backend zone rules as errors on the import that breaks them (see ./zones.ts).
|
|
5
6
|
*
|
|
6
7
|
* Wire it up in a project's tsconfig:
|
|
7
8
|
* ```json
|
|
@@ -10,13 +11,14 @@
|
|
|
10
11
|
*
|
|
11
12
|
* The routing rules are nifra's own: it discovers routes with `@nifrajs/web`'s `discoverRoutes` and
|
|
12
13
|
* matches with `@nifrajs/core`'s pattern matcher (see ./resolve.ts), so a path resolves to exactly the
|
|
13
|
-
* file it would serve at runtime
|
|
14
|
+
* file it would serve at runtime; the zone rules are `@nifrajs/web/zones`, the builds' own classifier.
|
|
14
15
|
*/
|
|
15
16
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"
|
|
16
17
|
import { dirname, isAbsolute, join, resolve } from "node:path"
|
|
17
18
|
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
18
19
|
import type * as ts from "typescript"
|
|
19
20
|
import { resolveRouteFile } from "./resolve.ts"
|
|
21
|
+
import { zoneDiagnostics } from "./zones.ts"
|
|
20
22
|
|
|
21
23
|
/** Walk up from a source file to the nearest directory that has a `routes/` folder - the app root. */
|
|
22
24
|
export function findRoutesDir(
|
|
@@ -324,6 +326,18 @@ function init(modules: { typescript: typeof ts }): ts.server.PluginModule {
|
|
|
324
326
|
? undefined
|
|
325
327
|
: ls.getDefinitionAndBoundSpan(source.fileName, position)
|
|
326
328
|
})()
|
|
329
|
+
proxy.getSemanticDiagnostics = (fileName) => {
|
|
330
|
+
const source = programSourceFile(tsm, ls.getProgram(), fileName)
|
|
331
|
+
const own = ls.getSemanticDiagnostics(source?.fileName ?? fileName)
|
|
332
|
+
const routesDir = source === undefined ? undefined : findRoutesDir(source.fileName)
|
|
333
|
+
if (source === undefined || routesDir === undefined) return own
|
|
334
|
+
try {
|
|
335
|
+
const options = ls.getProgram()?.getCompilerOptions() ?? {}
|
|
336
|
+
return [...own, ...zoneDiagnostics(tsm, source, dirname(routesDir), options)]
|
|
337
|
+
} catch {
|
|
338
|
+
return own // the build enforces the rules; an editor assist must never break diagnostics
|
|
339
|
+
}
|
|
340
|
+
}
|
|
327
341
|
return proxy
|
|
328
342
|
},
|
|
329
343
|
}
|
package/src/zones.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zone diagnostics: the frontend/backend import rules of `@nifrajs/web/zones`, in the editor.
|
|
3
|
+
*
|
|
4
|
+
* Assist only. The browser builds, the dev servers and `nifra check` enforce the same rules with the
|
|
5
|
+
* full import chain; this reports the direct edge as it is typed, so a backend import in a page is
|
|
6
|
+
* red before anything is built. Files in no zone (tests, scripts, config) are never reported: no
|
|
7
|
+
* build loads them.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, realpathSync } from "node:fs"
|
|
10
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path"
|
|
11
|
+
import {
|
|
12
|
+
BACKEND_ONLY_MARKER,
|
|
13
|
+
browserDenial,
|
|
14
|
+
type Classification,
|
|
15
|
+
createZoneClassifier,
|
|
16
|
+
importAllowed,
|
|
17
|
+
importRuleMessage,
|
|
18
|
+
specifierPackage,
|
|
19
|
+
type Zone,
|
|
20
|
+
type ZoneClassifier,
|
|
21
|
+
} from "@nifrajs/web/zones"
|
|
22
|
+
import type * as ts from "typescript"
|
|
23
|
+
|
|
24
|
+
/** The code every zone diagnostic carries; TypeScript requires a number, and none of its own is this. */
|
|
25
|
+
export const ZONE_DIAGNOSTIC_CODE = 990028
|
|
26
|
+
|
|
27
|
+
/** Zones whose own code runs in a browser. */
|
|
28
|
+
const BROWSER_CODE: ReadonlySet<Zone> = new Set(["route-frontend", "frontend", "shared"])
|
|
29
|
+
|
|
30
|
+
interface ImportEdge {
|
|
31
|
+
readonly specifier: string
|
|
32
|
+
readonly literal: ts.StringLiteralLike
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const allTypes = (elements: ts.NodeArray<ts.ImportSpecifier | ts.ExportSpecifier>): boolean =>
|
|
36
|
+
elements.length > 0 && elements.every((element) => element.isTypeOnly)
|
|
37
|
+
|
|
38
|
+
/** Every value import in a file: static, re-export and literal dynamic `import()`. Type-only ones are
|
|
39
|
+
* erased before bundling, so they may cross zones. */
|
|
40
|
+
function valueImports(tsm: typeof ts, source: ts.SourceFile): ImportEdge[] {
|
|
41
|
+
const edges: ImportEdge[] = []
|
|
42
|
+
const add = (node: ts.Expression | undefined): void => {
|
|
43
|
+
if (node !== undefined && tsm.isStringLiteralLike(node)) {
|
|
44
|
+
edges.push({ specifier: node.text, literal: node })
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const visit = (node: ts.Node): void => {
|
|
48
|
+
if (tsm.isImportDeclaration(node)) {
|
|
49
|
+
const clause = node.importClause
|
|
50
|
+
const bindings = clause?.namedBindings
|
|
51
|
+
const typeOnly =
|
|
52
|
+
clause !== undefined &&
|
|
53
|
+
(clause.isTypeOnly ||
|
|
54
|
+
(clause.name === undefined &&
|
|
55
|
+
bindings !== undefined &&
|
|
56
|
+
tsm.isNamedImports(bindings) &&
|
|
57
|
+
allTypes(bindings.elements)))
|
|
58
|
+
if (!typeOnly) add(node.moduleSpecifier)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (tsm.isExportDeclaration(node)) {
|
|
62
|
+
const clause = node.exportClause
|
|
63
|
+
const typeOnly =
|
|
64
|
+
node.isTypeOnly ||
|
|
65
|
+
(clause !== undefined && tsm.isNamedExports(clause) && allTypes(clause.elements))
|
|
66
|
+
if (!typeOnly) add(node.moduleSpecifier)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
if (
|
|
70
|
+
tsm.isImportEqualsDeclaration(node) &&
|
|
71
|
+
!node.isTypeOnly &&
|
|
72
|
+
tsm.isExternalModuleReference(node.moduleReference)
|
|
73
|
+
) {
|
|
74
|
+
add(node.moduleReference.expression)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
if (tsm.isCallExpression(node) && node.expression.kind === tsm.SyntaxKind.ImportKeyword) {
|
|
78
|
+
add(node.arguments[0])
|
|
79
|
+
}
|
|
80
|
+
tsm.forEachChild(node, visit)
|
|
81
|
+
}
|
|
82
|
+
visit(source)
|
|
83
|
+
return edges
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Where an import lands: TypeScript's resolution (tsconfig `paths` included), else a relative path
|
|
87
|
+
* that exists on disk (an `.svelte` or `.vue` file TypeScript only knows through a shim). */
|
|
88
|
+
function resolveImport(
|
|
89
|
+
tsm: typeof ts,
|
|
90
|
+
source: ts.SourceFile,
|
|
91
|
+
edge: ImportEdge,
|
|
92
|
+
options: ts.CompilerOptions,
|
|
93
|
+
): string | undefined {
|
|
94
|
+
const specifier = edge.specifier.replace(/[?#].*$/, "")
|
|
95
|
+
const mode = tsm.getModeForUsageLocation(source, edge.literal, options)
|
|
96
|
+
const resolved = tsm.resolveModuleName(
|
|
97
|
+
specifier,
|
|
98
|
+
source.fileName,
|
|
99
|
+
options,
|
|
100
|
+
tsm.sys,
|
|
101
|
+
undefined,
|
|
102
|
+
undefined,
|
|
103
|
+
mode,
|
|
104
|
+
).resolvedModule?.resolvedFileName
|
|
105
|
+
if (resolved !== undefined) return resolved
|
|
106
|
+
if (!specifier.startsWith("./") && !specifier.startsWith("../")) return undefined
|
|
107
|
+
const file = resolve(dirname(source.fileName), specifier)
|
|
108
|
+
return existsSync(file) ? file : undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const real = (file: string): string => {
|
|
112
|
+
try {
|
|
113
|
+
return realpathSync.native(file)
|
|
114
|
+
} catch {
|
|
115
|
+
return file
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A third-party target as the specifier names it: TypeScript resolves `pg` to `@types/pg`, and the
|
|
120
|
+
* server-package list knows `pg`. */
|
|
121
|
+
function libraryAsImported(target: Classification, specifier: string): Classification {
|
|
122
|
+
if (target.zone !== "library") return target
|
|
123
|
+
const named = specifierPackage(specifier)
|
|
124
|
+
if (named === undefined || named === target.packageName) return target
|
|
125
|
+
return { zone: "library", packageName: named }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Why one import breaks the zone rules, or `undefined` when it does not. */
|
|
129
|
+
function edgeProblem(
|
|
130
|
+
classifier: ZoneClassifier,
|
|
131
|
+
display: (file: string) => string,
|
|
132
|
+
fileName: string,
|
|
133
|
+
from: Zone,
|
|
134
|
+
specifier: string,
|
|
135
|
+
target: string | undefined,
|
|
136
|
+
): string | undefined {
|
|
137
|
+
const browser = BROWSER_CODE.has(from)
|
|
138
|
+
if (browser && /^(?:node|bun):/.test(specifier)) {
|
|
139
|
+
return `"${specifier}" may not reach the browser: Node and Bun built-ins run on the server only; use them in a route's backend half or under backend/`
|
|
140
|
+
}
|
|
141
|
+
if (browser && specifier === BACKEND_ONLY_MARKER) {
|
|
142
|
+
return `"${BACKEND_ONLY_MARKER}" marks a module backend-only, but ${display(fileName)} reaches the browser. Move it under backend/ or into a route's backend half`
|
|
143
|
+
}
|
|
144
|
+
if (target === undefined) return undefined
|
|
145
|
+
const to = classifier.classify(target)
|
|
146
|
+
if (to.zone === "error") return `${display(fileName)} imports ${display(target)}: ${to.reason}`
|
|
147
|
+
if (to.zone === "library") {
|
|
148
|
+
const reason = browser ? browserDenial(libraryAsImported(to, specifier), specifier) : undefined
|
|
149
|
+
return reason === undefined ? undefined : `"${specifier}" may not reach the browser: ${reason}`
|
|
150
|
+
}
|
|
151
|
+
return importAllowed(from, to.zone)
|
|
152
|
+
? undefined
|
|
153
|
+
: importRuleMessage(display(fileName), from, display(target), to.zone)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The zone diagnostics for one file of the app rooted at `appRoot`. A fresh classifier per call keeps a
|
|
158
|
+
* moved file or an edited package declaration from going stale; it costs a few `stat`s per import.
|
|
159
|
+
*/
|
|
160
|
+
export function zoneDiagnostics(
|
|
161
|
+
tsm: typeof ts,
|
|
162
|
+
source: ts.SourceFile,
|
|
163
|
+
appRoot: string,
|
|
164
|
+
options: ts.CompilerOptions,
|
|
165
|
+
): ts.Diagnostic[] {
|
|
166
|
+
const classifier = createZoneClassifier({ appRoot })
|
|
167
|
+
const from = classifier.classify(source.fileName)
|
|
168
|
+
if (from.zone === "error" || from.zone === "library" || from.zone === "generated") return []
|
|
169
|
+
const display = (file: string): string => {
|
|
170
|
+
const rel = relative(classifier.appRoot, real(file)).replaceAll("\\", "/")
|
|
171
|
+
return rel.startsWith("../") || isAbsolute(rel) ? file : rel
|
|
172
|
+
}
|
|
173
|
+
const diagnostics: ts.Diagnostic[] = []
|
|
174
|
+
for (const edge of valueImports(tsm, source)) {
|
|
175
|
+
const target = resolveImport(tsm, source, edge, options)
|
|
176
|
+
const message = edgeProblem(
|
|
177
|
+
classifier,
|
|
178
|
+
display,
|
|
179
|
+
source.fileName,
|
|
180
|
+
from.zone,
|
|
181
|
+
edge.specifier,
|
|
182
|
+
target,
|
|
183
|
+
)
|
|
184
|
+
if (message === undefined) continue
|
|
185
|
+
diagnostics.push({
|
|
186
|
+
file: source,
|
|
187
|
+
start: edge.literal.getStart(source),
|
|
188
|
+
length: edge.literal.getWidth(source),
|
|
189
|
+
messageText: message,
|
|
190
|
+
category: tsm.DiagnosticCategory.Error,
|
|
191
|
+
code: ZONE_DIAGNOSTIC_CODE,
|
|
192
|
+
source: "nifra",
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
return diagnostics
|
|
196
|
+
}
|