pastoria 1.0.11 → 1.0.13

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/src/generate.ts CHANGED
@@ -31,15 +31,14 @@ import * as path from 'node:path';
31
31
  import pc from 'picocolors';
32
32
  import {
33
33
  CodeBlockWriter,
34
- IndentationText,
35
34
  Project,
36
35
  SourceFile,
37
36
  Symbol,
38
37
  SyntaxKind,
39
38
  ts,
40
39
  TypeFlags,
41
- WriterFunction,
42
40
  } from 'ts-morph';
41
+ import {logInfo, logWarn} from './logger.js';
43
42
 
44
43
  async function loadRouterTemplates(project: Project, filename: string) {
45
44
  async function loadSourceFile(fileName: string, templateFileName: string) {
@@ -78,7 +77,7 @@ type ExportedSymbol = {
78
77
  symbol: Symbol;
79
78
  };
80
79
 
81
- interface PastoriaMetadata {
80
+ export interface PastoriaMetadata {
82
81
  resources: Map<string, RouterResource>;
83
82
  routes: Map<string, RouterRoute>;
84
83
  serverHandlers: Map<string, ExportedSymbol>;
@@ -87,9 +86,39 @@ interface PastoriaMetadata {
87
86
  }
88
87
 
89
88
  // Regex to quickly check if a file contains any Pastoria JSDoc tags
90
- const PASTORIA_TAG_REGEX =
89
+ export const PASTORIA_TAG_REGEX =
91
90
  /@(route|resource|appRoot|param|gqlContext|serverRoute)\b/;
92
91
 
92
+ function collectQueryParameters(
93
+ project: Project,
94
+ queries: string[],
95
+ ): Map<string, ts.Type> {
96
+ const vars = new Map<string, ts.Type>();
97
+
98
+ for (const query of queries) {
99
+ const variablesType = project
100
+ .getSourceFile(`__generated__/queries/${query}.graphql.ts`)
101
+ ?.getExportedDeclarations()
102
+ .get(`${query}$variables`)
103
+ ?.at(0)
104
+ ?.getType();
105
+
106
+ if (variablesType == null) continue;
107
+
108
+ for (const property of variablesType.getProperties()) {
109
+ // TODO: Detect conflicting types among properties declared.
110
+ const propertyName = property.getName();
111
+ const propertyType = property.getValueDeclaration()?.getType();
112
+
113
+ if (propertyType) {
114
+ vars.set(propertyName, propertyType.compilerType);
115
+ }
116
+ }
117
+ }
118
+
119
+ return vars;
120
+ }
121
+
93
122
  function collectPastoriaMetadata(project: Project): PastoriaMetadata {
94
123
  const resources = new Map<string, RouterResource>();
95
124
  const routes = new Map<string, RouterRoute>();
@@ -152,36 +181,13 @@ function collectPastoriaMetadata(project: Project): PastoriaMetadata {
152
181
  serverHandlers.set(tag.comment, {sourceFile, symbol});
153
182
  break;
154
183
  }
155
- // case 'query': {
156
- // const match = tag.comment.match(
157
- // /^\s*\{\s*(?<query>\w+)\s*\}\s+(?<name>\w+)\s*$/,
158
- // )?.groups;
159
-
160
- // if (match && match.query && match.name) {
161
- // resourceQueries.set(match.name, match.query);
162
- // }
163
- // break;
164
- // }
165
- // case 'entrypoint': {
166
- // const match = tag.comment.match(
167
- // /^\s*\{\s*(?<resource>[\w#]+)\s*\}\s+(?<name>\w+)\s*$/,
168
- // )?.groups;
169
-
170
- // if (match && match.resource && match.name) {
171
- // resourceEntryPoints.set(match.name, match.resource);
172
- // }
173
- // break;
174
- // }
175
184
  }
176
185
  } else {
177
186
  // Handle tags without comments (like @ExportedSymbol, @gqlContext)
178
187
  switch (tag.tagName.getText()) {
179
188
  case 'appRoot': {
180
189
  if (appRoot != null) {
181
- console.warn(
182
- pc.yellow('Warning:'),
183
- 'Multiple @appRoot tags found. Using the first one.',
184
- );
190
+ logWarn('Multiple @appRoot tags found. Using the first one.');
185
191
  } else {
186
192
  appRoot = {
187
193
  sourceFile,
@@ -215,8 +221,7 @@ function collectPastoriaMetadata(project: Project): PastoriaMetadata {
215
221
 
216
222
  if (extendsPastoriaRootContext) {
217
223
  if (gqlContext != null) {
218
- console.warn(
219
- pc.yellow('Warning:'),
224
+ logWarn(
220
225
  'Multiple classes with @gqlContext extending PastoriaRootContext found. Using the first one.',
221
226
  );
222
227
  } else {
@@ -328,35 +333,55 @@ function getResourceQueriesAndEntryPoints(symbol: Symbol): {
328
333
  return resource;
329
334
  }
330
335
 
331
- function zodSchemaOfType(tc: ts.TypeChecker, t: ts.Type): string {
332
- if (t.getFlags() & TypeFlags.String) {
336
+ function zodSchemaOfType(
337
+ sf: SourceFile,
338
+ tc: ts.TypeChecker,
339
+ t: ts.Type,
340
+ ): string {
341
+ if (t.aliasSymbol) {
342
+ const decl = t.aliasSymbol.declarations?.at(0);
343
+ if (decl == null) {
344
+ logWarn('Could not handle type:', tc.typeToString(t));
345
+ return `z.any()`;
346
+ } else {
347
+ const importPath = sf.getRelativePathAsModuleSpecifierTo(
348
+ decl.getSourceFile().fileName,
349
+ );
350
+
351
+ return `z.transform((s: string) => s as import('${importPath}').${t.aliasSymbol.getName()})`;
352
+ }
353
+ } else if (t.getFlags() & TypeFlags.String) {
333
354
  return `z.pipe(z.string(), z.transform(decodeURIComponent))`;
334
355
  } else if (t.getFlags() & TypeFlags.Number) {
335
356
  return `z.coerce.number<number>()`;
336
357
  } else if (t.getFlags() & TypeFlags.Null) {
337
358
  return `z.preprocess(s => s == null ? undefined : s, z.undefined())`;
338
359
  } else if (t.isUnion()) {
339
- const isRepresentingOptional =
340
- t.types.length === 2 &&
341
- t.types.some((s) => s.getFlags() & TypeFlags.Null);
342
-
343
- if (isRepresentingOptional) {
344
- const nonOptionalType = t.types.find(
345
- (s) => !(s.getFlags() & TypeFlags.Null),
346
- )!;
360
+ const nullishTypes: ts.Type[] = [];
361
+ const nonNullishTypes: ts.Type[] = [];
362
+ for (const s of t.types) {
363
+ const flags = s.getFlags();
364
+ if (flags & TypeFlags.Null || flags & TypeFlags.Undefined) {
365
+ nullishTypes.push(s);
366
+ } else {
367
+ nonNullishTypes.push(s);
368
+ }
369
+ }
347
370
 
348
- return `z.pipe(z.nullish(${zodSchemaOfType(tc, nonOptionalType)}), z.transform(s => s == null ? undefined : s))`;
371
+ if (nullishTypes.length > 0 && nonNullishTypes.length > 0) {
372
+ const nonOptionalType = t.getNonNullableType();
373
+ return `z.pipe(z.nullish(${zodSchemaOfType(sf, tc, nonOptionalType)}), z.transform(s => s == null ? undefined : s))`;
349
374
  } else {
350
- return `z.union([${t.types.map((it) => zodSchemaOfType(tc, it)).join(', ')}])`;
375
+ return `z.union([${t.types.map((it) => zodSchemaOfType(sf, tc, it)).join(', ')}])`;
351
376
  }
352
377
  } else if (tc.isArrayLikeType(t)) {
353
378
  const typeArg = tc.getTypeArguments(t as ts.TypeReference)[0];
354
379
  const argZodSchema =
355
- typeArg == null ? `z.any()` : zodSchemaOfType(tc, typeArg);
380
+ typeArg == null ? `z.any()` : zodSchemaOfType(sf, tc, typeArg);
356
381
 
357
382
  return `z.array(${argZodSchema})`;
358
383
  } else {
359
- console.log('Could not handle type:', tc.typeToString(t));
384
+ logWarn('Could not handle type:', tc.typeToString(t));
360
385
  return `z.any()`;
361
386
  }
362
387
  }
@@ -435,7 +460,7 @@ async function generateRouter(project: Project, metadata: PastoriaMetadata) {
435
460
  routerConf.getPropertyOrThrow('noop').remove();
436
461
 
437
462
  let entryPointImportIndex = 0;
438
- for (const [
463
+ for (let [
439
464
  routeName,
440
465
  {sourceFile, symbol, params},
441
466
  ] of metadata.routes.entries()) {
@@ -477,6 +502,10 @@ async function generateRouter(project: Project, metadata: PastoriaMetadata) {
477
502
  },
478
503
  });
479
504
 
505
+ if (params.size === 0 && consumedQueries.size > 0) {
506
+ params = collectQueryParameters(project, Array.from(consumedQueries));
507
+ }
508
+
480
509
  for (const query of consumedQueries) {
481
510
  routerTemplate.addImportDeclaration({
482
511
  moduleSpecifier: `#genfiles/queries/${query}$parameters`,
@@ -517,7 +546,7 @@ async function generateRouter(project: Project, metadata: PastoriaMetadata) {
517
546
  writer.writeLine(`schema: z.object({`);
518
547
  for (const [paramName, paramType] of Array.from(params)) {
519
548
  writer.writeLine(
520
- ` ${paramName}: ${zodSchemaOfType(tc, paramType)},`,
549
+ ` ${paramName}: ${zodSchemaOfType(routerTemplate, tc, paramType)},`,
521
550
  );
522
551
  }
523
552
 
@@ -528,7 +557,7 @@ async function generateRouter(project: Project, metadata: PastoriaMetadata) {
528
557
  },
529
558
  });
530
559
 
531
- console.log(
560
+ logInfo(
532
561
  'Created route',
533
562
  pc.cyan(routeName),
534
563
  'for',
@@ -579,7 +608,7 @@ async function generateJsResource(
579
608
  },
580
609
  });
581
610
 
582
- console.log(
611
+ logInfo(
583
612
  'Created resource',
584
613
  pc.cyan(resourceName),
585
614
  'for',
@@ -627,7 +656,7 @@ export {${appRootSymbol.getName()} as App} from '${moduleSpecifier}';
627
656
 
628
657
  await appRootFile.save();
629
658
 
630
- console.log(
659
+ logInfo(
631
660
  'Created app root for',
632
661
  pc.green(appRootSymbol.getName()),
633
662
  'exported from',
@@ -663,7 +692,7 @@ async function generateGraphqlContext(
663
692
  export {${contextSymbol.getName()} as Context} from '${moduleSpecifier}';
664
693
  `);
665
694
 
666
- console.log(
695
+ logInfo(
667
696
  'Created GraphQL context for',
668
697
  pc.green(contextSymbol.getName()),
669
698
  'exported from',
@@ -683,10 +712,7 @@ import {PastoriaRootContext} from 'pastoria-runtime/server';
683
712
  export class Context extends PastoriaRootContext {}
684
713
  `);
685
714
 
686
- console.log(
687
- 'No @gqlContext found, generating default',
688
- pc.green('Context'),
689
- );
715
+ logInfo('No @gqlContext found, generating default', pc.green('Context'));
690
716
  }
691
717
 
692
718
  await contextFile.save();
@@ -735,7 +761,7 @@ export const router = express.Router();
735
761
  `router.use('${routeName}', ${importAlias})`,
736
762
  );
737
763
 
738
- console.log(
764
+ logInfo(
739
765
  'Created server handler',
740
766
  pc.cyan(routeName),
741
767
  'for',
@@ -748,20 +774,112 @@ export const router = express.Router();
748
774
  await serverHandlerTemplate.save();
749
775
  }
750
776
 
751
- export async function generatePastoriaArtifacts() {
752
- const targetDir = process.cwd();
753
- const project = new Project({
754
- tsConfigFilePath: path.join(targetDir, 'tsconfig.json'),
755
- manipulationSettings: {
756
- indentationText: IndentationText.TwoSpaces,
757
- },
758
- });
759
-
777
+ export async function generatePastoriaExports(project: Project) {
760
778
  const metadata = collectPastoriaMetadata(project);
761
779
 
762
780
  await generateAppRoot(project, metadata);
763
781
  await generateGraphqlContext(project, metadata);
782
+ return metadata;
783
+ }
784
+
785
+ export async function generatePastoriaArtifacts(
786
+ project: Project,
787
+ metadata = collectPastoriaMetadata(project),
788
+ ) {
764
789
  await generateRouter(project, metadata);
765
790
  await generateJsResource(project, metadata);
766
791
  await generateServerHandler(project, metadata);
767
792
  }
793
+
794
+ export interface PastoriaCapabilities {
795
+ hasAppRoot: boolean;
796
+ hasServerHandler: boolean;
797
+ }
798
+
799
+ export function generateClientEntry({
800
+ hasAppRoot,
801
+ }: PastoriaCapabilities): string {
802
+ const appImport = hasAppRoot
803
+ ? `import {App} from '#genfiles/router/app_root';`
804
+ : '';
805
+ const appValue = hasAppRoot ? 'App' : 'null';
806
+
807
+ return `// Generated by Pastoria.
808
+ import {createRouterApp} from '#genfiles/router/router';
809
+ ${appImport}
810
+ import {hydrateRoot} from 'react-dom/client';
811
+
812
+ async function main() {
813
+ const RouterApp = await createRouterApp();
814
+ hydrateRoot(document, <RouterApp App={${appValue}} />);
815
+ }
816
+
817
+ main();
818
+ `;
819
+ }
820
+
821
+ export function generateServerEntry({
822
+ hasAppRoot,
823
+ hasServerHandler,
824
+ }: PastoriaCapabilities): string {
825
+ const appImport = hasAppRoot
826
+ ? `import {App} from '#genfiles/router/app_root';`
827
+ : '';
828
+ const appValue = hasAppRoot ? 'App' : 'null';
829
+
830
+ const serverHandlerImport = hasServerHandler
831
+ ? `import {router as serverHandler} from '#genfiles/router/server_handler';`
832
+ : '';
833
+ const serverHandlerUse = hasServerHandler
834
+ ? ' router.use(serverHandler)'
835
+ : '';
836
+
837
+ return `// Generated by Pastoria.
838
+ import {JSResource} from '#genfiles/router/js_resource';
839
+ import {
840
+ listRoutes,
841
+ router__createAppFromEntryPoint,
842
+ router__loadEntryPoint,
843
+ } from '#genfiles/router/router';
844
+ import {getSchema} from '#genfiles/schema/schema';
845
+ import {Context} from '#genfiles/router/context';
846
+ ${appImport}
847
+ ${serverHandlerImport}
848
+ import express from 'express';
849
+ import {GraphQLSchema, specifiedDirectives} from 'graphql';
850
+ import {PastoriaConfig} from 'pastoria-config';
851
+ import {createRouterHandler} from 'pastoria-runtime/server';
852
+ import type {Manifest} from 'vite';
853
+
854
+ const schemaConfig = getSchema().toConfig();
855
+ const schema = new GraphQLSchema({
856
+ ...schemaConfig,
857
+ directives: [...specifiedDirectives, ...schemaConfig.directives],
858
+ });
859
+
860
+ export function createHandler(
861
+ persistedQueries: Record<string, string>,
862
+ config: Required<PastoriaConfig>,
863
+ manifest?: Manifest,
864
+ ) {
865
+ const routeHandler = createRouterHandler(
866
+ listRoutes(),
867
+ JSResource.srcOfModuleId,
868
+ router__loadEntryPoint,
869
+ router__createAppFromEntryPoint,
870
+ ${appValue},
871
+ schema,
872
+ (req) => Context.createFromRequest(req),
873
+ persistedQueries,
874
+ config,
875
+ manifest,
876
+ );
877
+
878
+ const router = express.Router();
879
+ router.use(routeHandler);
880
+ ${serverHandlerUse}
881
+
882
+ return router;
883
+ }
884
+ `;
885
+ }
package/src/index.ts CHANGED
@@ -5,7 +5,6 @@ import {readFile} from 'node:fs/promises';
5
5
  import * as path from 'node:path';
6
6
  import {createBuild} from './build.js';
7
7
  import {startDevserver} from './devserver.js';
8
- import {generatePastoriaArtifacts} from './generate.js';
9
8
 
10
9
  async function main() {
11
10
  const packageData = JSON.parse(
@@ -17,11 +16,6 @@ async function main() {
17
16
  .description(packageData.description)
18
17
  .version(packageData.version);
19
18
 
20
- program
21
- .command('gen')
22
- .description('Run Pastoria code generation')
23
- .action(generatePastoriaArtifacts);
24
-
25
19
  program
26
20
  .command('dev')
27
21
  .description('Start the pastoria devserver')
@@ -29,8 +23,15 @@ async function main() {
29
23
  .action(startDevserver);
30
24
 
31
25
  program
32
- .command('build')
26
+ .command('make')
33
27
  .description('Creates a production build of the project')
28
+ .argument(
29
+ '[steps...]',
30
+ 'Specific build steps to run (schema, relay, router). If not provided, will infer from changed files.',
31
+ )
32
+ .option('-B, --always-make', 'Always make, never cache')
33
+ .option('-R, --release', 'Build for production')
34
+ .option('-w, --watch', 'Watch for changes and rebuild')
34
35
  .action(createBuild);
35
36
 
36
37
  program.parseAsync();
package/src/logger.ts ADDED
@@ -0,0 +1,12 @@
1
+ import {createLogger} from 'vite';
2
+ import pc from 'picocolors';
3
+
4
+ export const logger = createLogger('info', {prefix: pc.greenBright('[MAKE]')});
5
+
6
+ export function logInfo(...messages: string[]) {
7
+ logger.info(messages.join(' '), {timestamp: true});
8
+ }
9
+
10
+ export function logWarn(...messages: string[]) {
11
+ logger.warn(messages.join(' '), {timestamp: true});
12
+ }
@@ -0,0 +1,109 @@
1
+ import tailwindcss from '@tailwindcss/vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import {access} from 'node:fs/promises';
4
+ import {
5
+ InlineConfig,
6
+ PluginOption,
7
+ type BuildEnvironmentOptions,
8
+ type Plugin,
9
+ } from 'vite';
10
+ import {cjsInterop} from 'vite-plugin-cjs-interop';
11
+ import {
12
+ generateClientEntry,
13
+ generateServerEntry,
14
+ PastoriaCapabilities,
15
+ } from './generate.js';
16
+ import {logger} from './logger.js';
17
+
18
+ async function determineCapabilities(): Promise<PastoriaCapabilities> {
19
+ const capabilities: PastoriaCapabilities = {
20
+ hasAppRoot: false,
21
+ hasServerHandler: false,
22
+ };
23
+
24
+ async function hasAppRoot() {
25
+ try {
26
+ await access('__generated__/router/app_root.ts');
27
+ capabilities.hasAppRoot = true;
28
+ } catch {}
29
+ }
30
+
31
+ async function hasServerHandler() {
32
+ try {
33
+ await access('__generated__/router/server_handler.ts');
34
+ capabilities.hasServerHandler = true;
35
+ } catch {}
36
+ }
37
+
38
+ await Promise.all([hasAppRoot(), hasServerHandler()]);
39
+ return capabilities;
40
+ }
41
+
42
+ function pastoriaEntryPlugin(): Plugin {
43
+ const clientEntryModuleId = 'virtual:pastoria-entry-client.tsx';
44
+ const serverEntryModuleId = 'virtual:pastoria-entry-server.tsx';
45
+
46
+ return {
47
+ name: 'pastoria-entry',
48
+ resolveId(id) {
49
+ if (id === clientEntryModuleId) {
50
+ return clientEntryModuleId; // Return without \0 prefix so React plugin can see .tsx extension
51
+ } else if (id === serverEntryModuleId) {
52
+ return serverEntryModuleId;
53
+ }
54
+ },
55
+ async load(id) {
56
+ const capabilities = await determineCapabilities();
57
+ if (id === clientEntryModuleId) {
58
+ return generateClientEntry(capabilities);
59
+ } else if (id === serverEntryModuleId) {
60
+ return generateServerEntry(capabilities);
61
+ }
62
+ },
63
+ };
64
+ }
65
+
66
+ export const CLIENT_BUILD: BuildEnvironmentOptions = {
67
+ outDir: 'dist/client',
68
+ rollupOptions: {
69
+ input: 'virtual:pastoria-entry-client.tsx',
70
+ },
71
+ };
72
+
73
+ export const SERVER_BUILD: BuildEnvironmentOptions = {
74
+ outDir: 'dist/server',
75
+ ssr: true,
76
+ rollupOptions: {
77
+ input: 'virtual:pastoria-entry-server.tsx',
78
+ },
79
+ };
80
+
81
+ export function createBuildConfig(
82
+ buildEnv: BuildEnvironmentOptions,
83
+ ): InlineConfig {
84
+ return {
85
+ appType: 'custom' as const,
86
+ customLogger: logger,
87
+ build: {
88
+ ...buildEnv,
89
+ assetsInlineLimit: 0,
90
+ manifest: true,
91
+ ssrManifest: true,
92
+ },
93
+ plugins: [
94
+ pastoriaEntryPlugin(),
95
+ tailwindcss() as PluginOption,
96
+ react({
97
+ babel: {
98
+ plugins: [['babel-plugin-react-compiler', {}], 'relay'],
99
+ },
100
+ }),
101
+ cjsInterop({
102
+ dependencies: ['react-relay', 'react-relay/hooks', 'relay-runtime'],
103
+ }),
104
+ ],
105
+ ssr: {
106
+ noExternal: ['pastoria-runtime'],
107
+ },
108
+ };
109
+ }
@@ -245,20 +245,7 @@ export function router__createAppFromEntryPoint(
245
245
 
246
246
  return (
247
247
  <RouterContext value={routerContextValue}>
248
- {'fallback' in entryPoint.entryPoints ? (
249
- <Suspense
250
- fallback={
251
- <EntryPointContainer
252
- entryPointReference={entryPoint.entryPoints.fallback}
253
- props={{}}
254
- />
255
- }
256
- >
257
- <EntryPointContainer entryPointReference={entryPoint} props={{}} />
258
- </Suspense>
259
- ) : (
260
- <EntryPointContainer entryPointReference={entryPoint} props={{}} />
261
- )}
248
+ <EntryPointContainer entryPointReference={entryPoint} props={{}} />
262
249
  </RouterContext>
263
250
  );
264
251
  }
package/tsconfig.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "compilerOptions": {
3
3
  "target": "ES2022",
4
4
  "module": "nodenext",
5
- "lib": ["ES2022", "DOM"],
5
+ "lib": ["ES2022", "DOM", "ESNext.Collection"],
6
6
  "moduleResolution": "nodenext",
7
7
  "outDir": "./dist",
8
8
  "rootDir": "./src",