pastoria 0.0.1
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/package.json +14 -0
- package/src/index.mts +297 -0
- package/templates/js_resource.ts +55 -0
- package/templates/router.tsx +397 -0
- package/templates/server_router.ts +112 -0
- package/tsconfig.json +18 -0
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pastoria",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": "./src/index.mts",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"picocolors": "^1.1.1",
|
|
8
|
+
"ts-morph": "^26.0.0"
|
|
9
|
+
},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@types/node": "20.3.1",
|
|
12
|
+
"typescript": "^5.9.2"
|
|
13
|
+
}
|
|
14
|
+
}
|
package/src/index.mts
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
#!/usr/bin/env node --experimental-strip-types
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview Router Code Generator
|
|
4
|
+
*
|
|
5
|
+
* This script generates type-safe router configuration files by scanning TypeScript
|
|
6
|
+
* source code for JSDoc annotations. It's part of the "Pastoria" routing framework.
|
|
7
|
+
*
|
|
8
|
+
* How it works:
|
|
9
|
+
* 1. Scans all TypeScript files in the project for exported functions/classes
|
|
10
|
+
* 2. Looks for JSDoc tags: @route, @resource, and @param
|
|
11
|
+
* 3. Generates three files from templates:
|
|
12
|
+
* - js_resource.ts: Resource configuration for lazy loading
|
|
13
|
+
* - router.tsx: Client-side router with type-safe routes
|
|
14
|
+
* - server_router.ts: Server-side router configuration
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* - Add @route <route-name> to functions to create routes
|
|
18
|
+
* - Add @param <name> <type> to document route parameters
|
|
19
|
+
* - Add @resource <resource-name> to exports for lazy loading
|
|
20
|
+
*
|
|
21
|
+
* The generator automatically creates Zod schemas for route parameters based on
|
|
22
|
+
* TypeScript types, enabling runtime validation and type safety.
|
|
23
|
+
*
|
|
24
|
+
* Roadmap:
|
|
25
|
+
* 1. [DONE] Type-safe router APIs - Generate strongly typed navigation functions
|
|
26
|
+
* 2. Support for useTransition during routing - React 19 concurrent features
|
|
27
|
+
* 3. Support for metadata management in <head>
|
|
28
|
+
* 4. HTML manual generator suitable to be read by LLMs - Auto-generated docs
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {readFile} from 'node:fs/promises';
|
|
32
|
+
import * as path from 'node:path';
|
|
33
|
+
import {default as pc} from 'picocolors';
|
|
34
|
+
import {Project, SourceFile, Symbol, SyntaxKind, ts, TypeFlags} from 'ts-morph';
|
|
35
|
+
|
|
36
|
+
const JS_RESOURCE_FILENAME = '__generated__/router/js_resource.ts';
|
|
37
|
+
const JS_RESOURCE_TEMPLATE = path.join(
|
|
38
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
39
|
+
'../templates/js_resource.ts',
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const ROUTER_FILENAME = '__generated__/router/router.tsx';
|
|
43
|
+
const ROUTER_TEMPLATE = path.join(
|
|
44
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
45
|
+
'../templates/router.tsx',
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const SERVER_ROUTER_FILENAME = '__generated__/router/server_router.ts';
|
|
49
|
+
const SERVER_ROUTER_TEMPLATE = path.join(
|
|
50
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
51
|
+
'../templates/server_router.ts',
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
async function loadRouterFiles(project: Project) {
|
|
55
|
+
async function loadSourceFile(fileName: string, templateFileName: string) {
|
|
56
|
+
const template = await readFile(templateFileName, 'utf-8');
|
|
57
|
+
const warningComment = `/*
|
|
58
|
+
* This file was generated by \`pastoria\`.
|
|
59
|
+
* Do not modify this file directly. Instead, edit the template at ${path.basename(templateFileName)}.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
`;
|
|
63
|
+
return project.createSourceFile(fileName, warningComment + template, {
|
|
64
|
+
overwrite: true,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const [jsResource, router, serverRouter] = await Promise.all([
|
|
69
|
+
loadSourceFile(JS_RESOURCE_FILENAME, JS_RESOURCE_TEMPLATE),
|
|
70
|
+
loadSourceFile(ROUTER_FILENAME, ROUTER_TEMPLATE),
|
|
71
|
+
loadSourceFile(SERVER_ROUTER_FILENAME, SERVER_ROUTER_TEMPLATE),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
return {jsResource, router, serverRouter} as const;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type RouterResource = {
|
|
78
|
+
resourceName: string;
|
|
79
|
+
sourceFile: SourceFile;
|
|
80
|
+
symbol: Symbol;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type RouterRoute = {
|
|
84
|
+
routeName: string;
|
|
85
|
+
sourceFile: SourceFile;
|
|
86
|
+
symbol: Symbol;
|
|
87
|
+
params: Map<string, ts.Type>;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function collectRouterNodes(project: Project) {
|
|
91
|
+
const resources: RouterResource[] = [];
|
|
92
|
+
const routes: RouterRoute[] = [];
|
|
93
|
+
|
|
94
|
+
function visitRouterNodes(sourceFile: SourceFile) {
|
|
95
|
+
sourceFile.getExportSymbols().forEach((symbol) => {
|
|
96
|
+
let routerResource = null as RouterResource | null;
|
|
97
|
+
let routerRoute = null as RouterRoute | null;
|
|
98
|
+
const routeParams = new Map<string, ts.Type>();
|
|
99
|
+
|
|
100
|
+
function visitJSDocTags(tag: ts.JSDoc | ts.JSDocTag) {
|
|
101
|
+
if (ts.isJSDoc(tag)) {
|
|
102
|
+
tag.tags?.forEach(visitJSDocTags);
|
|
103
|
+
} else if (ts.isJSDocParameterTag(tag)) {
|
|
104
|
+
const typeNode = tag.typeExpression?.type;
|
|
105
|
+
const tc = project.getTypeChecker().compilerObject;
|
|
106
|
+
|
|
107
|
+
const type =
|
|
108
|
+
typeNode == null
|
|
109
|
+
? tc.getUnknownType()
|
|
110
|
+
: tc.getTypeFromTypeNode(typeNode);
|
|
111
|
+
|
|
112
|
+
routeParams.set(tag.name.getText(), type);
|
|
113
|
+
} else if (typeof tag.comment === 'string') {
|
|
114
|
+
switch (tag.tagName.getText()) {
|
|
115
|
+
case 'route': {
|
|
116
|
+
routerRoute = {
|
|
117
|
+
routeName: tag.comment,
|
|
118
|
+
sourceFile,
|
|
119
|
+
symbol,
|
|
120
|
+
params: routeParams,
|
|
121
|
+
};
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
case 'resource': {
|
|
125
|
+
routerResource = {
|
|
126
|
+
resourceName: tag.comment,
|
|
127
|
+
sourceFile,
|
|
128
|
+
symbol,
|
|
129
|
+
};
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
symbol
|
|
137
|
+
.getDeclarations()
|
|
138
|
+
.flatMap((decl) => ts.getJSDocCommentsAndTags(decl.compilerNode))
|
|
139
|
+
.forEach(visitJSDocTags);
|
|
140
|
+
|
|
141
|
+
if (routerRoute != null) routes.push(routerRoute);
|
|
142
|
+
if (routerResource != null) resources.push(routerResource);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
project.getSourceFiles().forEach(visitRouterNodes);
|
|
147
|
+
return {resources, routes} as const;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function zodSchemaOfType(tc: ts.TypeChecker, t: ts.Type): string {
|
|
151
|
+
if (t.getFlags() & TypeFlags.String) {
|
|
152
|
+
return `z.pipe(z.string(), z.transform(decodeURIComponent))`;
|
|
153
|
+
} else if (t.getFlags() & TypeFlags.Number) {
|
|
154
|
+
return `z.coerce.number<number>()`;
|
|
155
|
+
} else if (t.getFlags() & TypeFlags.Null) {
|
|
156
|
+
return `z.preprocess(s => s == null ? undefined : s, z.undefined())`;
|
|
157
|
+
} else if (t.isUnion()) {
|
|
158
|
+
const isRepresentingOptional =
|
|
159
|
+
t.types.length === 2 &&
|
|
160
|
+
t.types.some((s) => s.getFlags() & TypeFlags.Null);
|
|
161
|
+
|
|
162
|
+
if (isRepresentingOptional) {
|
|
163
|
+
const nonOptionalType = t.types.find(
|
|
164
|
+
(s) => !(s.getFlags() & TypeFlags.Null),
|
|
165
|
+
)!;
|
|
166
|
+
|
|
167
|
+
return `z.pipe(z.nullish(${zodSchemaOfType(tc, nonOptionalType)}), z.transform(s => s == null ? undefined : s))`;
|
|
168
|
+
} else {
|
|
169
|
+
return `z.union([${t.types.map((it) => zodSchemaOfType(tc, it)).join(', ')}])`;
|
|
170
|
+
}
|
|
171
|
+
} else if (tc.isArrayLikeType(t)) {
|
|
172
|
+
const typeArg = tc.getTypeArguments(t as ts.TypeReference)[0];
|
|
173
|
+
const argZodSchema =
|
|
174
|
+
typeArg == null ? `z.any()` : zodSchemaOfType(tc, typeArg);
|
|
175
|
+
|
|
176
|
+
return `z.array(${argZodSchema})`;
|
|
177
|
+
} else {
|
|
178
|
+
console.log('Could not handle type:', tc.typeToString(t));
|
|
179
|
+
return `z.any()`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function main() {
|
|
184
|
+
const targetDir = path.resolve(process.argv[2] || process.cwd());
|
|
185
|
+
process.chdir(targetDir);
|
|
186
|
+
const project = new Project({
|
|
187
|
+
tsConfigFilePath: path.join(targetDir, 'tsconfig.json'),
|
|
188
|
+
});
|
|
189
|
+
const tc = project.getTypeChecker().compilerObject;
|
|
190
|
+
|
|
191
|
+
const routerFiles = await loadRouterFiles(project);
|
|
192
|
+
const routerNodes = collectRouterNodes(project);
|
|
193
|
+
|
|
194
|
+
const resourceConf = routerFiles.jsResource
|
|
195
|
+
.getVariableDeclarationOrThrow('RESOURCE_CONF')
|
|
196
|
+
.getInitializerIfKindOrThrow(SyntaxKind.AsExpression)
|
|
197
|
+
.getExpressionIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
198
|
+
|
|
199
|
+
resourceConf.getPropertyOrThrow('noop').remove();
|
|
200
|
+
for (const {resourceName, sourceFile, symbol} of routerNodes.resources) {
|
|
201
|
+
const filePath = path.relative(process.cwd(), sourceFile.getFilePath());
|
|
202
|
+
const moduleSpecifier =
|
|
203
|
+
routerFiles.jsResource.getRelativePathAsModuleSpecifierTo(
|
|
204
|
+
sourceFile.getFilePath(),
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
resourceConf.addPropertyAssignment({
|
|
208
|
+
name: `"${resourceName}"`,
|
|
209
|
+
initializer: (writer) => {
|
|
210
|
+
writer.block(() => {
|
|
211
|
+
writer
|
|
212
|
+
.writeLine(`src: "${filePath}",`)
|
|
213
|
+
.writeLine(
|
|
214
|
+
`loader: () => import("${moduleSpecifier}").then(m => m.${symbol.getName()})`,
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
console.log(
|
|
221
|
+
'Created resource',
|
|
222
|
+
pc.cyan(resourceName),
|
|
223
|
+
'for',
|
|
224
|
+
pc.green(symbol.getName()),
|
|
225
|
+
'exported from',
|
|
226
|
+
pc.yellow(filePath),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const routerConf = routerFiles.router
|
|
231
|
+
.getVariableDeclarationOrThrow('ROUTER_CONF')
|
|
232
|
+
.getInitializerIfKindOrThrow(SyntaxKind.AsExpression)
|
|
233
|
+
.getExpressionIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
|
|
234
|
+
|
|
235
|
+
routerConf.getPropertyOrThrow('noop').remove();
|
|
236
|
+
|
|
237
|
+
let entryPointImportIndex = 0;
|
|
238
|
+
for (const {routeName, sourceFile, symbol, params} of routerNodes.routes) {
|
|
239
|
+
const importAlias = `e${entryPointImportIndex++}`;
|
|
240
|
+
const filePath = path.relative(process.cwd(), sourceFile.getFilePath());
|
|
241
|
+
const moduleSpecifier =
|
|
242
|
+
routerFiles.router.getRelativePathAsModuleSpecifierTo(
|
|
243
|
+
sourceFile.getFilePath(),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
routerFiles.router.addImportDeclaration({
|
|
247
|
+
moduleSpecifier,
|
|
248
|
+
namedImports: [
|
|
249
|
+
{
|
|
250
|
+
name: symbol.getName(),
|
|
251
|
+
alias: importAlias,
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
routerConf.addPropertyAssignment({
|
|
257
|
+
name: `"${routeName}"`,
|
|
258
|
+
initializer: (writer) => {
|
|
259
|
+
writer
|
|
260
|
+
.write('{')
|
|
261
|
+
.indent(() => {
|
|
262
|
+
writer.writeLine(`entrypoint: ${importAlias},`);
|
|
263
|
+
if (params.size === 0) {
|
|
264
|
+
writer.writeLine(`schema: z.object({})`);
|
|
265
|
+
} else {
|
|
266
|
+
writer.writeLine(`schema: z.object({`);
|
|
267
|
+
for (const [paramName, paramType] of Array.from(params)) {
|
|
268
|
+
writer.writeLine(
|
|
269
|
+
` ${paramName}: ${zodSchemaOfType(tc, paramType)},`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
writer.writeLine('})');
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
.write('} as const');
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
console.log(
|
|
281
|
+
'Created route',
|
|
282
|
+
pc.cyan(routeName),
|
|
283
|
+
'for',
|
|
284
|
+
pc.green(symbol.getName()),
|
|
285
|
+
'exported from',
|
|
286
|
+
pc.yellow(filePath),
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
await Promise.all([
|
|
291
|
+
routerFiles.jsResource.save(),
|
|
292
|
+
routerFiles.router.save(),
|
|
293
|
+
routerFiles.serverRouter.save(),
|
|
294
|
+
]);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type {JSResourceReference} from 'react-relay/hooks';
|
|
2
|
+
|
|
3
|
+
type ResourceConf = typeof RESOURCE_CONF;
|
|
4
|
+
const RESOURCE_CONF = {
|
|
5
|
+
noop: {src: '', loader: () => Promise.reject()},
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
type ModuleId = keyof ResourceConf;
|
|
9
|
+
export type ModuleType<M extends ModuleId> = Awaited<
|
|
10
|
+
ReturnType<ResourceConf[M]['loader']>
|
|
11
|
+
>;
|
|
12
|
+
|
|
13
|
+
export class JSResource<M extends ModuleId>
|
|
14
|
+
implements JSResourceReference<ModuleType<M>>
|
|
15
|
+
{
|
|
16
|
+
static srcOfModuleId(id: string): string | null {
|
|
17
|
+
return RESOURCE_CONF[id as ModuleId].src;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private static readonly resourceCache = new Map<ModuleId, JSResource<any>>();
|
|
21
|
+
static fromModuleId<M extends ModuleId>(moduleId: M) {
|
|
22
|
+
if (JSResource.resourceCache.has(moduleId)) {
|
|
23
|
+
return JSResource.resourceCache.get(moduleId)!;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const resource = new JSResource(moduleId);
|
|
27
|
+
JSResource.resourceCache.set(moduleId, resource);
|
|
28
|
+
return resource;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private constructor(private readonly moduleId: M) {}
|
|
32
|
+
private modulePromiseCache: Promise<ModuleType<M>> | null = null;
|
|
33
|
+
private moduleCache: ModuleType<M> | null = null;
|
|
34
|
+
|
|
35
|
+
getModuleId(): string {
|
|
36
|
+
return this.moduleId;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
getModuleIfRequired(): ModuleType<M> | null {
|
|
40
|
+
return this.moduleCache;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async load(): Promise<ModuleType<M>> {
|
|
44
|
+
if (this.modulePromiseCache == null) {
|
|
45
|
+
this.modulePromiseCache = RESOURCE_CONF[this.moduleId]
|
|
46
|
+
.loader()
|
|
47
|
+
.then((m) => {
|
|
48
|
+
this.moduleCache = m as ModuleType<M>;
|
|
49
|
+
return this.moduleCache;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return await this.modulePromiseCache;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import {createRouter} from 'radix3';
|
|
2
|
+
import {
|
|
3
|
+
AnchorHTMLAttributes,
|
|
4
|
+
createContext,
|
|
5
|
+
PropsWithChildren,
|
|
6
|
+
Suspense,
|
|
7
|
+
useCallback,
|
|
8
|
+
useContext,
|
|
9
|
+
useEffect,
|
|
10
|
+
useMemo,
|
|
11
|
+
useState,
|
|
12
|
+
} from 'react';
|
|
13
|
+
import {
|
|
14
|
+
EntryPoint,
|
|
15
|
+
EntryPointContainer,
|
|
16
|
+
EnvironmentProviderOptions,
|
|
17
|
+
IEnvironmentProvider,
|
|
18
|
+
loadEntryPoint,
|
|
19
|
+
PreloadedEntryPoint,
|
|
20
|
+
useEntryPointLoader,
|
|
21
|
+
} from 'react-relay/hooks';
|
|
22
|
+
import {OperationDescriptor, PayloadData} from 'relay-runtime';
|
|
23
|
+
import type {Manifest} from 'vite';
|
|
24
|
+
import * as z from 'zod/v4-mini';
|
|
25
|
+
|
|
26
|
+
export type AnyPreloadedEntryPoint = PreloadedEntryPoint<any>;
|
|
27
|
+
export type RouterOps = [OperationDescriptor, PayloadData][];
|
|
28
|
+
|
|
29
|
+
type RouterConf = typeof ROUTER_CONF;
|
|
30
|
+
const ROUTER_CONF = {
|
|
31
|
+
noop: {
|
|
32
|
+
entrypoint: null! as EntryPoint<any>,
|
|
33
|
+
schema: z.object({}),
|
|
34
|
+
},
|
|
35
|
+
} as const;
|
|
36
|
+
|
|
37
|
+
export type RouteId = keyof RouterConf;
|
|
38
|
+
export type NavigationDirection = string | URL | ((nextUrl: URL) => void);
|
|
39
|
+
|
|
40
|
+
export interface EntryPointParams<R extends RouteId> {
|
|
41
|
+
params: Record<string, any>;
|
|
42
|
+
schema: RouterConf[R]['schema'];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ROUTER = createRouter<RouterConf[keyof RouterConf]>({
|
|
46
|
+
routes: ROUTER_CONF,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
class RouterLocation {
|
|
50
|
+
private constructor(
|
|
51
|
+
readonly pathname: string,
|
|
52
|
+
readonly searchParams: URLSearchParams,
|
|
53
|
+
readonly method?: 'push' | 'replace' | 'popstate',
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
href() {
|
|
57
|
+
if (this.searchParams.size > 0) {
|
|
58
|
+
return this.pathname + '?' + this.searchParams.toString();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return this.pathname;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
route() {
|
|
65
|
+
return ROUTER.lookup(this.pathname);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
params() {
|
|
69
|
+
const matchedRoute = this.route();
|
|
70
|
+
const params = {
|
|
71
|
+
...matchedRoute?.params,
|
|
72
|
+
...Object.fromEntries(this.searchParams),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
if (matchedRoute?.schema) {
|
|
76
|
+
return matchedRoute.schema.parse(params);
|
|
77
|
+
} else {
|
|
78
|
+
return params;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
static parse(path: string, method?: 'push' | 'replace' | 'popstate') {
|
|
83
|
+
if (path.startsWith('/')) {
|
|
84
|
+
path = 'router:' + path;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const nextUrl = new URL(path);
|
|
89
|
+
return new RouterLocation(nextUrl.pathname, nextUrl.searchParams, method);
|
|
90
|
+
} catch (_e) {
|
|
91
|
+
return new RouterLocation(path, new URLSearchParams(), method);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function useLocation(initialPath?: string) {
|
|
97
|
+
const [location, setLocation] = useState((): RouterLocation => {
|
|
98
|
+
return RouterLocation.parse(initialPath ?? window.location.href);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
function listener(e: PopStateEvent) {
|
|
103
|
+
setLocation(RouterLocation.parse(window.location.href, 'popstate'));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
window.addEventListener('popstate', listener);
|
|
107
|
+
return () => {
|
|
108
|
+
window.removeEventListener('popstate', listener);
|
|
109
|
+
};
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (location.method === 'push') {
|
|
114
|
+
window.history.pushState({}, '', location.href());
|
|
115
|
+
window.scrollTo(0, 0);
|
|
116
|
+
} else if (location.method === 'replace') {
|
|
117
|
+
window.history.replaceState({}, '', location.href());
|
|
118
|
+
}
|
|
119
|
+
}, [location]);
|
|
120
|
+
|
|
121
|
+
return [location, setLocation] as const;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function router__hydrateStore(
|
|
125
|
+
provider: IEnvironmentProvider<EnvironmentProviderOptions>,
|
|
126
|
+
) {
|
|
127
|
+
const env = provider.getEnvironment(null);
|
|
128
|
+
if ('__router_ops' in window) {
|
|
129
|
+
const ops = (window as any).__router_ops as RouterOps;
|
|
130
|
+
for (const [op, payload] of ops) {
|
|
131
|
+
env.commitPayload(op, payload);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function router__loadEntryPoint(
|
|
137
|
+
provider: IEnvironmentProvider<EnvironmentProviderOptions>,
|
|
138
|
+
initialPath?: string,
|
|
139
|
+
) {
|
|
140
|
+
if (!initialPath) initialPath = window.location.href;
|
|
141
|
+
const initialLocation = RouterLocation.parse(initialPath);
|
|
142
|
+
const initialRoute = initialLocation.route();
|
|
143
|
+
if (!initialRoute) return null;
|
|
144
|
+
|
|
145
|
+
await initialRoute.entrypoint?.root.load();
|
|
146
|
+
return loadEntryPoint(provider, initialRoute.entrypoint, {
|
|
147
|
+
params: initialLocation.params(),
|
|
148
|
+
schema: initialRoute.schema,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
interface RouterContextValue {
|
|
153
|
+
location: RouterLocation;
|
|
154
|
+
setLocation: React.Dispatch<React.SetStateAction<RouterLocation>>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const RouterContext = createContext<RouterContextValue>({
|
|
158
|
+
location: RouterLocation.parse('/'),
|
|
159
|
+
setLocation: () => {},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
export function router__createAppFromEntryPoint(
|
|
163
|
+
provider: IEnvironmentProvider<EnvironmentProviderOptions>,
|
|
164
|
+
initialEntryPoint: AnyPreloadedEntryPoint | null,
|
|
165
|
+
initialPath?: string,
|
|
166
|
+
) {
|
|
167
|
+
function RouterApp() {
|
|
168
|
+
const [location, setLocation] = useLocation(initialPath);
|
|
169
|
+
const routerContextValue = useMemo(
|
|
170
|
+
(): RouterContextValue => ({
|
|
171
|
+
location,
|
|
172
|
+
setLocation,
|
|
173
|
+
}),
|
|
174
|
+
[location, setLocation],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const [entryPointRef, loadEntryPointRef, _dispose] = useEntryPointLoader(
|
|
178
|
+
provider,
|
|
179
|
+
location.route()?.entrypoint,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
const schema = location.route()?.schema;
|
|
184
|
+
if (schema) {
|
|
185
|
+
loadEntryPointRef({
|
|
186
|
+
params: location.params(),
|
|
187
|
+
schema,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
191
|
+
}, [location]);
|
|
192
|
+
|
|
193
|
+
const entryPoint = entryPointRef ?? initialEntryPoint;
|
|
194
|
+
if (entryPoint == null) return null;
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<RouterContext value={routerContextValue}>
|
|
198
|
+
{'fallback' in entryPoint.entryPoints ? (
|
|
199
|
+
<Suspense
|
|
200
|
+
fallback={
|
|
201
|
+
<EntryPointContainer
|
|
202
|
+
entryPointReference={entryPoint.entryPoints.fallback}
|
|
203
|
+
props={{}}
|
|
204
|
+
/>
|
|
205
|
+
}
|
|
206
|
+
>
|
|
207
|
+
<EntryPointContainer entryPointReference={entryPoint} props={{}} />
|
|
208
|
+
</Suspense>
|
|
209
|
+
) : (
|
|
210
|
+
<EntryPointContainer entryPointReference={entryPoint} props={{}} />
|
|
211
|
+
)}
|
|
212
|
+
</RouterContext>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
RouterApp.bootstrap = (manifest?: Manifest): string | null => null;
|
|
217
|
+
return RouterApp;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function createRouterApp(
|
|
221
|
+
provider: IEnvironmentProvider<EnvironmentProviderOptions>,
|
|
222
|
+
) {
|
|
223
|
+
router__hydrateStore(provider);
|
|
224
|
+
const ep = await router__loadEntryPoint(provider);
|
|
225
|
+
return router__createAppFromEntryPoint(provider, ep);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function usePath() {
|
|
229
|
+
const {location} = useContext(RouterContext);
|
|
230
|
+
return location.pathname;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function useRouteParams<R extends RouteId>(
|
|
234
|
+
routeId: R,
|
|
235
|
+
): z.infer<RouterConf[R]['schema']> {
|
|
236
|
+
const schema = ROUTER_CONF[routeId].schema;
|
|
237
|
+
const {location} = useContext(RouterContext);
|
|
238
|
+
|
|
239
|
+
return schema.parse(location.params()) as z.infer<RouterConf[R]['schema']>;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function router__createPathForRoute(
|
|
243
|
+
routeId: RouteId,
|
|
244
|
+
inputParams: Record<string, any>,
|
|
245
|
+
): string {
|
|
246
|
+
const schema = ROUTER_CONF[routeId].schema;
|
|
247
|
+
const params = schema.parse(inputParams);
|
|
248
|
+
|
|
249
|
+
let pathname = routeId as string;
|
|
250
|
+
const searchParams = new URLSearchParams();
|
|
251
|
+
|
|
252
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
253
|
+
if (value != null) {
|
|
254
|
+
const paramPattern = `:${key}`;
|
|
255
|
+
if (pathname.includes(paramPattern)) {
|
|
256
|
+
pathname = pathname.replace(
|
|
257
|
+
paramPattern,
|
|
258
|
+
encodeURIComponent(String(value)),
|
|
259
|
+
);
|
|
260
|
+
} else {
|
|
261
|
+
searchParams.set(key, String(value));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
if (searchParams.size > 0) {
|
|
267
|
+
return pathname + '?' + searchParams.toString();
|
|
268
|
+
} else {
|
|
269
|
+
return pathname;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function router__evaluateNavigationDirection(nav: NavigationDirection) {
|
|
274
|
+
let nextUrl: URL;
|
|
275
|
+
if (typeof nav === 'string') {
|
|
276
|
+
nextUrl = new URL(nav, window.location.origin);
|
|
277
|
+
} else if (nav instanceof URL) {
|
|
278
|
+
nextUrl = nav;
|
|
279
|
+
} else {
|
|
280
|
+
nextUrl = new URL(window.location.href);
|
|
281
|
+
nav(nextUrl);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (window.location.origin !== nextUrl.origin) {
|
|
285
|
+
throw new Error('Cannot navigate to a different origin.');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (nextUrl.searchParams.size > 0) {
|
|
289
|
+
return nextUrl.pathname + '?' + nextUrl.searchParams.toString();
|
|
290
|
+
} else {
|
|
291
|
+
return nextUrl.pathname;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function useNavigation() {
|
|
296
|
+
const {setLocation} = useContext(RouterContext);
|
|
297
|
+
|
|
298
|
+
return useMemo(() => {
|
|
299
|
+
function push(nav: NavigationDirection) {
|
|
300
|
+
setLocation(
|
|
301
|
+
RouterLocation.parse(router__evaluateNavigationDirection(nav), 'push'),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function replace(nav: NavigationDirection) {
|
|
306
|
+
setLocation(
|
|
307
|
+
RouterLocation.parse(
|
|
308
|
+
router__evaluateNavigationDirection(nav),
|
|
309
|
+
'replace',
|
|
310
|
+
),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function pushRoute<R extends RouteId>(
|
|
315
|
+
routeId: R,
|
|
316
|
+
params: z.input<RouterConf[R]['schema']>,
|
|
317
|
+
) {
|
|
318
|
+
setLocation(
|
|
319
|
+
RouterLocation.parse(
|
|
320
|
+
router__createPathForRoute(routeId, params),
|
|
321
|
+
'push',
|
|
322
|
+
),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function replaceRoute<R extends RouteId>(
|
|
327
|
+
routeId: R,
|
|
328
|
+
params: z.input<RouterConf[R]['schema']>,
|
|
329
|
+
) {
|
|
330
|
+
setLocation((prevLoc) =>
|
|
331
|
+
RouterLocation.parse(
|
|
332
|
+
router__createPathForRoute(routeId, {...prevLoc.params(), ...params}),
|
|
333
|
+
'replace',
|
|
334
|
+
),
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {push, replace, pushRoute, replaceRoute} as const;
|
|
339
|
+
}, [setLocation]);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function Link({
|
|
343
|
+
href,
|
|
344
|
+
target,
|
|
345
|
+
onClick,
|
|
346
|
+
...props
|
|
347
|
+
}: AnchorHTMLAttributes<HTMLAnchorElement>) {
|
|
348
|
+
const {push} = useNavigation();
|
|
349
|
+
|
|
350
|
+
const handleClick = useCallback(
|
|
351
|
+
(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
|
|
352
|
+
onClick?.(e);
|
|
353
|
+
if (e.defaultPrevented || !href) return;
|
|
354
|
+
|
|
355
|
+
// See https://github.com/remix-run/react-router/blob/main/packages/react-router/lib/dom/dom.ts#L34
|
|
356
|
+
const shouldHandle =
|
|
357
|
+
e.button === 0 &&
|
|
358
|
+
(!target || target === '_self') &&
|
|
359
|
+
!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey);
|
|
360
|
+
|
|
361
|
+
if (!shouldHandle) return;
|
|
362
|
+
|
|
363
|
+
const destination = new URL(href, window.location.href);
|
|
364
|
+
if (destination.origin !== window.location.origin) return;
|
|
365
|
+
|
|
366
|
+
e.preventDefault();
|
|
367
|
+
push(destination);
|
|
368
|
+
},
|
|
369
|
+
[push, href, target, onClick],
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
return <a {...props} href={href} target={target} onClick={handleClick} />;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export interface LinkProps<R extends RouteId>
|
|
376
|
+
extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
377
|
+
route: R;
|
|
378
|
+
params: z.input<RouterConf[R]['schema']>;
|
|
379
|
+
href?: never;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function RouteLink<R extends RouteId>({
|
|
383
|
+
route,
|
|
384
|
+
params,
|
|
385
|
+
...props
|
|
386
|
+
}: PropsWithChildren<LinkProps<R>>) {
|
|
387
|
+
const href = useMemo(
|
|
388
|
+
() => router__createPathForRoute(route, params).toString(),
|
|
389
|
+
[params, route],
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
return <Link {...props} href={href} />;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function listRoutes() {
|
|
396
|
+
return Object.keys(ROUTER_CONF);
|
|
397
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EnvironmentProviderOptions,
|
|
3
|
+
IEnvironmentProvider,
|
|
4
|
+
OperationDescriptor,
|
|
5
|
+
PreloadedQuery,
|
|
6
|
+
} from 'react-relay/hooks';
|
|
7
|
+
import {
|
|
8
|
+
createOperationDescriptor,
|
|
9
|
+
GraphQLResponse,
|
|
10
|
+
GraphQLSingularResponse,
|
|
11
|
+
OperationType,
|
|
12
|
+
PayloadData,
|
|
13
|
+
PreloadableQueryRegistry,
|
|
14
|
+
} from 'relay-runtime';
|
|
15
|
+
import serialize from 'serialize-javascript';
|
|
16
|
+
import type {Manifest} from 'vite';
|
|
17
|
+
import {JSResource} from './js_resource';
|
|
18
|
+
import {
|
|
19
|
+
AnyPreloadedEntryPoint,
|
|
20
|
+
router__createAppFromEntryPoint,
|
|
21
|
+
router__loadEntryPoint,
|
|
22
|
+
RouterOps,
|
|
23
|
+
} from './router';
|
|
24
|
+
|
|
25
|
+
type AnyPreloadedQuery = PreloadedQuery<OperationType>;
|
|
26
|
+
|
|
27
|
+
function router__bootstrapScripts(
|
|
28
|
+
entryPoint: AnyPreloadedEntryPoint,
|
|
29
|
+
ops: RouterOps,
|
|
30
|
+
manifest?: Manifest,
|
|
31
|
+
) {
|
|
32
|
+
let bootstrap = `
|
|
33
|
+
<script type="text/javascript">
|
|
34
|
+
window.__router_ops = ${serialize(ops)};
|
|
35
|
+
</script>`;
|
|
36
|
+
|
|
37
|
+
const rootModuleSrc = JSResource.srcOfModuleId(entryPoint.rootModuleID);
|
|
38
|
+
if (rootModuleSrc == null) return bootstrap;
|
|
39
|
+
|
|
40
|
+
function crawlImports(moduleName: string) {
|
|
41
|
+
const chunk = manifest?.[moduleName];
|
|
42
|
+
if (!chunk) return;
|
|
43
|
+
|
|
44
|
+
chunk.imports?.forEach(crawlImports);
|
|
45
|
+
bootstrap =
|
|
46
|
+
`<link rel="modulepreload" href="${chunk.file}" />\n` + bootstrap;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
crawlImports(rootModuleSrc);
|
|
50
|
+
return bootstrap;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function router__ensureQueryFlushed(
|
|
54
|
+
query: AnyPreloadedQuery,
|
|
55
|
+
): Promise<GraphQLResponse> {
|
|
56
|
+
return new Promise((resolve, reject) => {
|
|
57
|
+
if (query.source == null) {
|
|
58
|
+
resolve({data: {}});
|
|
59
|
+
} else {
|
|
60
|
+
query.source.subscribe({
|
|
61
|
+
next: resolve,
|
|
62
|
+
error: reject,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function router__loadQueries(entryPoint: AnyPreloadedEntryPoint) {
|
|
69
|
+
const preloadedQueryOps: [OperationDescriptor, PayloadData][] = [];
|
|
70
|
+
for (const query of Object.values(
|
|
71
|
+
entryPoint?.queries ?? {},
|
|
72
|
+
) as PreloadedQuery<OperationType>[]) {
|
|
73
|
+
try {
|
|
74
|
+
const payload = await router__ensureQueryFlushed(query);
|
|
75
|
+
const concreteRequest =
|
|
76
|
+
query.id == null ? null : PreloadableQueryRegistry.get(query.id);
|
|
77
|
+
|
|
78
|
+
if (concreteRequest != null) {
|
|
79
|
+
const desc = createOperationDescriptor(
|
|
80
|
+
concreteRequest,
|
|
81
|
+
query.variables,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
preloadedQueryOps.push([
|
|
85
|
+
desc,
|
|
86
|
+
(payload as GraphQLSingularResponse).data!,
|
|
87
|
+
]);
|
|
88
|
+
}
|
|
89
|
+
} catch (e) {
|
|
90
|
+
console.error(e);
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return preloadedQueryOps;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function createRouterServerApp(
|
|
99
|
+
provider: IEnvironmentProvider<EnvironmentProviderOptions>,
|
|
100
|
+
initialPath: string,
|
|
101
|
+
) {
|
|
102
|
+
const ep = await router__loadEntryPoint(provider, initialPath);
|
|
103
|
+
const ops = ep != null ? await router__loadQueries(ep) : [];
|
|
104
|
+
const RouterApp = router__createAppFromEntryPoint(provider, ep, initialPath);
|
|
105
|
+
|
|
106
|
+
if (ep != null) {
|
|
107
|
+
RouterApp.bootstrap = (manifest) =>
|
|
108
|
+
router__bootstrapScripts(ep, ops, manifest);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return RouterApp;
|
|
112
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"allowSyntheticDefaultImports": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"strict": false,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"downlevelIteration": true,
|
|
12
|
+
"types": ["node"],
|
|
13
|
+
"lib": ["ES2022"],
|
|
14
|
+
"noEmit": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["src/**/*"],
|
|
17
|
+
"exclude": ["node_modules", "dist"]
|
|
18
|
+
}
|