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 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
+ }