.
103 | const functionBodyPath = functionReturn.parentPath;
104 | if (!functionBodyPath.isBlockStatement()) {
105 | throw new Error("Expected parentPath to be a block statement.");
106 | }
107 |
108 | const functionPath = functionBodyPath.parentPath;
109 | if (!functionPath.isFunctionDeclaration() || !t.isFunctionDeclaration(functionPath.node)) {
110 | throw new Error("Expected parentPath to be a function declaration.");
111 | }
112 |
113 | const appPropsArg = functionPath.node.params[0];
114 |
115 | if (!appPropsArg) {
116 | throw new Error("Expected function to have one argument.");
117 | }
118 |
119 | const genericAppProps = t.genericTypeAnnotation(
120 | t.identifier(APP_PROPS),
121 | t.typeParameterInstantiation([t.genericTypeAnnotation(t.identifier(RELAY_PAGE_PROPS))])
122 | );
123 |
124 | appPropsArg.typeAnnotation = t.typeAnnotation(genericAppProps);
125 | }
126 |
127 | insertNamedImports(path, ["useMemo", "useEffect"], "react");
128 | insertNamedImport(path, "RecordSource", RELAY_RUNTIME_PACKAGE);
129 |
130 | const relayEnvImportPath = new RelativePath(
131 | mainFile!.parentDirectory,
132 | removeExtension(this.context.relayEnvFile.abs)
133 | );
134 |
135 | insertNamedImport(path, "initRelayEnvironment", relayEnvImportPath.rel);
136 |
137 | functionReturn.addComment("leading", "--MARKER", true);
138 |
139 | const envProviderId = t.jsxIdentifier(insertNamedImport(path, RELAY_ENV_PROVIDER, REACT_RELAY_PACKAGE).name);
140 |
141 | wrapJsxInRelayProvider(path, envProviderId, t.identifier("environment"));
142 |
143 | providerWrapped = true;
144 |
145 | path.skip();
146 | },
147 | });
148 |
149 | if (!providerWrapped) {
150 | throw new Error("Could not find JSX");
151 | }
152 |
153 | let updatedCode = astToString(ast, code);
154 |
155 | updatedCode = updatedCode.replace("//--MARKER", envCreationAndHydration);
156 | updatedCode = prettifyCode(updatedCode);
157 |
158 | await this.context.fs.writeToFile(mainFile.abs, updatedCode);
159 | }
160 | }
161 |
--------------------------------------------------------------------------------
/src/tasks/next/Next_AddTypeHelpers.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import { NEXT_SRC_PATH } from "../../consts.js";
3 | import { ProjectContext } from "../../misc/ProjectContext.js";
4 | import { RelativePath } from "../../misc/RelativePath.js";
5 | import { prettifyCode } from "../../utils/ast.js";
6 | import { bold } from "../../utils/cli.js";
7 | import { TaskBase } from "../TaskBase.js";
8 |
9 | const code = `
10 | import type { GetServerSideProps, GetStaticProps, PreviewData } from "next";
11 | import type { ParsedUrlQuery } from "querystring";
12 | import type { RecordMap } from "relay-runtime/lib/store/RelayStoreTypes";
13 |
14 | export type RelayPageProps = {
15 | initialRecords?: RecordMap;
16 | };
17 |
18 | export type GetRelayServerSideProps<
19 | P extends { [key: string]: any } = { [key: string]: any },
20 | Q extends ParsedUrlQuery = ParsedUrlQuery,
21 | D extends PreviewData = PreviewData
22 | > = GetServerSideProps, Q, D>;
23 |
24 | export type GetRelayStaticProps<
25 | P extends { [key: string]: any } = { [key: string]: any },
26 | Q extends ParsedUrlQuery = ParsedUrlQuery,
27 | D extends PreviewData = PreviewData
28 | > = GetStaticProps
, Q, D>;
29 | `;
30 |
31 | export class Next_AddTypeHelpers extends TaskBase {
32 | message = "Add type helpers";
33 |
34 | constructor(private context: ProjectContext) {
35 | super();
36 | }
37 |
38 | isEnabled(): boolean {
39 | return this.context.is("next") && this.context.args.typescript;
40 | }
41 |
42 | async run(): Promise {
43 | const filepath = Next_AddTypeHelpers.getRelayTypesPath(this.context);
44 |
45 | this.updateMessage(this.message + " " + bold(filepath.rel));
46 |
47 | if (this.context.fs.exists(filepath.abs)) {
48 | this.skip("File exists");
49 | return;
50 | }
51 |
52 | const prettifiedCode = prettifyCode(code);
53 |
54 | await this.context.fs.writeToFile(filepath.abs, prettifiedCode);
55 | }
56 |
57 | static getRelayTypesPath(context: ProjectContext): RelativePath {
58 | const filepath = path.join(NEXT_SRC_PATH, "relay-types.ts");
59 |
60 | return context.env.rel(filepath);
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/tasks/next/Next_ConfigureNextCompilerTask.ts:
--------------------------------------------------------------------------------
1 | import traverse from "@babel/traverse";
2 | import t from "@babel/types";
3 | import { ProjectContext } from "../../misc/ProjectContext.js";
4 | import { parseAst, printAst, mergeProperties } from "../../utils/ast.js";
5 | import { bold } from "../../utils/cli.js";
6 | import { TaskBase } from "../TaskBase.js";
7 |
8 | export class Next_ConfigureNextCompilerTask extends TaskBase {
9 | message: string = `Configure Next.js compiler`;
10 |
11 | constructor(private context: ProjectContext) {
12 | super();
13 | }
14 |
15 | isEnabled(): boolean {
16 | return this.context.is("next");
17 | }
18 |
19 | async run(): Promise {
20 | const configFilename = "next.config.js";
21 |
22 | const configFile = this.context.env.rel(configFilename);
23 |
24 | this.updateMessage(this.message + " in " + bold(configFile.rel));
25 |
26 | const configCode = await this.context.fs.readFromFile(configFile.abs);
27 |
28 | const ast = parseAst(configCode);
29 |
30 | let configured = false;
31 |
32 | traverse.default(ast, {
33 | AssignmentExpression: (path) => {
34 | if (configured) {
35 | return;
36 | }
37 |
38 | const node = path.node;
39 |
40 | // We are looking for module.exports = ???.
41 | if (
42 | node.operator !== "=" ||
43 | !t.isMemberExpression(node.left) ||
44 | !t.isIdentifier(node.left.object) ||
45 | !t.isIdentifier(node.left.property) ||
46 | node.left.object.name !== "module" ||
47 | node.left.property.name !== "exports"
48 | ) {
49 | return;
50 | }
51 |
52 | let objExp: t.ObjectExpression;
53 |
54 | // We are looking for the object expression
55 | // that was assigned to module.exports.
56 | if (t.isIdentifier(node.right)) {
57 | // The export is linked to a variable,
58 | // so we need to resolve the variable declaration.
59 | const binding = path.scope.getBinding(node.right.name);
60 |
61 | if (!binding || !t.isVariableDeclarator(binding.path.node) || !t.isObjectExpression(binding.path.node.init)) {
62 | throw new Error("`module.exports` references a variable, but the variable is not an object.");
63 | }
64 |
65 | objExp = binding.path.node.init;
66 | } else if (t.isObjectExpression(node.right)) {
67 | objExp = node.right;
68 | } else {
69 | throw new Error("Expected to find an object initializer or variable assigned to `module.exports`.");
70 | }
71 |
72 | // We are creating or getting the 'compiler' property.
73 | let compiler_Prop = objExp.properties.find(
74 | (p) => t.isObjectProperty(p) && t.isIdentifier(p.key) && p.key.name === "compiler"
75 | ) as t.ObjectProperty;
76 |
77 | if (!compiler_Prop) {
78 | compiler_Prop = t.objectProperty(t.identifier("compiler"), t.objectExpression([]));
79 |
80 | objExp.properties.push(compiler_Prop);
81 | }
82 |
83 | if (!t.isObjectExpression(compiler_Prop.value)) {
84 | throw new Error("Expected the `compiler` property to be an object.");
85 | }
86 |
87 | let relay_ObjProps: t.ObjectProperty[] = [
88 | t.objectProperty(t.identifier("src"), t.stringLiteral(this.context.srcPath.rel)),
89 | t.objectProperty(t.identifier("language"), t.stringLiteral(this.context.compilerLanguage)),
90 | ];
91 |
92 | if (this.context.artifactPath) {
93 | relay_ObjProps.push(
94 | t.objectProperty(t.identifier("artifactDirectory"), t.stringLiteral(this.context.artifactPath.rel))
95 | );
96 | }
97 |
98 | const compiler_relayProp = compiler_Prop.value.properties.find(
99 | (p) => t.isObjectProperty(p) && t.isIdentifier(p.key) && p.key.name === "relay"
100 | ) as t.ObjectProperty;
101 |
102 | if (compiler_relayProp && t.isObjectExpression(compiler_relayProp.value)) {
103 | // We already have a "relay" property, so we merge its properties,
104 | // with the new ones.
105 | compiler_relayProp.value = t.objectExpression(
106 | mergeProperties(compiler_relayProp.value.properties, relay_ObjProps)
107 | );
108 | } else {
109 | // We do not yet have a "relay" propery, so we add it.
110 | compiler_Prop.value.properties.push(
111 | t.objectProperty(t.identifier("relay"), t.objectExpression(relay_ObjProps))
112 | );
113 | }
114 |
115 | path.skip();
116 | },
117 | });
118 |
119 | const updatedConfigCode = printAst(ast, configCode);
120 |
121 | await this.context.fs.writeToFile(configFile.abs, updatedConfigCode);
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/src/tasks/vite/Vite_AddRelayEnvironmentProvider.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import { RELAY_ENV_PROVIDER } from "../../consts.js";
3 | import { ProjectContext } from "../../misc/ProjectContext.js";
4 | import { parseAst, printAst } from "../../utils/ast.js";
5 | import { bold } from "../../utils/cli.js";
6 | import { TaskBase } from "../TaskBase.js";
7 | import { configureRelayProviderInReactDomRender } from "../cra/Cra_AddRelayEnvironmentProvider.js";
8 |
9 | export class Vite_AddRelayEnvironmentProvider extends TaskBase {
10 | message: string = "Add " + RELAY_ENV_PROVIDER;
11 |
12 | constructor(private context: ProjectContext) {
13 | super();
14 | }
15 |
16 | isEnabled(): boolean {
17 | return this.context.is("vite");
18 | }
19 |
20 | async run(): Promise {
21 | const mainFilename = "main" + (this.context.args.typescript ? ".tsx" : ".jsx");
22 |
23 | const mainFile = this.context.env.rel(path.join("src", mainFilename));
24 |
25 | this.updateMessage(this.message + " in " + bold(mainFile.rel));
26 |
27 | const code = await this.context.fs.readFromFile(mainFile.abs);
28 |
29 | const ast = parseAst(code);
30 |
31 | configureRelayProviderInReactDomRender(ast, mainFile, this.context.relayEnvFile);
32 |
33 | const updatedCode = printAst(ast, code);
34 |
35 | await this.context.fs.writeToFile(mainFile.abs, updatedCode);
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/tasks/vite/Vite_ConfigureVitePluginRelayTask.ts:
--------------------------------------------------------------------------------
1 | import traverse from "@babel/traverse";
2 | import t from "@babel/types";
3 | import { VITE_RELAY_PACKAGE } from "../../consts.js";
4 | import { ProjectContext } from "../../misc/ProjectContext.js";
5 | import { parseAst, insertDefaultImport, printAst } from "../../utils/ast.js";
6 | import { bold } from "../../utils/cli.js";
7 | import { TaskBase } from "../TaskBase.js";
8 |
9 | export class Vite_ConfigureVitePluginRelayTask extends TaskBase {
10 | message: string = `Configure ${bold(VITE_RELAY_PACKAGE)}`;
11 |
12 | constructor(private context: ProjectContext) {
13 | super();
14 | }
15 |
16 | isEnabled(): boolean {
17 | return this.context.is("vite");
18 | }
19 |
20 | async run(): Promise {
21 | const configFilename = "vite.config" + (this.context.args.typescript ? ".ts" : ".js");
22 |
23 | const configFile = this.context.env.rel(configFilename);
24 |
25 | this.updateMessage(this.message + " in " + bold(configFile.rel));
26 |
27 | const configCode = await this.context.fs.readFromFile(configFile.abs);
28 |
29 | const ast = parseAst(configCode);
30 |
31 | traverse.default(ast, {
32 | ExportDefaultDeclaration: (path) => {
33 | const relayImportId = insertDefaultImport(path, "relay", VITE_RELAY_PACKAGE);
34 |
35 | const node = path.node;
36 |
37 | // Find export default defineConfig(???)
38 | if (
39 | !t.isCallExpression(node.declaration) ||
40 | node.declaration.arguments.length < 1 ||
41 | !t.isIdentifier(node.declaration.callee) ||
42 | node.declaration.callee.name !== "defineConfig"
43 | ) {
44 | throw new Error("Expected `export default defineConfig()`");
45 | }
46 |
47 | const arg = node.declaration.arguments[0];
48 |
49 | if (!t.isObjectExpression(arg)) {
50 | throw new Error("Expected first argument of `defineConfig` to be an object");
51 | }
52 |
53 | // We are creating or getting the 'plugins' property.
54 | let pluginsProperty = arg.properties.find(
55 | (p) => t.isObjectProperty(p) && t.isIdentifier(p.key) && p.key.name === "plugins"
56 | ) as t.ObjectProperty;
57 |
58 | if (!pluginsProperty) {
59 | pluginsProperty = t.objectProperty(t.identifier("plugins"), t.arrayExpression([]));
60 |
61 | arg.properties.push(pluginsProperty);
62 | }
63 |
64 | if (!t.isArrayExpression(pluginsProperty.value)) {
65 | throw new Error("Expected the `plugins` property in the object passed to `defineConfig()` to be an array");
66 | }
67 |
68 | const vitePlugins = pluginsProperty.value.elements;
69 |
70 | if (vitePlugins.some((p) => t.isIdentifier(p) && p.name === relayImportId.name)) {
71 | this.skip("Already configured");
72 | return;
73 | }
74 |
75 | // Add the "relay" import to the beginning of "plugins".
76 | vitePlugins.splice(0, 0, relayImportId);
77 | },
78 | });
79 |
80 | const updatedConfigCode = printAst(ast, configCode);
81 |
82 | await this.context.fs.writeToFile(configFile.abs, updatedConfigCode);
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export const ToolchainOptions = ["cra", "next", "vite"] as const;
2 | export const PackageManagerOptions = ["npm", "yarn", "pnpm"] as const;
3 |
4 | export type ToolchainType = typeof ToolchainOptions[number];
5 | export type PackageManagerType = typeof PackageManagerOptions[number];
6 |
7 | export type RelayCompilerLanguage = "javascript" | "typescript" | "flow";
8 |
9 | export type CliArguments = {
10 | toolchain: ToolchainType;
11 | typescript: boolean;
12 | subscriptions: boolean;
13 | schemaFile: string;
14 | src: string;
15 | artifactDirectory: string;
16 | packageManager: PackageManagerType;
17 | ignoreGitChanges: boolean;
18 | skipInstall: boolean;
19 | interactive: boolean;
20 | };
21 |
--------------------------------------------------------------------------------
/src/utils/ast.ts:
--------------------------------------------------------------------------------
1 | import generate from "@babel/generator";
2 | import { ParseResult, parse } from "@babel/parser";
3 | import { NodePath } from "@babel/traverse";
4 | import t from "@babel/types";
5 | import { format } from "prettier";
6 |
7 | export function parseAst(code: string): ParseResult {
8 | return parse(code, {
9 | sourceType: "module",
10 | plugins: ["typescript", "jsx"],
11 | });
12 | }
13 |
14 | export function astToString(ast: ParseResult, oldCode: string): string {
15 | return generate.default(ast, { retainLines: true }, oldCode).code;
16 | }
17 |
18 | export function printAst(ast: ParseResult, oldCode: string): string {
19 | const newCode = astToString(ast, oldCode);
20 |
21 | return prettifyCode(newCode);
22 | }
23 |
24 | export function prettifyCode(code: string): string {
25 | return format(code, {
26 | bracketSameLine: false,
27 | endOfLine: "auto",
28 | parser: "babel-ts",
29 | });
30 | }
31 |
32 | export function insertNamedImport(path: NodePath, importName: string, packageName: string): t.Identifier {
33 | return insertNamedImports(path, [importName], packageName)[0];
34 | }
35 |
36 | export function insertNamedImports(path: NodePath, imports: string[], packageName: string): t.Identifier[] {
37 | const program = path.findParent((p) => p.isProgram()) as NodePath;
38 |
39 | const identifiers: t.Identifier[] = [];
40 | const missingImports: string[] = [];
41 |
42 | for (const namedImport of imports) {
43 | const importIdentifier = t.identifier(namedImport);
44 |
45 | const existingImport = getNamedImport(program, namedImport, packageName);
46 |
47 | if (!!existingImport) {
48 | identifiers.push(importIdentifier);
49 | continue;
50 | }
51 |
52 | missingImports.push(namedImport);
53 | }
54 |
55 | let importDeclaration: t.ImportDeclaration;
56 | const isFirstImportFromPackage = missingImports.length === imports.length;
57 |
58 | if (isFirstImportFromPackage) {
59 | importDeclaration = t.importDeclaration([], t.stringLiteral(packageName));
60 | } else {
61 | importDeclaration = getImportDeclaration(program, packageName)!;
62 | }
63 |
64 | for (const namedImport of missingImports) {
65 | const importIdentifier = t.identifier(namedImport);
66 |
67 | const newImport = t.importSpecifier(t.cloneNode(importIdentifier), importIdentifier);
68 |
69 | importDeclaration.specifiers.push(newImport);
70 |
71 | identifiers.push(importIdentifier);
72 | }
73 |
74 | if (isFirstImportFromPackage) {
75 | // Insert import at start of file.
76 | program.node.body.unshift(importDeclaration);
77 | }
78 |
79 | return identifiers;
80 | }
81 |
82 | export function insertDefaultImport(path: NodePath, importName: string, packageName: string): t.Identifier {
83 | const importIdentifier = t.identifier(importName);
84 |
85 | const program = path.findParent((p) => p.isProgram()) as NodePath;
86 |
87 | const existingImport = getDefaultImport(program, importName, packageName);
88 |
89 | if (!!existingImport) {
90 | return importIdentifier;
91 | }
92 |
93 | const importDeclaration = t.importDeclaration(
94 | [t.importDefaultSpecifier(t.cloneNode(importIdentifier))],
95 |
96 | t.stringLiteral(packageName)
97 | );
98 |
99 | // Insert import at start of file.
100 | program.node.body.unshift(importDeclaration);
101 |
102 | return importIdentifier;
103 | }
104 |
105 | function getImportDeclaration(path: NodePath, packageName: string): t.ImportDeclaration | null {
106 | return path.node.body.find(
107 | (s) => t.isImportDeclaration(s) && s.source.value === packageName
108 | ) as t.ImportDeclaration | null;
109 | }
110 |
111 | export function getNamedImport(
112 | path: NodePath,
113 | importName: string,
114 | packageName: string
115 | ): t.ImportDeclaration {
116 | return path.node.body.find(
117 | (s) =>
118 | t.isImportDeclaration(s) &&
119 | s.source.value === packageName &&
120 | s.specifiers.some((sp) => t.isImportSpecifier(sp) && sp.local.name === importName)
121 | ) as t.ImportDeclaration;
122 | }
123 |
124 | function getDefaultImport(path: NodePath, importName: string, packageName: string): t.ImportDeclaration {
125 | return path.node.body.find(
126 | (s) =>
127 | t.isImportDeclaration(s) &&
128 | s.source.value === packageName &&
129 | s.specifiers.some((sp) => t.isImportDefaultSpecifier(sp) && sp.local.name === importName)
130 | ) as t.ImportDeclaration;
131 | }
132 |
133 | export function mergeProperties(
134 | existingProps: t.ObjectExpression["properties"],
135 | newProps: t.ObjectProperty[]
136 | ): t.ObjectExpression["properties"] {
137 | let existingCopy = [...existingProps];
138 |
139 | for (const prop of newProps) {
140 | const existingIndex = existingCopy.findIndex(
141 | (p) => t.isObjectProperty(p) && t.isIdentifier(p.key) && t.isIdentifier(prop.key) && p.key.name === prop.key.name
142 | );
143 |
144 | if (existingIndex !== -1) {
145 | existingCopy[existingIndex] = prop;
146 | } else {
147 | existingCopy.push(prop);
148 | }
149 | }
150 |
151 | return existingCopy;
152 | }
153 |
--------------------------------------------------------------------------------
/src/utils/cli.ts:
--------------------------------------------------------------------------------
1 | import chalk from "chalk";
2 |
3 | export function printError(message: string): void {
4 | console.log(chalk.red("✖") + " " + message);
5 | }
6 |
7 | export function headline(message: string): string {
8 | return chalk.cyan.bold.underline(message);
9 | }
10 |
11 | export function importantHeadline(message: string): string {
12 | return chalk.red.bold.underline(message);
13 | }
14 |
15 | export function bold(message: string): string {
16 | return chalk.cyan.bold(message);
17 | }
18 |
19 | export function dim(message: string): string {
20 | return chalk.dim(message);
21 | }
22 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./cli.js";
2 | export * from "./ast.js";
3 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs
3 | "include": ["src", "types", "assets/env"],
4 | "compilerOptions": {
5 | "target": "ES2017",
6 | "module": "Node16",
7 | "moduleResolution": "Node16",
8 | "lib": ["dom", "esnext"],
9 | "rootDir": "./src",
10 | "outDir": "dist",
11 | "strict": true,
12 | "noImplicitReturns": true,
13 | "noFallthroughCasesInSwitch": true,
14 | "esModuleInterop": true,
15 | "skipLibCheck": true,
16 | "forceConsistentCasingInFileNames": true
17 | }
18 | }
19 |
--------------------------------------------------------------------------------