pastoria 1.1.0 → 1.2.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/dist/generate.js CHANGED
@@ -1,67 +1,88 @@
1
1
  /**
2
2
  * @fileoverview Router Code Generator
3
3
  *
4
- * This script generates type-safe router configuration files by scanning TypeScript
5
- * source code for JSDoc annotations. It's part of the "Pastoria" routing framework.
4
+ * This script generates type-safe router configuration files using filesystem-based
5
+ * routing, similar to Next.js App Router.
6
6
  *
7
- * How it works:
8
- * 1. Scans all TypeScript files in the project for exported functions/classes
9
- * 2. Looks for JSDoc tags: @route, @resource, @appRoot, and @param
10
- * 3. Looks for exported classes that extend PastoriaRootContext for GraphQL context
11
- * 4. Generates files from templates:
12
- * - js_resource.ts: Resource configuration for lazy loading
13
- * - router.tsx: Client-side router with type-safe routes
14
- * - app_root.ts: Re-export of the app root component (if @appRoot is found)
15
- * - context.ts: Re-export of user's context class, or generate a default one
7
+ * ## Filesystem Routing Convention
16
8
  *
17
- * Usage:
18
- * - Add @route <route-name> to functions to create routes
19
- * - Add @param <name> <type> to document route parameters
20
- * - Add @resource <resource-name> to exports for lazy loading
21
- * - Add @appRoot to a component to designate it as the application root wrapper
22
- * - Add @gqlContext to a class extending PastoriaRootContext to provide a custom GraphQL context
23
- * - Add @serverRoute to functions to add an express handler
9
+ * Routes are defined by the directory structure under `pastoria/`:
10
+ * - `pastoria/page.tsx` Route `/`
11
+ * - `pastoria/about/page.tsx` Route `/about`
12
+ * - `pastoria/post/[slug]/page.tsx` Route `/post/:slug`
13
+ * - `pastoria/app.tsx` Root layout component
14
+ * - `pastoria/api/.../route.ts` API route handlers
15
+ * - `pastoria/*.page.tsx` Nested entry points
16
+ * - `pastoria/entrypoint.ts` → Manual entry point definitions
24
17
  *
25
- * The generator automatically creates Zod schemas for route parameters based on
26
- * TypeScript types, enabling runtime validation and type safety.
18
+ * ## Page Files
19
+ *
20
+ * Each `page.tsx` should:
21
+ * - Default export a React component
22
+ * - Optionally export `queries` object mapping query refs to Relay query types
23
+ *
24
+ * ## Generated Files
25
+ *
26
+ * Output is placed in `__generated__/router/`:
27
+ * - `js_resource.ts` - Resource configuration for lazy loading
28
+ * - `router.tsx` - Client-side router with type-safe routes
29
+ * - `app_root.ts` - Re-export of the app root component
30
+ * - `environment.ts` - Re-export of the user's PastoriaEnvironment
31
+ * - `server_handler.ts` - API route handlers
32
+ * - `types.ts` - PageProps and other type definitions
27
33
  */
28
34
  import { readFile } from 'node:fs/promises';
29
35
  import * as path from 'node:path';
30
36
  import pc from 'picocolors';
31
- import { SyntaxKind, ts, TypeFlags, } from 'ts-morph';
37
+ import { SyntaxKind, TypeFlags, } from 'ts-morph';
38
+ import { scanFilesystemRoutes, toRouterPath, } from './filesystem.js';
32
39
  import { logInfo, logWarn } from './logger.js';
33
- async function loadRouterTemplates(project, filename) {
34
- async function loadSourceFile(fileName, templateFileName) {
35
- const template = await readFile(templateFileName, 'utf-8');
36
- const warningComment = `/*
40
+ // ============================================================================
41
+ // Template Loading
42
+ // ============================================================================
43
+ /**
44
+ * Loads a template file and creates a new source file with a generated header.
45
+ */
46
+ async function loadRouterTemplate(project, filename) {
47
+ const templatePath = path.join(import.meta.dirname, '../templates', filename);
48
+ const outputPath = path.join('__generated__/router', filename);
49
+ const template = await readFile(templatePath, 'utf-8');
50
+ const warningComment = `/*
37
51
  * This file was generated by \`pastoria\`.
38
- * Do not modify this file directly. Instead, edit the template at ${path.basename(templateFileName)}.
52
+ * Do not modify this file directly.
39
53
  */
40
54
 
41
55
  `;
42
- return project.createSourceFile(fileName, warningComment + template, {
43
- overwrite: true,
44
- });
45
- }
46
- const template = path.join(import.meta.dirname, '../templates', filename);
47
- const output = path.join('__generated__/router', filename);
48
- return loadSourceFile(output, template);
56
+ return project.createSourceFile(outputPath, warningComment + template, {
57
+ overwrite: true,
58
+ });
49
59
  }
50
- // Regex to quickly check if a file contains any Pastoria JSDoc tags
51
- export const PASTORIA_TAG_REGEX = /@(route|resource|appRoot|param|gqlContext|serverRoute)\b/;
60
+ // ============================================================================
61
+ // Query Parameter Collection
62
+ // ============================================================================
63
+ /**
64
+ * Collects query variables from Relay-generated query type files.
65
+ *
66
+ * For each query, looks up the generated `${queryName}$variables` type
67
+ * and extracts all property names and types.
68
+ */
52
69
  function collectQueryParameters(project, queries) {
53
70
  const vars = new Map();
54
71
  for (const query of queries) {
55
- const variablesType = project
56
- .getSourceFile(`__generated__/queries/${query}.graphql.ts`)
57
- ?.getExportedDeclarations()
72
+ // Find the generated query file
73
+ const queryFile = project.getSourceFile(`__generated__/queries/${query}.graphql.ts`);
74
+ if (!queryFile)
75
+ continue;
76
+ // Get the variables type export
77
+ const variablesType = queryFile
78
+ .getExportedDeclarations()
58
79
  .get(`${query}$variables`)
59
80
  ?.at(0)
60
81
  ?.getType();
61
- if (variablesType == null)
82
+ if (!variablesType)
62
83
  continue;
84
+ // Extract each property from the variables type
63
85
  for (const property of variablesType.getProperties()) {
64
- // TODO: Detect conflicting types among properties declared.
65
86
  const propertyName = property.getName();
66
87
  const propertyType = property.getValueDeclaration()?.getType();
67
88
  if (propertyType) {
@@ -71,197 +92,16 @@ function collectQueryParameters(project, queries) {
71
92
  }
72
93
  return vars;
73
94
  }
74
- function collectPastoriaMetadata(project) {
75
- const resources = new Map();
76
- const routes = new Map();
77
- const serverHandlers = new Map();
78
- let appRoot = null;
79
- let gqlContext = null;
80
- function visitRouterNodes(sourceFile) {
81
- // Skip generated files
82
- if (sourceFile.getFilePath().includes('__generated__')) {
83
- return;
84
- }
85
- // Skip files that don't contain any Pastoria JSDoc tags
86
- const fileText = sourceFile.getFullText();
87
- if (!PASTORIA_TAG_REGEX.test(fileText)) {
88
- return;
89
- }
90
- sourceFile.getExportSymbols().forEach((symbol) => {
91
- let routerResource = null;
92
- let routerRoute = null;
93
- const routeParams = new Map();
94
- function visitJSDocTags(tag) {
95
- if (ts.isJSDoc(tag)) {
96
- tag.tags?.forEach(visitJSDocTags);
97
- }
98
- else if (ts.isJSDocParameterTag(tag)) {
99
- const typeNode = tag.typeExpression?.type;
100
- const tc = project.getTypeChecker().compilerObject;
101
- const type = typeNode == null
102
- ? tc.getUnknownType()
103
- : tc.getTypeFromTypeNode(typeNode);
104
- routeParams.set(tag.name.getText(), type);
105
- }
106
- else if (typeof tag.comment === 'string') {
107
- switch (tag.tagName.getText()) {
108
- case 'route': {
109
- routerRoute = [
110
- tag.comment,
111
- { sourceFile, symbol, params: routeParams },
112
- ];
113
- break;
114
- }
115
- case 'resource': {
116
- routerResource = [
117
- tag.comment,
118
- {
119
- sourceFile,
120
- symbol,
121
- queries: new Map(),
122
- entryPoints: new Map(),
123
- },
124
- ];
125
- break;
126
- }
127
- case 'serverRoute': {
128
- serverHandlers.set(tag.comment, { sourceFile, symbol });
129
- break;
130
- }
131
- }
132
- }
133
- else {
134
- // Handle tags without comments (like @ExportedSymbol, @gqlContext)
135
- switch (tag.tagName.getText()) {
136
- case 'appRoot': {
137
- if (appRoot != null) {
138
- logWarn('Multiple @appRoot tags found. Using the first one.');
139
- }
140
- else {
141
- appRoot = {
142
- sourceFile,
143
- symbol,
144
- };
145
- }
146
- break;
147
- }
148
- case 'gqlContext': {
149
- // Check if this class extends PastoriaRootContext
150
- const declarations = symbol.getDeclarations();
151
- let extendsPastoriaRootContext = false;
152
- for (const decl of declarations) {
153
- if (decl.isKind(SyntaxKind.ClassDeclaration)) {
154
- const classDecl = decl.asKindOrThrow(SyntaxKind.ClassDeclaration);
155
- const extendsClause = classDecl.getExtends();
156
- if (extendsClause != null) {
157
- const baseClassName = extendsClause
158
- .getExpression()
159
- .getText();
160
- if (baseClassName === 'PastoriaRootContext') {
161
- extendsPastoriaRootContext = true;
162
- break;
163
- }
164
- }
165
- }
166
- }
167
- if (extendsPastoriaRootContext) {
168
- if (gqlContext != null) {
169
- logWarn('Multiple classes with @gqlContext extending PastoriaRootContext found. Using the first one.');
170
- }
171
- else {
172
- gqlContext = {
173
- sourceFile,
174
- symbol,
175
- };
176
- }
177
- }
178
- break;
179
- }
180
- }
181
- }
182
- }
183
- symbol
184
- .getDeclarations()
185
- .flatMap((decl) => ts.getJSDocCommentsAndTags(decl.compilerNode))
186
- .forEach(visitJSDocTags);
187
- if (routerRoute != null) {
188
- const [routeName, routeSymbol] = routerRoute;
189
- routes.set(routeName, routeSymbol);
190
- const { entryPoints, queries } = getResourceQueriesAndEntryPoints(routeSymbol.symbol);
191
- if (routerResource == null && (entryPoints.size || queries.size)) {
192
- resources.set(`route(${routeName})`, {
193
- ...routeSymbol,
194
- entryPoints,
195
- queries,
196
- });
197
- }
198
- }
199
- if (routerResource != null) {
200
- const [resourceName, resourceSymbol] = routerResource;
201
- const { entryPoints, queries } = getResourceQueriesAndEntryPoints(resourceSymbol.symbol);
202
- resourceSymbol.queries = queries;
203
- resourceSymbol.entryPoints = entryPoints;
204
- resources.set(resourceName, resourceSymbol);
205
- }
206
- });
207
- }
208
- project.getSourceFiles().forEach(visitRouterNodes);
209
- return { resources, routes, appRoot, gqlContext, serverHandlers };
210
- }
211
- function getResourceQueriesAndEntryPoints(symbol) {
212
- const resource = {
213
- queries: new Map(),
214
- entryPoints: new Map(),
215
- };
216
- const decl = symbol.getValueDeclaration();
217
- if (!decl)
218
- return resource;
219
- const t = decl.getType();
220
- const aliasSymbol = t.getAliasSymbol();
221
- if (aliasSymbol?.getName() === 'EntryPointComponent') {
222
- const [queries, entryPoints] = t.getAliasTypeArguments();
223
- queries?.getProperties().forEach((prop) => {
224
- const queryRef = prop.getName();
225
- const queryName = prop
226
- .getValueDeclaration()
227
- ?.getType()
228
- .getAliasSymbol()
229
- ?.getName();
230
- if (queryName) {
231
- resource.queries.set(queryRef, queryName);
232
- }
233
- });
234
- entryPoints?.getProperties().forEach((prop) => {
235
- const epRef = prop.getName();
236
- const entryPointTypeRef = prop
237
- .getValueDeclaration()
238
- ?.asKind(SyntaxKind.PropertySignature)
239
- ?.getTypeNode()
240
- ?.asKind(SyntaxKind.TypeReference);
241
- const entryPointTypeName = entryPointTypeRef?.getTypeName().getText();
242
- if (entryPointTypeName !== 'EntryPoint') {
243
- // TODO: Warn about found types not named EntryPoint
244
- return;
245
- }
246
- const entryPointInner = entryPointTypeRef?.getTypeArguments().at(0);
247
- const moduleTypeRef = entryPointInner?.asKind(SyntaxKind.TypeReference);
248
- if (moduleTypeRef != null) {
249
- const resourceName = moduleTypeRef
250
- ?.getTypeArguments()
251
- .at(0)
252
- ?.asKind(SyntaxKind.LiteralType)
253
- ?.getLiteral()
254
- .asKind(SyntaxKind.StringLiteral)
255
- ?.getLiteralText();
256
- if (resourceName) {
257
- resource.entryPoints.set(epRef, resourceName);
258
- }
259
- }
260
- });
261
- }
262
- return resource;
263
- }
95
+ // ============================================================================
96
+ // Zod Schema Generation
97
+ // ============================================================================
98
+ /**
99
+ * Generates a Zod schema string for a TypeScript type.
100
+ *
101
+ * Used to create runtime validation schemas for route parameters.
102
+ */
264
103
  function zodSchemaOfType(sf, tc, t) {
104
+ // Handle type aliases (custom types)
265
105
  if (t.aliasSymbol) {
266
106
  const decl = t.aliasSymbol.declarations?.at(0);
267
107
  if (decl == null) {
@@ -273,16 +113,18 @@ function zodSchemaOfType(sf, tc, t) {
273
113
  return `z.transform((s: string) => s as import('${importPath}').${t.aliasSymbol.getName()})`;
274
114
  }
275
115
  }
276
- else if (t.getFlags() & TypeFlags.String) {
116
+ // Handle primitive types
117
+ if (t.getFlags() & TypeFlags.String) {
277
118
  return `z.pipe(z.string(), z.transform(decodeURIComponent))`;
278
119
  }
279
- else if (t.getFlags() & TypeFlags.Number) {
120
+ if (t.getFlags() & TypeFlags.Number) {
280
121
  return `z.coerce.number<number>()`;
281
122
  }
282
- else if (t.getFlags() & TypeFlags.Null) {
123
+ if (t.getFlags() & TypeFlags.Null) {
283
124
  return `z.preprocess(s => s == null ? undefined : s, z.undefined())`;
284
125
  }
285
- else if (t.isUnion()) {
126
+ // Handle union types
127
+ if (t.isUnion()) {
286
128
  const nullishTypes = [];
287
129
  const nonNullishTypes = [];
288
130
  for (const s of t.types) {
@@ -302,282 +144,748 @@ function zodSchemaOfType(sf, tc, t) {
302
144
  return `z.union([${t.types.map((it) => zodSchemaOfType(sf, tc, it)).join(', ')}])`;
303
145
  }
304
146
  }
305
- else if (tc.isArrayLikeType(t)) {
147
+ // Handle array types
148
+ if (tc.isArrayLikeType(t)) {
306
149
  const typeArg = tc.getTypeArguments(t)[0];
307
150
  const argZodSchema = typeArg == null ? `z.any()` : zodSchemaOfType(sf, tc, typeArg);
308
151
  return `z.array(${argZodSchema})`;
309
152
  }
310
- else {
311
- logWarn('Could not handle type:', tc.typeToString(t));
312
- return `z.any()`;
153
+ // Fallback
154
+ logWarn('Could not handle type:', tc.typeToString(t));
155
+ return `z.any()`;
156
+ }
157
+ // ============================================================================
158
+ // Entry Point Generation
159
+ // ============================================================================
160
+ /**
161
+ * Collects all query names from a page and its nested entry points.
162
+ */
163
+ function collectAllQueries(page) {
164
+ const queries = new Set();
165
+ // Add main page queries
166
+ for (const queryName of page.queries.values()) {
167
+ queries.add(queryName);
168
+ }
169
+ // Add nested entry point queries
170
+ for (const nestedPage of page.nestedEntryPoints.values()) {
171
+ for (const queryName of nestedPage.queries.values()) {
172
+ queries.add(queryName);
173
+ }
313
174
  }
175
+ return queries;
314
176
  }
315
- function writeEntryPoint(writer, project, metadata, consumedQueries, resourceName, resource, parseVars = true) {
316
- writer.writeLine(`root: JSResource.fromModuleId('${resourceName}'),`);
317
- writer
318
- .write(`getPreloadProps(${parseVars ? '{params, schema}' : ''})`)
319
- .block(() => {
320
- if (parseVars) {
321
- writer.writeLine('const variables = schema.parse(params);');
177
+ /**
178
+ * Writes an entry point definition for a filesystem-based page.
179
+ *
180
+ * Generates a getPreloadProps function that:
181
+ * 1. Sets up query preloading with the provided variables
182
+ * 2. Includes any nested entry points
183
+ *
184
+ * Note: Params are already parsed and typed by ROUTER_CONF schema via
185
+ * EntryPointParams<R>, so no additional parsing is needed here.
186
+ */
187
+ function writeFilesystemEntryPoint(writer, project, page, resourceName, routePath, schemaExpression) {
188
+ const hasNestedEntryPoints = page.nestedEntryPoints.size > 0;
189
+ // Define schema locally so it can be referenced by wrappedGetPreloadProps
190
+ writer.writeLine(`const schema = ${schemaExpression};`);
191
+ // Generate query helper functions
192
+ writer.write('const queryHelpers = ').block(() => {
193
+ for (const [queryRef, queryName] of page.queries.entries()) {
194
+ writer.writeLine(`${queryRef}: (variables: ${queryName}$variables) => ({ parameters: ${queryName}Parameters, variables }),`);
322
195
  }
323
- writer.write('return').block(() => {
196
+ });
197
+ writer.writeLine(';');
198
+ // Generate entry point helper functions
199
+ writer.write('const entryPointHelpers = ').block(() => {
200
+ for (const [epName, nestedPage] of page.nestedEntryPoints.entries()) {
201
+ const nestedResourceName = `${resourceName}#${epName}`;
202
+ // Build the combined variables type from all queries in this nested entry point
203
+ const nestedQueryNames = Array.from(nestedPage.queries.values());
204
+ const variablesType = nestedQueryNames.length === 0
205
+ ? 'Record<string, never>'
206
+ : nestedQueryNames.map((q) => `${q}$variables`).join(' & ');
324
207
  writer
325
- .write('queries:')
208
+ .write(`${epName}: (variables: ${variablesType}) => (`)
326
209
  .block(() => {
327
- for (const [queryRef, query] of resource.queries.entries()) {
328
- consumedQueries.add(query);
329
- // Determine which variables this specific query needs
330
- const queryVars = collectQueryParameters(project, [query]);
331
- const hasVariables = queryVars.size > 0;
332
- writer
333
- .write(`${queryRef}:`)
334
- .block(() => {
335
- writer.writeLine(`parameters: ${query}Parameters,`);
336
- if (hasVariables) {
337
- const varNames = Array.from(queryVars.keys());
338
- // Always pick from the variables object
339
- writer.write(`variables: {`);
340
- writer.write(varNames.map((v) => `${v}: variables.${v}`).join(', '));
341
- writer.write(`}`);
342
- }
343
- else {
344
- // Query has no variables, pass empty object
345
- writer.write(`variables: {}`);
346
- }
347
- writer.newLine();
348
- })
349
- .write(',');
350
- }
351
- })
352
- .writeLine(',');
353
- writer.write('entryPoints:').block(() => {
354
- for (const [epRef, subresourceName,] of resource.entryPoints.entries()) {
355
- const subresource = metadata.resources.get(subresourceName);
356
- if (subresource) {
357
- writer
358
- .write(`${epRef}:`)
359
- .block(() => {
360
- writer.writeLine(`entryPointParams: {},`);
361
- writer.write('entryPoint:').block(() => {
362
- writeEntryPoint(writer, project, metadata, consumedQueries, subresourceName, subresource, false);
210
+ writer.writeLine('entryPointParams: {},');
211
+ writer.write('entryPoint: ').block(() => {
212
+ writer.writeLine(`root: JSResource.fromModuleId('${nestedResourceName}'),`);
213
+ writer.write('getPreloadProps() ').block(() => {
214
+ writer.write('return ').block(() => {
215
+ writer.write('queries: ').block(() => {
216
+ for (const [nestedQueryRef, nestedQueryName,] of nestedPage.queries.entries()) {
217
+ writer.writeLine(`${nestedQueryRef}: { parameters: ${nestedQueryName}Parameters, variables },`);
218
+ }
363
219
  });
364
- })
365
- .writeLine(',');
220
+ writer.writeLine(',');
221
+ writer.writeLine('entryPoints: undefined');
222
+ });
223
+ });
224
+ });
225
+ });
226
+ writer.writeLine('),');
227
+ }
228
+ });
229
+ writer.writeLine(';');
230
+ // Write getPreloadProps using the helpers
231
+ writer
232
+ .write(`function getPreloadProps({params, queries, entryPoints}: EntryPointParams<'${routePath}'>)`)
233
+ .block(() => {
234
+ // Params are already parsed and typed via EntryPointParams<R>
235
+ writer.writeLine('const variables = params;');
236
+ writer.write('return ').block(() => {
237
+ // Write queries using helpers
238
+ writer.write('queries: ').block(() => {
239
+ for (const [queryRef, _queryName] of page.queries.entries()) {
240
+ const queryVars = collectQueryParameters(project, [
241
+ page.queries.get(queryRef),
242
+ ]);
243
+ if (queryVars.size > 0) {
244
+ const varNames = Array.from(queryVars.keys());
245
+ writer.writeLine(`${queryRef}: queries.${queryRef}({${varNames.map((v) => `${v}: variables.${v}`).join(', ')}}),`);
246
+ }
247
+ else {
248
+ writer.writeLine(`${queryRef}: queries.${queryRef}({}),`);
366
249
  }
367
250
  }
368
251
  });
252
+ writer.writeLine(',');
253
+ // Write entry points using helpers
254
+ if (hasNestedEntryPoints) {
255
+ writer.write('entryPoints: ').block(() => {
256
+ for (const [epName, nestedPage,] of page.nestedEntryPoints.entries()) {
257
+ // Collect all variables needed by this nested entry point
258
+ const nestedVars = collectQueryParameters(project, Array.from(nestedPage.queries.values()));
259
+ if (nestedVars.size > 0) {
260
+ const varNames = Array.from(nestedVars.keys());
261
+ writer.writeLine(`${epName}: entryPoints.${epName}({${varNames.map((v) => `${v}: variables.${v}`).join(', ')}}),`);
262
+ }
263
+ else {
264
+ writer.writeLine(`${epName}: entryPoints.${epName}({}),`);
265
+ }
266
+ }
267
+ });
268
+ }
269
+ else {
270
+ writer.writeLine('entryPoints: undefined');
271
+ }
369
272
  });
370
273
  });
371
274
  }
372
- async function generateRouter(project, metadata) {
373
- const routerTemplate = await loadRouterTemplates(project, 'router.tsx');
374
- const tc = project.getTypeChecker().compilerObject;
375
- let didAddJsResourceImport = false;
275
+ // ============================================================================
276
+ // Router Generation
277
+ // ============================================================================
278
+ /**
279
+ * Generates the router.tsx file with all route configurations.
280
+ *
281
+ * For each page discovered in the filesystem:
282
+ * 1. Generates an entry point function with getPreloadProps
283
+ * 2. Creates a route configuration entry with Zod schema
284
+ */
285
+ async function generateRouter(project, fsMetadata) {
286
+ const routerTemplate = await loadRouterTemplate(project, 'router.tsx');
287
+ // Get the ROUTER_CONF object to add routes to
376
288
  const routerConf = routerTemplate
377
289
  .getVariableDeclarationOrThrow('ROUTER_CONF')
378
290
  .getInitializerIfKindOrThrow(SyntaxKind.AsExpression)
379
291
  .getExpressionIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
380
- routerConf.getPropertyOrThrow('noop').remove();
381
- let entryPointImportIndex = 0;
382
- for (let [routeName, { sourceFile, symbol, params },] of metadata.routes.entries()) {
383
- const filePath = path.relative(process.cwd(), sourceFile.getFilePath());
384
- let entryPointExpression;
385
- // Resource-routes are combined declarations of a resource and a route
386
- // where we should generate the entrypoint for the route.
387
- const isResourceRoute = Array.from(metadata.resources.entries()).find(([, { symbol: resourceSymbol }]) => symbol === resourceSymbol);
388
- if (isResourceRoute) {
389
- const [resourceName, resource] = isResourceRoute;
390
- const entryPointFunctionName = `entrypoint_${resourceName.replace(/\W/g, '')}`;
391
- if (!didAddJsResourceImport) {
392
- didAddJsResourceImport = true;
292
+ // Only remove the noop placeholder if there are actual routes
293
+ // (keeping it prevents type errors when the config is empty)
294
+ const hasRoutes = fsMetadata.pages.size > 0 || fsMetadata.entryPoints.size > 0;
295
+ if (hasRoutes) {
296
+ routerConf.getPropertyOrThrow('noop').remove();
297
+ }
298
+ // Add JSResource import
299
+ routerTemplate.addImportDeclaration({
300
+ moduleSpecifier: './js_resource',
301
+ namedImports: ['JSResource', 'ModuleType'],
302
+ });
303
+ // Add helper types import from types.ts
304
+ routerTemplate.addImportDeclaration({
305
+ moduleSpecifier: './types',
306
+ namedImports: ['QueryHelpersForRoute', 'EntryPointHelpersForRoute'],
307
+ isTypeOnly: true,
308
+ });
309
+ const tc = project.getTypeChecker().compilerObject;
310
+ // Process each page
311
+ for (const [routePath, page] of fsMetadata.pages.entries()) {
312
+ // Create a unique resource name for this page
313
+ const resourceName = `fs:page(${routePath})`;
314
+ const safeResourceName = resourceName.replace(/[^a-zA-Z0-9]/g, '_');
315
+ // Check if this page has a custom entrypoint.ts
316
+ const hasCustomEntryPoint = page.customEntryPointPath != null;
317
+ // Import custom entrypoint exports if present
318
+ let customEntryPointAlias = null;
319
+ if (hasCustomEntryPoint) {
320
+ customEntryPointAlias = `customEp_${safeResourceName}`;
321
+ routerTemplate.addImportDeclaration({
322
+ moduleSpecifier: routerTemplate.getRelativePathAsModuleSpecifierTo(path.join(process.cwd(), page.customEntryPointPath)),
323
+ defaultImport: customEntryPointAlias,
324
+ namedImports: [
325
+ { name: 'schema', alias: `${customEntryPointAlias}_schema` },
326
+ ],
327
+ });
328
+ }
329
+ // Collect all queries consumed by this page and its nested entry points
330
+ const consumedQueries = collectAllQueries(page);
331
+ // Add imports for query parameters and variable types (only needed for generated entry points)
332
+ if (!hasCustomEntryPoint) {
333
+ for (const queryName of consumedQueries) {
334
+ routerTemplate.addImportDeclaration({
335
+ moduleSpecifier: `#genfiles/queries/${queryName}$parameters`,
336
+ defaultImport: `${queryName}Parameters`,
337
+ });
338
+ // Add type-only import for variables (doesn't import runtime code)
339
+ routerTemplate.addImportDeclaration({
340
+ moduleSpecifier: `#genfiles/queries/${queryName}.graphql`,
341
+ namedImports: [`${queryName}$variables`],
342
+ isTypeOnly: true,
343
+ });
344
+ }
345
+ }
346
+ // Build params schema from route params + query variables
347
+ // Track both the type and whether it's optional
348
+ const params = new Map();
349
+ // Add filesystem params as string types (using routeParams for optional info)
350
+ for (const routeParam of page.routeParams) {
351
+ params.set(routeParam.name, {
352
+ type: tc.getStringType(),
353
+ optional: routeParam.optional,
354
+ });
355
+ }
356
+ // Also include query variables (only needed for generated entry points)
357
+ if (!hasCustomEntryPoint && consumedQueries.size > 0) {
358
+ const queryParams = collectQueryParameters(project, Array.from(consumedQueries));
359
+ // Merge query params with route params (route params take precedence)
360
+ for (const [paramName, paramType] of queryParams) {
361
+ if (!params.has(paramName)) {
362
+ params.set(paramName, { type: paramType, optional: false });
363
+ }
364
+ }
365
+ }
366
+ // Build schema expression string for the entry point to capture
367
+ let schemaExpression;
368
+ if (hasCustomEntryPoint) {
369
+ // Use the imported schema from the custom entrypoint
370
+ schemaExpression = `${customEntryPointAlias}_schema`;
371
+ }
372
+ else if (params.size === 0) {
373
+ schemaExpression = 'z.object({})';
374
+ }
375
+ else {
376
+ const schemaFields = Array.from(params.entries())
377
+ .map(([paramName, { type: paramType, optional: isOptional }]) => {
378
+ const baseSchema = zodSchemaOfType(routerTemplate, tc, paramType);
379
+ // Wrap optional params with z.optional()
380
+ const schema = isOptional ? `z.optional(${baseSchema})` : baseSchema;
381
+ return `${paramName}: ${schema}`;
382
+ })
383
+ .join(', ');
384
+ schemaExpression = `z.object({ ${schemaFields} })`;
385
+ }
386
+ // Generate the entry point function
387
+ if (hasCustomEntryPoint) {
388
+ // Custom entrypoint: use imported getPreloadProps but still wrap with helpers
389
+ // First, add query parameter imports needed for the helpers
390
+ for (const queryName of consumedQueries) {
393
391
  routerTemplate.addImportDeclaration({
394
- moduleSpecifier: './js_resource',
395
- namedImports: ['JSResource', 'ModuleType'],
392
+ moduleSpecifier: `#genfiles/queries/${queryName}$parameters`,
393
+ defaultImport: `${queryName}Parameters`,
394
+ });
395
+ routerTemplate.addImportDeclaration({
396
+ moduleSpecifier: `#genfiles/queries/${queryName}.graphql`,
397
+ namedImports: [`${queryName}$variables`],
398
+ isTypeOnly: true,
396
399
  });
397
400
  }
398
- const consumedQueries = new Set();
399
401
  routerTemplate.addFunction({
400
- name: entryPointFunctionName,
401
- returnType: `EntryPoint<ModuleType<'${resourceName}'>, EntryPointParams<'${routeName}'>>`,
402
+ name: `entrypoint_${safeResourceName}`,
402
403
  statements: (writer) => {
404
+ // Generate query helpers
405
+ writer.write('const queryHelpers = ').block(() => {
406
+ for (const [queryRef, queryName] of page.queries.entries()) {
407
+ writer.writeLine(`${queryRef}: (variables: ${queryName}$variables) => ({ parameters: ${queryName}Parameters, variables }),`);
408
+ }
409
+ });
410
+ writer.writeLine(';');
411
+ // Generate entry point helpers
412
+ writer.write('const entryPointHelpers = ').block(() => {
413
+ for (const [epName, nestedPage,] of page.nestedEntryPoints.entries()) {
414
+ const nestedResourceName = `${resourceName}#${epName}`;
415
+ const nestedQueryNames = Array.from(nestedPage.queries.values());
416
+ const variablesType = nestedQueryNames.length === 0
417
+ ? 'Record<string, never>'
418
+ : nestedQueryNames.map((q) => `${q}$variables`).join(' & ');
419
+ writer
420
+ .write(`${epName}: (variables: ${variablesType}) => (`)
421
+ .block(() => {
422
+ writer.writeLine('entryPointParams: {},');
423
+ writer.write('entryPoint: ').block(() => {
424
+ writer.writeLine(`root: JSResource.fromModuleId('${nestedResourceName}'),`);
425
+ writer.write('getPreloadProps() ').block(() => {
426
+ writer.write('return ').block(() => {
427
+ writer.write('queries: ').block(() => {
428
+ for (const [nestedQueryRef, nestedQueryName,] of nestedPage.queries.entries()) {
429
+ writer.writeLine(`${nestedQueryRef}: { parameters: ${nestedQueryName}Parameters, variables },`);
430
+ }
431
+ });
432
+ writer.writeLine(',');
433
+ writer.writeLine('entryPoints: undefined');
434
+ });
435
+ });
436
+ });
437
+ });
438
+ writer.writeLine('),');
439
+ }
440
+ });
441
+ writer.writeLine(';');
403
442
  writer.write('return ').block(() => {
404
- writeEntryPoint(writer, project, metadata, consumedQueries, resourceName, resource);
443
+ writer.writeLine(`root: JSResource.fromModuleId('${resourceName}'),`);
444
+ writer.writeLine(`getPreloadProps: (p: {params: z.infer<typeof ${customEntryPointAlias}_schema>}) => ${customEntryPointAlias}({`);
445
+ writer.writeLine(' params: p.params,');
446
+ writer.writeLine(' queries: queryHelpers,');
447
+ writer.writeLine(' entryPoints: entryPointHelpers,');
448
+ writer.writeLine('}),');
405
449
  });
406
450
  },
407
451
  });
408
- if (params.size === 0 && consumedQueries.size > 0) {
409
- params = collectQueryParameters(project, Array.from(consumedQueries));
410
- }
411
- for (const query of consumedQueries) {
412
- routerTemplate.addImportDeclaration({
413
- moduleSpecifier: `#genfiles/queries/${query}$parameters`,
414
- defaultImport: `${query}Parameters`,
415
- });
416
- }
417
- entryPointExpression = entryPointFunctionName + '()';
418
452
  }
419
453
  else {
420
- const importAlias = `e${entryPointImportIndex++}`;
421
- const moduleSpecifier = routerTemplate.getRelativePathAsModuleSpecifierTo(sourceFile.getFilePath());
422
- routerTemplate.addImportDeclaration({
423
- moduleSpecifier,
424
- namedImports: [
425
- {
426
- name: symbol.getName(),
427
- alias: importAlias,
428
- },
429
- ],
454
+ // Generated entrypoint: create getPreloadProps from page queries
455
+ routerTemplate.addFunction({
456
+ name: `entrypoint_${safeResourceName}`,
457
+ statements: (writer) => {
458
+ writeFilesystemEntryPoint(writer, project, page, resourceName, routePath, schemaExpression);
459
+ writer.write('return ').block(() => {
460
+ writer.writeLine(`root: JSResource.fromModuleId('${resourceName}'),`);
461
+ writer.writeLine('getPreloadProps: (p: {params: Record<string, unknown>}) => getPreloadProps({');
462
+ writer.writeLine(' params: p.params as z.infer<typeof schema>,');
463
+ writer.writeLine(' queries: queryHelpers,');
464
+ writer.writeLine(' entryPoints: entryPointHelpers,');
465
+ writer.writeLine('}),');
466
+ });
467
+ },
430
468
  });
431
- entryPointExpression = importAlias;
432
469
  }
470
+ // Add route configuration (uses bracket format, converted to colon for radix3 at runtime)
433
471
  routerConf.addPropertyAssignment({
434
- name: `"${routeName}"`,
472
+ name: `"${routePath}"`,
435
473
  initializer: (writer) => {
436
- writer
437
- .write('{')
438
- .indent(() => {
439
- writer.writeLine(`entrypoint: ${entryPointExpression},`);
440
- if (params.size === 0) {
441
- writer.writeLine(`schema: z.object({})`);
442
- }
443
- else {
444
- writer.writeLine(`schema: z.object({`);
445
- for (const [paramName, paramType] of Array.from(params)) {
446
- writer.writeLine(` ${paramName}: ${zodSchemaOfType(routerTemplate, tc, paramType)},`);
447
- }
448
- writer.writeLine('})');
449
- }
450
- })
451
- .write('} as const');
474
+ writer.write('{').indent(() => {
475
+ writer.writeLine(`entrypoint: entrypoint_${safeResourceName}(),`);
476
+ writer.writeLine(`schema: ${schemaExpression}`);
477
+ });
478
+ writer.write('} as const');
452
479
  },
453
480
  });
454
- logInfo('Created route', pc.cyan(routeName), 'for', pc.green(symbol.getName()), 'exported from', pc.yellow(filePath));
481
+ logInfo('Created route', pc.cyan(routePath), 'from', pc.yellow(page.filePath), hasCustomEntryPoint ? '(custom entrypoint)' : '');
455
482
  }
456
483
  await routerTemplate.save();
457
484
  }
458
- async function generateJsResource(project, metadata) {
459
- const jsResourceTemplate = await loadRouterTemplates(project, 'js_resource.ts');
485
+ // ============================================================================
486
+ // JS Resource Generation
487
+ // ============================================================================
488
+ /**
489
+ * Generates the js_resource.ts file for lazy loading.
490
+ *
491
+ * Each page.tsx and *.page.tsx file becomes a lazy-loadable resource.
492
+ */
493
+ async function generateJsResource(project, fsMetadata) {
494
+ const jsResourceTemplate = await loadRouterTemplate(project, 'js_resource.ts');
460
495
  const resourceConf = jsResourceTemplate
461
496
  .getVariableDeclarationOrThrow('RESOURCE_CONF')
462
497
  .getInitializerIfKindOrThrow(SyntaxKind.AsExpression)
463
498
  .getExpressionIfKindOrThrow(SyntaxKind.ObjectLiteralExpression);
464
- resourceConf.getPropertyOrThrow('noop').remove();
465
- for (const [resourceName, { sourceFile, symbol },] of metadata.resources.entries()) {
466
- const filePath = path.relative(process.cwd(), sourceFile.getFilePath());
467
- const moduleSpecifier = jsResourceTemplate.getRelativePathAsModuleSpecifierTo(sourceFile.getFilePath());
499
+ // Only remove the noop placeholder if there are actual pages
500
+ const hasPages = fsMetadata.pages.size > 0;
501
+ if (hasPages) {
502
+ resourceConf.getPropertyOrThrow('noop').remove();
503
+ }
504
+ // Process main pages
505
+ for (const [routePath, page] of fsMetadata.pages.entries()) {
506
+ const resourceName = `fs:page(${routePath})`;
507
+ const moduleSpecifier = jsResourceTemplate.getRelativePathAsModuleSpecifierTo(path.join(process.cwd(), page.filePath));
468
508
  resourceConf.addPropertyAssignment({
469
509
  name: `"${resourceName}"`,
470
510
  initializer: (writer) => {
471
511
  writer.block(() => {
472
- writer
473
- .writeLine(`src: "${filePath}",`)
474
- .writeLine(`loader: () => import("${moduleSpecifier}").then(m => m.${symbol.getName()})`);
512
+ writer.writeLine(`src: "${page.filePath}",`);
513
+ writer.writeLine(`loader: (): Promise<ComponentType<PageProps<'${routePath}'>>> => import("${moduleSpecifier}").then(m => m.default)`);
475
514
  });
476
515
  },
477
516
  });
478
- logInfo('Created resource', pc.cyan(resourceName), 'for', pc.green(symbol.getName()), 'exported from', pc.yellow(filePath));
517
+ logInfo('Created resource', pc.cyan(resourceName), 'from', pc.yellow(page.filePath));
518
+ // Process nested entry points for this page
519
+ for (const [epName, nestedPage] of page.nestedEntryPoints.entries()) {
520
+ const nestedResourceName = `${resourceName}#${epName}`;
521
+ const nestedModuleSpecifier = jsResourceTemplate.getRelativePathAsModuleSpecifierTo(path.join(process.cwd(), nestedPage.filePath));
522
+ // Nested entry points use flattened keys like '/#search_results'
523
+ const nestedRouteKey = `${routePath}#${epName}`;
524
+ resourceConf.addPropertyAssignment({
525
+ name: `"${nestedResourceName}"`,
526
+ initializer: (writer) => {
527
+ writer.block(() => {
528
+ writer.writeLine(`src: "${nestedPage.filePath}",`);
529
+ writer.writeLine(`loader: (): Promise<ComponentType<PageProps<'${nestedRouteKey}'>>> => import("${nestedModuleSpecifier}").then(m => m.default)`);
530
+ });
531
+ },
532
+ });
533
+ logInfo('Created nested resource', pc.cyan(nestedResourceName), 'from', pc.yellow(nestedPage.filePath));
534
+ }
479
535
  }
480
536
  await jsResourceTemplate.save();
481
537
  }
482
- async function generateAppRoot(project, metadata) {
483
- const targetDir = process.cwd();
484
- const appRoot = metadata.appRoot;
485
- if (appRoot == null) {
538
+ // ============================================================================
539
+ // Server Handler Generation
540
+ // ============================================================================
541
+ /**
542
+ * Generates server_handler.ts with API route handlers.
543
+ *
544
+ * Each route.ts file in the filesystem exports an express.Router
545
+ * that handles requests at that path.
546
+ */
547
+ async function generateServerHandler(project, fsMetadata) {
548
+ if (fsMetadata.apiRoutes.size === 0) {
549
+ // No API routes found, delete the file if it exists
486
550
  await project
487
- .getSourceFile('__generated__/router/app_root.ts')
551
+ .getSourceFile('__generated__/router/server_handler.ts')
488
552
  ?.deleteImmediately();
489
553
  return;
490
554
  }
491
- const appRootSourceFile = appRoot.sourceFile;
492
- const appRootSymbol = appRoot.symbol;
493
- const filePath = path.relative(targetDir, appRootSourceFile.getFilePath());
494
- const appRootFile = project.createSourceFile('__generated__/router/app_root.ts', '', { overwrite: true });
495
- const moduleSpecifier = appRootFile.getRelativePathAsModuleSpecifierTo(appRootSourceFile.getFilePath());
496
- appRootFile.addStatements(`/*
555
+ const serverHandlerFile = project.createSourceFile('__generated__/router/server_handler.ts', `/*
497
556
  * This file was generated by \`pastoria\`.
498
557
  * Do not modify this file directly.
499
558
  */
500
559
 
501
- export {${appRootSymbol.getName()} as App} from '${moduleSpecifier}';
502
- `);
503
- await appRootFile.save();
504
- logInfo('Created app root for', pc.green(appRootSymbol.getName()), 'exported from', pc.yellow(filePath));
560
+ import express from 'express';
561
+ export const router = express.Router();
562
+ `, { overwrite: true });
563
+ let importIndex = 0;
564
+ for (const [routePath, apiRoute] of fsMetadata.apiRoutes.entries()) {
565
+ // Convert [param] to :param for Express router
566
+ const routerPath = toRouterPath(routePath);
567
+ const importAlias = `route${importIndex++}`;
568
+ const moduleSpecifier = serverHandlerFile.getRelativePathAsModuleSpecifierTo(path.join(process.cwd(), apiRoute.filePath));
569
+ serverHandlerFile.addImportDeclaration({
570
+ moduleSpecifier,
571
+ defaultImport: importAlias,
572
+ });
573
+ serverHandlerFile.addStatements(`router.use('${routerPath}', ${importAlias});`);
574
+ logInfo('Created API route', pc.cyan(routePath), 'from', pc.yellow(apiRoute.filePath));
575
+ }
576
+ await serverHandlerFile.save();
505
577
  }
506
- async function generateGraphqlContext(project, metadata) {
507
- const targetDir = process.cwd();
508
- const gqlContext = metadata.gqlContext;
509
- const contextFile = project.createSourceFile('__generated__/router/context.ts', '', { overwrite: true });
510
- if (gqlContext != null) {
511
- const contextSourceFile = gqlContext.sourceFile;
512
- const contextSymbol = gqlContext.symbol;
513
- const filePath = path.relative(targetDir, contextSourceFile.getFilePath());
514
- const moduleSpecifier = contextFile.getRelativePathAsModuleSpecifierTo(contextSourceFile.getFilePath());
515
- contextFile.addStatements(`/*
516
- * This file was generated by \`pastoria\`.
517
- * Do not modify this file directly.
578
+ // ============================================================================
579
+ // Type Generation
580
+ // ============================================================================
581
+ /**
582
+ * Generates types.ts with PageProps type definitions.
583
+ *
584
+ * Creates types like PageProps<'/posts'> that include:
585
+ * - queries: Preloaded query references
586
+ * - entryPoints: Nested entry point references
518
587
  */
519
-
520
- export {${contextSymbol.getName()} as Context} from '${moduleSpecifier}';
521
- `);
522
- logInfo('Created GraphQL context for', pc.green(contextSymbol.getName()), 'exported from', pc.yellow(filePath));
588
+ async function generateTypes(project, fsMetadata) {
589
+ const typesFile = project.createSourceFile('__generated__/router/types.ts', '', { overwrite: true });
590
+ // Collect all query types to import (both the query type and $variables type)
591
+ const queryImports = new Set();
592
+ const variablesImports = new Set();
593
+ // Helper to build queries type string
594
+ function buildQueriesType(queries) {
595
+ if (queries.size === 0)
596
+ return '{}';
597
+ return `{ ${Array.from(queries.entries())
598
+ .map(([ref, queryTypeName]) => {
599
+ queryImports.add(queryTypeName);
600
+ return `${ref}: ${queryTypeName}`;
601
+ })
602
+ .join('; ')} }`;
603
+ }
604
+ // Helper to build query helper type for a route
605
+ function buildQueryHelpersType(queries) {
606
+ if (queries.size === 0)
607
+ return '{}';
608
+ return `{ ${Array.from(queries.entries())
609
+ .map(([ref, queryTypeName]) => {
610
+ variablesImports.add(queryTypeName);
611
+ return `${ref}: (variables: ${queryTypeName}$variables) => { parameters: unknown; variables: ${queryTypeName}$variables }`;
612
+ })
613
+ .join('; ')} }`;
614
+ }
615
+ // Helper to build entry point helper type for a route
616
+ function buildEntryPointHelpersType(routePath, nestedEntryPoints) {
617
+ if (nestedEntryPoints.size === 0)
618
+ return '{}';
619
+ return `{ ${Array.from(nestedEntryPoints.entries())
620
+ .map(([epName, nestedPage]) => {
621
+ const nestedTypeName = routeToTypeName(routePath, epName);
622
+ // Build intersection of all query variable types for this nested entry point
623
+ const queryNames = Array.from(nestedPage.queries.values());
624
+ queryNames.forEach((q) => variablesImports.add(q));
625
+ const variablesType = queryNames.length === 0
626
+ ? 'Record<string, never>'
627
+ : queryNames.map((q) => `${q}$variables`).join(' & ');
628
+ // Optional entry points can return undefined
629
+ const baseReturnType = `{ entryPointParams: Record<string, never>; entryPoint: EntryPoint<EntryPointComponent<${nestedTypeName}['queries'], ${nestedTypeName}['entryPoints'], {}, {}>, {}> }`;
630
+ const returnType = nestedPage.optional
631
+ ? `${baseReturnType} | undefined`
632
+ : baseReturnType;
633
+ return `${epName}: (variables: ${variablesType}) => ${returnType}`;
634
+ })
635
+ .join('; ')} }`;
636
+ }
637
+ // Helper to convert route path to a valid TypeScript identifier
638
+ function routeToTypeName(routePath, nestedName) {
639
+ // Convert /hello/[name] to Hello$name, /hello/[[name]] to Hello$$name, / to Root
640
+ const pathPart = routePath === '/'
641
+ ? 'Root'
642
+ : routePath
643
+ .slice(1) // remove leading /
644
+ .split('/')
645
+ .map((segment) => {
646
+ // Check for optional param first: [[name]] -> $$name
647
+ if (segment.startsWith('[[') && segment.endsWith(']]')) {
648
+ return '$$' + segment.slice(2, -2);
649
+ }
650
+ // Check for required param: [name] -> $name
651
+ if (segment.startsWith('[') && segment.endsWith(']')) {
652
+ return '$' + segment.slice(1, -1);
653
+ }
654
+ // Static segment: hello -> Hello (capitalize first letter)
655
+ return segment.charAt(0).toUpperCase() + segment.slice(1);
656
+ })
657
+ .join('');
658
+ if (nestedName) {
659
+ // Nested entry point: append _nestedName
660
+ return `Route${pathPart}_${nestedName}`;
661
+ }
662
+ return `Route${pathPart}`;
663
+ }
664
+ // Phase 1: Generate type aliases for nested entry points (leaf nodes - no dependencies)
665
+ const nestedTypeAliases = [];
666
+ for (const [routePath, page] of fsMetadata.pages.entries()) {
667
+ for (const [epName, nestedPage] of page.nestedEntryPoints.entries()) {
668
+ const typeName = routeToTypeName(routePath, epName);
669
+ const queriesType = buildQueriesType(nestedPage.queries);
670
+ nestedTypeAliases.push(`type ${typeName} = { queries: ${queriesType}; entryPoints: {} };`);
671
+ }
523
672
  }
524
- else {
525
- contextFile.addStatements(`/*
673
+ // Phase 2: Generate type aliases for main pages (can reference nested types)
674
+ const mainTypeAliases = [];
675
+ for (const [routePath, page] of fsMetadata.pages.entries()) {
676
+ const typeName = routeToTypeName(routePath);
677
+ const queriesType = buildQueriesType(page.queries);
678
+ let entryPointsType = '{}';
679
+ if (page.nestedEntryPoints.size > 0) {
680
+ const epTypes = Array.from(page.nestedEntryPoints.entries())
681
+ .map(([epName, nestedPage]) => {
682
+ const nestedTypeName = routeToTypeName(routePath, epName);
683
+ const optionalMarker = nestedPage.optional ? '?' : '';
684
+ return `${epName}${optionalMarker}: EntryPoint<EntryPointComponent<${nestedTypeName}['queries'], ${nestedTypeName}['entryPoints'], {}, {}>, {}>`;
685
+ })
686
+ .join('; ');
687
+ entryPointsType = `{ ${epTypes} }`;
688
+ }
689
+ mainTypeAliases.push(`type ${typeName} = { queries: ${queriesType}; entryPoints: ${entryPointsType} };`);
690
+ }
691
+ // Phase 3: Build PageQueryMap entries referencing the type aliases
692
+ const routeTypes = [];
693
+ for (const [routePath, page] of fsMetadata.pages.entries()) {
694
+ const typeName = routeToTypeName(routePath);
695
+ routeTypes.push(` '${routePath}': ${typeName}`);
696
+ for (const epName of page.nestedEntryPoints.keys()) {
697
+ const nestedTypeName = routeToTypeName(routePath, epName);
698
+ routeTypes.push(` '${routePath}#${epName}': ${nestedTypeName}`);
699
+ }
700
+ }
701
+ // Phase 4: Generate helper type aliases for each route
702
+ const queryHelperAliases = [];
703
+ const entryPointHelperAliases = [];
704
+ const queryHelpersMapEntries = [];
705
+ const entryPointHelpersMapEntries = [];
706
+ for (const [routePath, page] of fsMetadata.pages.entries()) {
707
+ const typeName = routeToTypeName(routePath);
708
+ // Query helpers type
709
+ const queryHelpersType = buildQueryHelpersType(page.queries);
710
+ queryHelperAliases.push(`type QueryHelpers_${typeName} = ${queryHelpersType};`);
711
+ queryHelpersMapEntries.push(` '${routePath}': QueryHelpers_${typeName}`);
712
+ // Entry point helpers type
713
+ const entryPointHelpersType = buildEntryPointHelpersType(routePath, page.nestedEntryPoints);
714
+ entryPointHelperAliases.push(`type EntryPointHelpers_${typeName} = ${entryPointHelpersType};`);
715
+ entryPointHelpersMapEntries.push(` '${routePath}': EntryPointHelpers_${typeName}`);
716
+ }
717
+ // Generate import statements for query types
718
+ const queryImportStatements = Array.from(queryImports)
719
+ .map((queryTypeName) => `import type {${queryTypeName}} from '#genfiles/queries/${queryTypeName}.graphql';`)
720
+ .join('\n');
721
+ // Generate import statements for $variables types
722
+ const variablesImportStatements = Array.from(variablesImports)
723
+ .map((queryTypeName) => `import type {${queryTypeName}$variables} from '#genfiles/queries/${queryTypeName}.graphql';`)
724
+ .join('\n');
725
+ typesFile.addStatements(`/*
526
726
  * This file was generated by \`pastoria\`.
527
727
  * Do not modify this file directly.
728
+ *
729
+ * Type definitions for filesystem-based routing.
528
730
  */
529
731
 
530
- import {PastoriaRootContext} from 'pastoria-runtime/server';
732
+ import type {EntryPoint, EntryPointComponent, EntryPointProps} from 'react-relay/hooks';
733
+ ${queryImportStatements ? '\n' + queryImportStatements : ''}
734
+ ${variablesImportStatements ? '\n' + variablesImportStatements : ''}
735
+
736
+ // Route type aliases - nested entry points (leaf nodes)
737
+ ${nestedTypeAliases.join('\n')}
738
+
739
+ // Route type aliases - main pages
740
+ ${mainTypeAliases.join('\n')}
531
741
 
532
742
  /**
533
- * @gqlContext
743
+ * Map of route paths to their query types.
744
+ * Nested entry points use the format: '/route#entryPointName'
534
745
  */
535
- export class Context extends PastoriaRootContext {}
536
- `);
537
- logInfo('No @gqlContext found, generating default', pc.green('Context'));
538
- }
539
- await contextFile.save();
746
+ export interface PageQueryMap {
747
+ ${routeTypes.join(';\n')}${routeTypes.length > 0 ? ';' : ''}
540
748
  }
541
- async function generateServerHandler(project, metadata) {
542
- if (metadata.serverHandlers.size === 0) {
543
- await project
544
- .getSourceFile('__generated__/router/server_handler.ts')
545
- ?.deleteImmediately();
546
- return;
547
- }
548
- const sourceText = `import express from 'express';
549
- export const router = express.Router();
550
- `;
551
- const serverHandlerTemplate = project.createSourceFile('__generated__/router/server_handler.ts', sourceText, { overwrite: true });
552
- let serverHandlerImportIndex = 0;
553
- for (const [routeName, { symbol, sourceFile },] of metadata.serverHandlers.entries()) {
554
- const importAlias = `e${serverHandlerImportIndex++}`;
555
- const filePath = path.relative(process.cwd(), sourceFile.getFilePath());
556
- const moduleSpecifier = serverHandlerTemplate.getRelativePathAsModuleSpecifierTo(sourceFile.getFilePath());
557
- serverHandlerTemplate.addImportDeclaration({
558
- moduleSpecifier,
559
- namedImports: [{ name: symbol.getName(), alias: importAlias }],
560
- });
561
- serverHandlerTemplate.addStatements(`router.use('${routeName}', ${importAlias})`);
562
- logInfo('Created server handler', pc.cyan(routeName), 'for', pc.green(symbol.getName()), 'exported from', pc.yellow(filePath));
563
- }
564
- await serverHandlerTemplate.save();
749
+
750
+ // Query helper type aliases for each route
751
+ ${queryHelperAliases.join('\n')}
752
+
753
+ // Entry point helper type aliases for each route
754
+ ${entryPointHelperAliases.join('\n')}
755
+
756
+ /**
757
+ * Query helper functions for a route.
758
+ * Each helper takes typed variables and returns {parameters, variables} for Relay.
759
+ */
760
+ export interface QueryHelpersMap {
761
+ ${queryHelpersMapEntries.join(';\n')}${queryHelpersMapEntries.length > 0 ? ';' : ''}
762
+ }
763
+
764
+ export type QueryHelpersForRoute<R extends string> = R extends keyof QueryHelpersMap
765
+ ? QueryHelpersMap[R]
766
+ : {};
767
+
768
+ /**
769
+ * Entry point helper functions for a route.
770
+ * Each helper takes typed variables and returns the nested entry point configuration.
771
+ */
772
+ export interface EntryPointHelpersMap {
773
+ ${entryPointHelpersMapEntries.join(';\n')}${entryPointHelpersMapEntries.length > 0 ? ';' : ''}
774
+ }
775
+
776
+ export type EntryPointHelpersForRoute<R extends string> = R extends keyof EntryPointHelpersMap
777
+ ? EntryPointHelpersMap[R]
778
+ : {};
779
+
780
+ /**
781
+ * Props type for a page component at the given route.
782
+ * Uses EntryPointProps to transform raw query types to PreloadedQuery<...>.
783
+ *
784
+ * @example
785
+ * \`\`\`typescript
786
+ * // Main page
787
+ * export default function BlogPosts({ queries }: PageProps<'/posts'>) {
788
+ * const data = usePreloadedQuery(query, queries.posts);
789
+ * }
790
+ *
791
+ * // Nested entry point
792
+ * export default function Sidebar({ queries }: PageProps<'/posts#sidebar'>) {
793
+ * const data = usePreloadedQuery(query, queries.sidebarData);
794
+ * }
795
+ * \`\`\`
796
+ */
797
+ export type PageProps<R extends keyof PageQueryMap> = EntryPointProps<
798
+ PageQueryMap[R]['queries'],
799
+ PageQueryMap[R]['entryPoints'],
800
+ {},
801
+ {}
802
+ >;
803
+
804
+ /**
805
+ * Return type for getPreloadProps in entrypoint.ts files.
806
+ * This type matches the structure expected by Relay's loadEntryPoint.
807
+ *
808
+ * @example
809
+ * \`\`\`typescript
810
+ * export default function getPreloadProps({
811
+ * params,
812
+ * queries,
813
+ * entryPoints,
814
+ * }: EntryPointParams<'/posts'>): PreloadPropsForRoute<'/posts'> {
815
+ * return {
816
+ * queries: {
817
+ * postsQuery: queries.postsQuery(params),
818
+ * },
819
+ * entryPoints: {
820
+ * sidebar: entryPoints.sidebar({}),
821
+ * },
822
+ * };
823
+ * }
824
+ * \`\`\`
825
+ */
826
+ export type PreloadPropsForRoute<R extends keyof PageQueryMap> = {
827
+ queries: {
828
+ [K in keyof PageQueryMap[R]['queries']]: {
829
+ parameters: unknown;
830
+ variables: unknown;
831
+ };
832
+ };
833
+ entryPoints: PageQueryMap[R]['entryPoints'] extends Record<string, never>
834
+ ? undefined
835
+ : {
836
+ [K in keyof PageQueryMap[R]['entryPoints']]: {
837
+ entryPointParams: unknown;
838
+ entryPoint: unknown;
839
+ };
840
+ };
841
+ };
842
+ `);
843
+ await typesFile.save();
844
+ logInfo('Generated PageProps types for', pc.cyan(`${fsMetadata.pages.size} routes`));
565
845
  }
566
- export async function generatePastoriaExports(project) {
567
- const metadata = collectPastoriaMetadata(project);
568
- await generateAppRoot(project, metadata);
569
- await generateGraphqlContext(project, metadata);
570
- return metadata;
846
+ // ============================================================================
847
+ // Main Generation Functions
848
+ // ============================================================================
849
+ /**
850
+ * Generates all Pastoria artifacts from the filesystem.
851
+ *
852
+ * This is the main entry point for code generation.
853
+ */
854
+ export async function generatePastoriaArtifacts(project) {
855
+ // Scan the pastoria/ directory for routing files
856
+ const fsMetadata = scanFilesystemRoutes(project);
857
+ // Generate all artifacts
858
+ await Promise.all([
859
+ generateRouter(project, fsMetadata),
860
+ generateJsResource(project, fsMetadata),
861
+ generateServerHandler(project, fsMetadata),
862
+ generateTypes(project, fsMetadata),
863
+ ]);
864
+ return fsMetadata;
571
865
  }
572
- export async function generatePastoriaArtifacts(project, metadata = collectPastoriaMetadata(project)) {
573
- await generateRouter(project, metadata);
574
- await generateJsResource(project, metadata);
575
- await generateServerHandler(project, metadata);
866
+ /**
867
+ * Detects what capabilities are available based on source files.
868
+ */
869
+ export async function detectCapabilities() {
870
+ const fs = await import('node:fs/promises');
871
+ const hasAppRoot = await fs
872
+ .stat('pastoria/app.tsx')
873
+ .then(() => true)
874
+ .catch(() => false);
875
+ const hasServerHandler = await fs
876
+ .stat('__generated__/router/server_handler.ts')
877
+ .then(() => true)
878
+ .catch(() => false);
879
+ return { hasAppRoot, hasServerHandler };
576
880
  }
881
+ // ============================================================================
882
+ // Entry Point Generation (for Vite plugin)
883
+ // ============================================================================
884
+ /**
885
+ * Generates the client entry point code.
886
+ */
577
887
  export function generateClientEntry({ hasAppRoot, }) {
578
- const appImport = hasAppRoot
579
- ? `import {App} from '#genfiles/router/app_root';`
580
- : '';
888
+ const appImport = hasAppRoot ? `import App from './pastoria/app';` : '';
581
889
  const appValue = hasAppRoot ? 'App' : 'null';
582
890
  return `// Generated by Pastoria.
583
891
  import {createRouterApp} from '#genfiles/router/router';
@@ -592,10 +900,11 @@ async function main() {
592
900
  main();
593
901
  `;
594
902
  }
903
+ /**
904
+ * Generates the server entry point code.
905
+ */
595
906
  export function generateServerEntry({ hasAppRoot, hasServerHandler, }) {
596
- const appImport = hasAppRoot
597
- ? `import {App} from '#genfiles/router/app_root';`
598
- : '';
907
+ const appImport = hasAppRoot ? `import App from './pastoria/app';` : '';
599
908
  const appValue = hasAppRoot ? 'App' : 'null';
600
909
  const serverHandlerImport = hasServerHandler
601
910
  ? `import {router as serverHandler} from '#genfiles/router/server_handler';`
@@ -610,25 +919,15 @@ import {
610
919
  router__createAppFromEntryPoint,
611
920
  router__loadEntryPoint,
612
921
  } from '#genfiles/router/router';
613
- import {getSchema} from '#genfiles/schema/schema';
614
- import {Context} from '#genfiles/router/context';
922
+ import environment from './pastoria/environment';
615
923
  ${appImport}
616
924
  ${serverHandlerImport}
617
925
  import express from 'express';
618
- import {GraphQLSchema, specifiedDirectives} from 'graphql';
619
- import {PastoriaConfig} from 'pastoria-config';
620
926
  import {createRouterHandler} from 'pastoria-runtime/server';
621
927
  import type {Manifest} from 'vite';
622
928
 
623
- const schemaConfig = getSchema().toConfig();
624
- const schema = new GraphQLSchema({
625
- ...schemaConfig,
626
- directives: [...specifiedDirectives, ...schemaConfig.directives],
627
- });
628
-
629
929
  export function createHandler(
630
930
  persistedQueries: Record<string, string>,
631
- config: Required<PastoriaConfig>,
632
931
  manifest?: Manifest,
633
932
  ) {
634
933
  const routeHandler = createRouterHandler(
@@ -637,10 +936,8 @@ export function createHandler(
637
936
  router__loadEntryPoint,
638
937
  router__createAppFromEntryPoint,
639
938
  ${appValue},
640
- schema,
641
- (req) => Context.createFromRequest(req),
939
+ environment,
642
940
  persistedQueries,
643
- config,
644
941
  manifest,
645
942
  );
646
943