@nifrajs/ts-plugin 2.4.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 +33 -0
- package/dist/index.cjs +159 -0
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/resolve.d.ts +12 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +37 -0
- package/dist/resolve.js.map +1 -0
- package/package.json +66 -0
- package/src/index.ts +131 -0
- package/src/resolve.ts +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @nifrajs/ts-plugin
|
|
2
|
+
|
|
3
|
+
A TypeScript language-service plugin for [nifra](https://github.com/nifrajs/nifra). It adds go-to-definition on route-path string literals: put your cursor on `"/orders"` in `navigate({ to: "/orders" })`, `<Link to="/orders">`, or `href="/orders"`, and jump straight to the `routes/` file that serves it.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install -D @nifrajs/ts-plugin
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Enable
|
|
14
|
+
|
|
15
|
+
Add it to your `tsconfig.json`:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"compilerOptions": {
|
|
20
|
+
"plugins": [{ "name": "@nifrajs/ts-plugin" }]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
In VS Code, run "TypeScript: Select TypeScript Version" from the Command Palette and choose "Use Workspace Version" so the editor loads the plugin from your project.
|
|
26
|
+
|
|
27
|
+
## For agents
|
|
28
|
+
|
|
29
|
+
Start with [`LLM.md`](./LLM.md) - this package's contract card (the exports it ships and how it wires in). [`llms-full.txt`](../../llms-full.txt) is the full machine-readable corpus for the whole framework.
|
|
30
|
+
|
|
31
|
+
## License
|
|
32
|
+
|
|
33
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
function __accessProp(key) {
|
|
6
|
+
return this[key];
|
|
7
|
+
}
|
|
8
|
+
var __toCommonJS = (from) => {
|
|
9
|
+
var entry = (__moduleCache ??= new WeakMap).get(from), desc;
|
|
10
|
+
if (entry)
|
|
11
|
+
return entry;
|
|
12
|
+
entry = __defProp({}, "__esModule", { value: true });
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (var key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(entry, key))
|
|
16
|
+
__defProp(entry, key, {
|
|
17
|
+
get: __accessProp.bind(from, key),
|
|
18
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
__moduleCache.set(from, entry);
|
|
22
|
+
return entry;
|
|
23
|
+
};
|
|
24
|
+
var __moduleCache;
|
|
25
|
+
var __returnValue = (v) => v;
|
|
26
|
+
function __exportSetter(name, newValue) {
|
|
27
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
28
|
+
}
|
|
29
|
+
var __export = (target, all) => {
|
|
30
|
+
for (var name in all)
|
|
31
|
+
__defProp(target, name, {
|
|
32
|
+
get: all[name],
|
|
33
|
+
enumerable: true,
|
|
34
|
+
configurable: true,
|
|
35
|
+
set: __exportSetter.bind(all, name)
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// src/index.ts
|
|
40
|
+
var exports_src = {};
|
|
41
|
+
__export(exports_src, {
|
|
42
|
+
findRoutesDir: () => findRoutesDir,
|
|
43
|
+
findRoutePathLiteral: () => findRoutePathLiteral,
|
|
44
|
+
default: () => src_default
|
|
45
|
+
});
|
|
46
|
+
module.exports = __toCommonJS(exports_src);
|
|
47
|
+
var import_node_fs = require("node:fs");
|
|
48
|
+
var import_node_path = require("node:path");
|
|
49
|
+
var import_fs = require("@nifrajs/web/fs");
|
|
50
|
+
|
|
51
|
+
// src/resolve.ts
|
|
52
|
+
var import_pattern = require("@nifrajs/core/pattern");
|
|
53
|
+
var compiledRouteCache = new WeakMap;
|
|
54
|
+
function compiledRoutes(routes) {
|
|
55
|
+
const cached = compiledRouteCache.get(routes);
|
|
56
|
+
if (cached !== undefined)
|
|
57
|
+
return cached;
|
|
58
|
+
const compiled = import_pattern.sortRoutesBySpecificity(routes.map((route) => ({ route, pattern: import_pattern.compileRoutePattern(route.pattern) })));
|
|
59
|
+
compiledRouteCache.set(routes, compiled);
|
|
60
|
+
return compiled;
|
|
61
|
+
}
|
|
62
|
+
function resolveRouteFile(path, routes) {
|
|
63
|
+
if (!path.startsWith("/"))
|
|
64
|
+
return;
|
|
65
|
+
const pathname = path.split(/[?#]/, 1)[0] ?? path;
|
|
66
|
+
for (const { route, pattern } of compiledRoutes(routes)) {
|
|
67
|
+
if (import_pattern.matchRoutePattern(pattern, pathname).matched)
|
|
68
|
+
return route.file;
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/index.ts
|
|
74
|
+
function findRoutesDir(fromFile, exists = import_node_fs.existsSync) {
|
|
75
|
+
let dir = import_node_path.dirname(fromFile);
|
|
76
|
+
for (let i = 0;i < 64; i++) {
|
|
77
|
+
if (exists(import_node_path.join(dir, "routes")))
|
|
78
|
+
return import_node_path.join(dir, "routes");
|
|
79
|
+
const parent = import_node_path.dirname(dir);
|
|
80
|
+
if (parent === dir)
|
|
81
|
+
return;
|
|
82
|
+
dir = parent;
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
function innermostNodeAt(tsm, source, node, position) {
|
|
87
|
+
if (position < node.getStart(source) || position > node.getEnd())
|
|
88
|
+
return;
|
|
89
|
+
let found = node;
|
|
90
|
+
node.forEachChild((child) => {
|
|
91
|
+
const inChild = innermostNodeAt(tsm, source, child, position);
|
|
92
|
+
if (inChild !== undefined)
|
|
93
|
+
found = inChild;
|
|
94
|
+
});
|
|
95
|
+
return found;
|
|
96
|
+
}
|
|
97
|
+
function findRoutePathLiteral(tsm, source, position) {
|
|
98
|
+
const node = innermostNodeAt(tsm, source, source, position);
|
|
99
|
+
if (node === undefined || !tsm.isStringLiteralLike(node))
|
|
100
|
+
return;
|
|
101
|
+
if (!node.text.startsWith("/"))
|
|
102
|
+
return;
|
|
103
|
+
const start = node.getStart(source) + 1;
|
|
104
|
+
return { text: node.text, span: { start, length: Math.max(0, node.getWidth(source) - 2) } };
|
|
105
|
+
}
|
|
106
|
+
function routeDefinitionAt(tsm, ls, fileName, position) {
|
|
107
|
+
const source = ls.getProgram()?.getSourceFile(fileName);
|
|
108
|
+
if (source === undefined)
|
|
109
|
+
return;
|
|
110
|
+
const literal = findRoutePathLiteral(tsm, source, position);
|
|
111
|
+
if (literal === undefined)
|
|
112
|
+
return;
|
|
113
|
+
const routesDir = findRoutesDir(fileName);
|
|
114
|
+
if (routesDir === undefined)
|
|
115
|
+
return;
|
|
116
|
+
let file;
|
|
117
|
+
try {
|
|
118
|
+
file = resolveRouteFile(literal.text, import_fs.discoverRoutes(routesDir).routes);
|
|
119
|
+
} catch {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (file === undefined)
|
|
123
|
+
return;
|
|
124
|
+
const target = import_node_path.isAbsolute(file) ? file : import_node_path.join(routesDir, file);
|
|
125
|
+
if (!import_node_fs.existsSync(target))
|
|
126
|
+
return;
|
|
127
|
+
return {
|
|
128
|
+
definitions: [
|
|
129
|
+
{
|
|
130
|
+
fileName: target,
|
|
131
|
+
textSpan: { start: 0, length: 0 },
|
|
132
|
+
kind: tsm.ScriptElementKind.moduleElement,
|
|
133
|
+
name: literal.text,
|
|
134
|
+
containerName: "",
|
|
135
|
+
containerKind: tsm.ScriptElementKind.unknown
|
|
136
|
+
}
|
|
137
|
+
],
|
|
138
|
+
textSpan: literal.span
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function init(modules) {
|
|
142
|
+
const tsm = modules.typescript;
|
|
143
|
+
return {
|
|
144
|
+
create(info) {
|
|
145
|
+
const ls = info.languageService;
|
|
146
|
+
const proxy = Object.create(null);
|
|
147
|
+
const writable = proxy;
|
|
148
|
+
for (const key of Object.keys(ls)) {
|
|
149
|
+
const member = ls[key];
|
|
150
|
+
writable[key] = typeof member === "function" ? member.bind(ls) : member;
|
|
151
|
+
}
|
|
152
|
+
proxy.getDefinitionAndBoundSpan = (fileName, position) => routeDefinitionAt(tsm, ls, fileName, position) ?? ls.getDefinitionAndBoundSpan(fileName, position);
|
|
153
|
+
return proxy;
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
var src_default = init;
|
|
158
|
+
|
|
159
|
+
module.exports = module.exports && module.exports.default ? module.exports.default : module.exports
|
package/dist/index.d.cts
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type * as ts from "typescript";
|
|
2
|
+
/** Walk up from a source file to the nearest directory that has a `routes/` folder - the app root. */
|
|
3
|
+
export declare function findRoutesDir(fromFile: string, exists?: (p: string) => boolean): string | undefined;
|
|
4
|
+
/**
|
|
5
|
+
* If `position` sits on a string-literal route path (`"/..."`), return its text and the span INSIDE the
|
|
6
|
+
* quotes (so the editor highlights the path, not the quotes). Exported for unit testing against a
|
|
7
|
+
* `SourceFile` built with `ts.createSourceFile`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function findRoutePathLiteral(tsm: typeof ts, source: ts.SourceFile, position: number): {
|
|
10
|
+
text: string;
|
|
11
|
+
span: ts.TextSpan;
|
|
12
|
+
} | undefined;
|
|
13
|
+
declare function init(modules: {
|
|
14
|
+
typescript: typeof ts;
|
|
15
|
+
}): ts.server.PluginModule;
|
|
16
|
+
export default init;
|
|
17
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,KAAK,EAAE,MAAM,YAAY,CAAA;AAGrC,sGAAsG;AACtG,wBAAgB,aAAa,CAC3B,QAAQ,EAAE,MAAM,EAChB,MAAM,GAAE,CAAC,CAAC,EAAE,MAAM,KAAK,OAAoB,GAC1C,MAAM,GAAG,SAAS,CASpB;AAkBD;;;;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;AAuCD,iBAAS,IAAI,CAAC,OAAO,EAAE;IAAE,UAAU,EAAE,OAAO,EAAE,CAAA;CAAE,GAAG,EAAE,CAAC,MAAM,CAAC,YAAY,CAuBxE;AAED,eAAe,IAAI,CAAA"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** A route as the plugin needs it: its URL pattern and the source file that serves it. */
|
|
2
|
+
export interface RouteLocation {
|
|
3
|
+
readonly pattern: string;
|
|
4
|
+
readonly file: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Resolve a route path literal to the first route whose pattern matches it. Query/hash are ignored
|
|
8
|
+
* (a link's `?tab=x` does not change which route file serves it). Returns the route's `file`, or
|
|
9
|
+
* undefined when the value is not a path or matches no route.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveRouteFile(path: string, routes: readonly RouteLocation[]): string | undefined;
|
|
12
|
+
//# sourceMappingURL=resolve.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve.d.ts","sourceRoot":"","sources":["../src/resolve.ts"],"names":[],"mappings":"AAYA,0FAA0F;AAC1F,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAsBD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,SAAS,aAAa,EAAE,GAC/B,MAAM,GAAG,SAAS,CASpB"}
|
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of the plugin: resolve a concrete route path (a string literal the user typed, e.g.
|
|
3
|
+
* `"/users/42"`) to the `routes/` file that serves it. It matches the path against each route's compiled
|
|
4
|
+
* pattern using nifra's OWN router matcher (`@nifrajs/core/pattern`), so a path resolves here exactly as
|
|
5
|
+
* it would at runtime - the plugin never re-derives the file-based routing rules.
|
|
6
|
+
*/
|
|
7
|
+
import { compileRoutePattern, matchRoutePattern, sortRoutesBySpecificity, } from "@nifrajs/core/pattern";
|
|
8
|
+
// The language service asks for definitions repeatedly while the cursor moves. Cache the immutable
|
|
9
|
+
// compilation/specificity sort by manifest identity so each query only performs route matching; a new
|
|
10
|
+
// manifest array naturally gets a fresh entry when routes are added or removed.
|
|
11
|
+
const compiledRouteCache = new WeakMap();
|
|
12
|
+
function compiledRoutes(routes) {
|
|
13
|
+
const cached = compiledRouteCache.get(routes);
|
|
14
|
+
if (cached !== undefined)
|
|
15
|
+
return cached;
|
|
16
|
+
const compiled = sortRoutesBySpecificity(routes.map((route) => ({ route, pattern: compileRoutePattern(route.pattern) })));
|
|
17
|
+
compiledRouteCache.set(routes, compiled);
|
|
18
|
+
return compiled;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a route path literal to the first route whose pattern matches it. Query/hash are ignored
|
|
22
|
+
* (a link's `?tab=x` does not change which route file serves it). Returns the route's `file`, or
|
|
23
|
+
* undefined when the value is not a path or matches no route.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveRouteFile(path, routes) {
|
|
26
|
+
if (!path.startsWith("/"))
|
|
27
|
+
return undefined;
|
|
28
|
+
const pathname = path.split(/[?#]/, 1)[0] ?? path;
|
|
29
|
+
// Runtime/client routing sorts by specificity before matching, so a static route wins over a
|
|
30
|
+
// dynamic one regardless of manifest discovery order.
|
|
31
|
+
for (const { route, pattern } of compiledRoutes(routes)) {
|
|
32
|
+
if (matchRoutePattern(pattern, pathname).matched)
|
|
33
|
+
return route.file;
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=resolve.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resolve.js","sourceRoot":"","sources":["../src/resolve.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EACjB,uBAAuB,GACxB,MAAM,uBAAuB,CAAA;AAa9B,mGAAmG;AACnG,sGAAsG;AACtG,gFAAgF;AAChF,MAAM,kBAAkB,GAAG,IAAI,OAAO,EAA8D,CAAA;AAEpG,SAAS,cAAc,CAAC,MAAgC;IACtD,MAAM,MAAM,GAAG,kBAAkB,CAAC,GAAG,CAAC,MAAM,CAAC,CAAA;IAC7C,IAAI,MAAM,KAAK,SAAS;QAAE,OAAO,MAAM,CAAA;IACvC,MAAM,QAAQ,GAAG,uBAAuB,CACtC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,mBAAmB,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAChF,CAAA;IACD,kBAAkB,CAAC,GAAG,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAA;IACxC,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAC9B,IAAY,EACZ,MAAgC;IAEhC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,SAAS,CAAA;IAC3C,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,CAAA;IACjD,6FAA6F;IAC7F,sDAAsD;IACtD,KAAK,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,cAAc,CAAC,MAAM,CAAC,EAAE,CAAC;QACxD,IAAI,iBAAiB,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,OAAO;YAAE,OAAO,KAAK,CAAC,IAAI,CAAA;IACrE,CAAC;IACD,OAAO,SAAS,CAAA;AAClB,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nifrajs/ts-plugin",
|
|
3
|
+
"version": "2.4.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.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bun",
|
|
7
|
+
"typescript",
|
|
8
|
+
"nifra",
|
|
9
|
+
"web-framework",
|
|
10
|
+
"full-stack",
|
|
11
|
+
"tsserver",
|
|
12
|
+
"language-service",
|
|
13
|
+
"editor"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./dist/index.cjs",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"bun": "./src/index.ts",
|
|
21
|
+
"import": {
|
|
22
|
+
"types": "./dist/index.d.cts",
|
|
23
|
+
"default": "./dist/index.cjs"
|
|
24
|
+
},
|
|
25
|
+
"require": {
|
|
26
|
+
"types": "./dist/index.d.cts",
|
|
27
|
+
"default": "./dist/index.cjs"
|
|
28
|
+
},
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"default": "./dist/index.cjs"
|
|
31
|
+
},
|
|
32
|
+
"./resolve": {
|
|
33
|
+
"bun": "./src/resolve.ts",
|
|
34
|
+
"types": "./dist/resolve.d.ts",
|
|
35
|
+
"default": "./dist/resolve.js"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"src"
|
|
41
|
+
],
|
|
42
|
+
"sideEffects": false,
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "bun run build.ts"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@nifrajs/core": "2.4.0",
|
|
48
|
+
"@nifrajs/web": "2.4.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"typescript": ">=5.5.0"
|
|
52
|
+
},
|
|
53
|
+
"license": "MIT",
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"repository": {
|
|
58
|
+
"type": "git",
|
|
59
|
+
"url": "git+https://github.com/nifrajs/nifra.git",
|
|
60
|
+
"directory": "packages/ts-plugin"
|
|
61
|
+
},
|
|
62
|
+
"bugs": {
|
|
63
|
+
"url": "https://github.com/nifrajs/nifra/issues"
|
|
64
|
+
},
|
|
65
|
+
"homepage": "https://github.com/nifrajs/nifra#readme"
|
|
66
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nifrajs/ts-plugin` - a TypeScript language-service plugin. It adds ONE thing 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
|
+
*
|
|
6
|
+
* Wire it up in a project's tsconfig:
|
|
7
|
+
* ```json
|
|
8
|
+
* { "compilerOptions": { "plugins": [{ "name": "@nifrajs/ts-plugin" }] } }
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* The routing rules are nifra's own: it discovers routes with `@nifrajs/web`'s `discoverRoutes` and
|
|
12
|
+
* matches with `@nifrajs/core`'s pattern matcher (see ./resolve.ts), so a path resolves to exactly the
|
|
13
|
+
* file it would serve at runtime. The plugin only wires that into `getDefinitionAndBoundSpan`.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync } from "node:fs"
|
|
16
|
+
import { dirname, isAbsolute, join } from "node:path"
|
|
17
|
+
import { discoverRoutes } from "@nifrajs/web/fs"
|
|
18
|
+
import type * as ts from "typescript"
|
|
19
|
+
import { resolveRouteFile } from "./resolve.ts"
|
|
20
|
+
|
|
21
|
+
/** Walk up from a source file to the nearest directory that has a `routes/` folder - the app root. */
|
|
22
|
+
export function findRoutesDir(
|
|
23
|
+
fromFile: string,
|
|
24
|
+
exists: (p: string) => boolean = existsSync,
|
|
25
|
+
): string | undefined {
|
|
26
|
+
let dir = dirname(fromFile)
|
|
27
|
+
for (let i = 0; i < 64; i++) {
|
|
28
|
+
if (exists(join(dir, "routes"))) return join(dir, "routes")
|
|
29
|
+
const parent = dirname(dir)
|
|
30
|
+
if (parent === dir) return undefined
|
|
31
|
+
dir = parent
|
|
32
|
+
}
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The innermost AST node whose span contains `position`. */
|
|
37
|
+
function innermostNodeAt(
|
|
38
|
+
tsm: typeof ts,
|
|
39
|
+
source: ts.SourceFile,
|
|
40
|
+
node: ts.Node,
|
|
41
|
+
position: number,
|
|
42
|
+
): ts.Node | undefined {
|
|
43
|
+
if (position < node.getStart(source) || position > node.getEnd()) return undefined
|
|
44
|
+
let found: ts.Node = node
|
|
45
|
+
node.forEachChild((child) => {
|
|
46
|
+
const inChild = innermostNodeAt(tsm, source, child, position)
|
|
47
|
+
if (inChild !== undefined) found = inChild
|
|
48
|
+
})
|
|
49
|
+
return found
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* If `position` sits on a string-literal route path (`"/..."`), return its text and the span INSIDE the
|
|
54
|
+
* quotes (so the editor highlights the path, not the quotes). Exported for unit testing against a
|
|
55
|
+
* `SourceFile` built with `ts.createSourceFile`.
|
|
56
|
+
*/
|
|
57
|
+
export function findRoutePathLiteral(
|
|
58
|
+
tsm: typeof ts,
|
|
59
|
+
source: ts.SourceFile,
|
|
60
|
+
position: number,
|
|
61
|
+
): { text: string; span: ts.TextSpan } | undefined {
|
|
62
|
+
const node = innermostNodeAt(tsm, source, source, position)
|
|
63
|
+
if (node === undefined || !tsm.isStringLiteralLike(node)) return undefined
|
|
64
|
+
if (!node.text.startsWith("/")) return undefined
|
|
65
|
+
const start = node.getStart(source) + 1 // step past the opening quote
|
|
66
|
+
return { text: node.text, span: { start, length: Math.max(0, node.getWidth(source) - 2) } }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Build the route go-to-definition for the literal at `position`, or undefined to fall through. */
|
|
70
|
+
function routeDefinitionAt(
|
|
71
|
+
tsm: typeof ts,
|
|
72
|
+
ls: ts.LanguageService,
|
|
73
|
+
fileName: string,
|
|
74
|
+
position: number,
|
|
75
|
+
): ts.DefinitionInfoAndBoundSpan | undefined {
|
|
76
|
+
const source = ls.getProgram()?.getSourceFile(fileName)
|
|
77
|
+
if (source === undefined) return undefined
|
|
78
|
+
const literal = findRoutePathLiteral(tsm, source, position)
|
|
79
|
+
if (literal === undefined) return undefined
|
|
80
|
+
const routesDir = findRoutesDir(fileName)
|
|
81
|
+
if (routesDir === undefined) return undefined
|
|
82
|
+
let file: string | undefined
|
|
83
|
+
try {
|
|
84
|
+
file = resolveRouteFile(literal.text, discoverRoutes(routesDir).routes)
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined // a routes/ dir that can't be scanned is not this plugin's error to raise
|
|
87
|
+
}
|
|
88
|
+
if (file === undefined) return undefined
|
|
89
|
+
const target = isAbsolute(file) ? file : join(routesDir, file)
|
|
90
|
+
if (!existsSync(target)) return undefined
|
|
91
|
+
return {
|
|
92
|
+
definitions: [
|
|
93
|
+
{
|
|
94
|
+
fileName: target,
|
|
95
|
+
textSpan: { start: 0, length: 0 },
|
|
96
|
+
kind: tsm.ScriptElementKind.moduleElement,
|
|
97
|
+
name: literal.text,
|
|
98
|
+
containerName: "",
|
|
99
|
+
containerKind: tsm.ScriptElementKind.unknown,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
textSpan: literal.span,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function init(modules: { typescript: typeof ts }): ts.server.PluginModule {
|
|
107
|
+
const tsm = modules.typescript
|
|
108
|
+
return {
|
|
109
|
+
create(info: ts.server.PluginCreateInfo): ts.LanguageService {
|
|
110
|
+
const ls = info.languageService
|
|
111
|
+
// Proxy the language service, delegating every method to the real one except the one we augment.
|
|
112
|
+
// Plain ASSIGNMENT (not Object.defineProperty) so each property stays writable - the override
|
|
113
|
+
// below reassigns one, which a non-writable property would reject at runtime.
|
|
114
|
+
const proxy = Object.create(null) as ts.LanguageService
|
|
115
|
+
const writable = proxy as unknown as Record<string, unknown>
|
|
116
|
+
for (const key of Object.keys(ls) as Array<keyof ts.LanguageService>) {
|
|
117
|
+
const member = ls[key]
|
|
118
|
+
writable[key] =
|
|
119
|
+
typeof member === "function"
|
|
120
|
+
? (member as (...args: unknown[]) => unknown).bind(ls)
|
|
121
|
+
: member
|
|
122
|
+
}
|
|
123
|
+
proxy.getDefinitionAndBoundSpan = (fileName, position) =>
|
|
124
|
+
routeDefinitionAt(tsm, ls, fileName, position) ??
|
|
125
|
+
ls.getDefinitionAndBoundSpan(fileName, position)
|
|
126
|
+
return proxy
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default init
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of the plugin: resolve a concrete route path (a string literal the user typed, e.g.
|
|
3
|
+
* `"/users/42"`) to the `routes/` file that serves it. It matches the path against each route's compiled
|
|
4
|
+
* pattern using nifra's OWN router matcher (`@nifrajs/core/pattern`), so a path resolves here exactly as
|
|
5
|
+
* it would at runtime - the plugin never re-derives the file-based routing rules.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
compileRoutePattern,
|
|
9
|
+
matchRoutePattern,
|
|
10
|
+
sortRoutesBySpecificity,
|
|
11
|
+
} from "@nifrajs/core/pattern"
|
|
12
|
+
|
|
13
|
+
/** A route as the plugin needs it: its URL pattern and the source file that serves it. */
|
|
14
|
+
export interface RouteLocation {
|
|
15
|
+
readonly pattern: string
|
|
16
|
+
readonly file: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type CompiledRouteLocation = {
|
|
20
|
+
readonly route: RouteLocation
|
|
21
|
+
readonly pattern: ReturnType<typeof compileRoutePattern>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The language service asks for definitions repeatedly while the cursor moves. Cache the immutable
|
|
25
|
+
// compilation/specificity sort by manifest identity so each query only performs route matching; a new
|
|
26
|
+
// manifest array naturally gets a fresh entry when routes are added or removed.
|
|
27
|
+
const compiledRouteCache = new WeakMap<readonly RouteLocation[], readonly CompiledRouteLocation[]>()
|
|
28
|
+
|
|
29
|
+
function compiledRoutes(routes: readonly RouteLocation[]): readonly CompiledRouteLocation[] {
|
|
30
|
+
const cached = compiledRouteCache.get(routes)
|
|
31
|
+
if (cached !== undefined) return cached
|
|
32
|
+
const compiled = sortRoutesBySpecificity(
|
|
33
|
+
routes.map((route) => ({ route, pattern: compileRoutePattern(route.pattern) })),
|
|
34
|
+
)
|
|
35
|
+
compiledRouteCache.set(routes, compiled)
|
|
36
|
+
return compiled
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a route path literal to the first route whose pattern matches it. Query/hash are ignored
|
|
41
|
+
* (a link's `?tab=x` does not change which route file serves it). Returns the route's `file`, or
|
|
42
|
+
* undefined when the value is not a path or matches no route.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveRouteFile(
|
|
45
|
+
path: string,
|
|
46
|
+
routes: readonly RouteLocation[],
|
|
47
|
+
): string | undefined {
|
|
48
|
+
if (!path.startsWith("/")) return undefined
|
|
49
|
+
const pathname = path.split(/[?#]/, 1)[0] ?? path
|
|
50
|
+
// Runtime/client routing sorts by specificity before matching, so a static route wins over a
|
|
51
|
+
// dynamic one regardless of manifest discovery order.
|
|
52
|
+
for (const { route, pattern } of compiledRoutes(routes)) {
|
|
53
|
+
if (matchRoutePattern(pattern, pathname).matched) return route.file
|
|
54
|
+
}
|
|
55
|
+
return undefined
|
|
56
|
+
}
|