Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add experimental codegen support to CLI #707

Merged
merged 59 commits into from
May 17, 2023
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
5783598
Infer return and variable types from query string argument
frandiox Mar 20, 2023
23f2cf5
Make arguments optional if variables are not required
frandiox Mar 20, 2023
9bdfb0e
Force optional arguments when not using codegen
frandiox Mar 20, 2023
37b2105
Ignore country and language when checking optional arguments
frandiox Mar 20, 2023
12768a4
New @shopify/hydrogen-codegen package
frandiox Mar 20, 2023
2dabe78
Add vendor patch for graphql-tag-pluck
frandiox Mar 20, 2023
9df43e4
Remove unused deps and add comments
frandiox Mar 20, 2023
72e13c1
Add codegen to hello-world template
frandiox Mar 20, 2023
a1e2a0a
Remove codegen from generated JS templates
frandiox Mar 20, 2023
a54cf1b
Use Pick instead of primitives for generated operations
frandiox Mar 22, 2023
05dd759
Remove unnecessary properties in package.json
frandiox Mar 22, 2023
6080a2e
Add comments and rename some generics
frandiox Mar 22, 2023
5745030
Simplify storefront types for codegen
frandiox Mar 22, 2023
6ba905c
Add createRequire in ESM build
frandiox Mar 22, 2023
b4917ff
Fix typecheck
frandiox Mar 22, 2023
f7422a7
Disable tests for now
frandiox Mar 22, 2023
818e42f
Prettier readme
frandiox Mar 22, 2023
99db3a7
Use any by default for the return type
frandiox Mar 23, 2023
59db3e1
Adjust new lines and exports
frandiox Mar 23, 2023
b07cafe
Check custom plugins and export hydrogen plugin
frandiox Mar 23, 2023
d606f8c
wip
frandiox Mar 28, 2023
e590e22
Merge branch '2023-01' into fd-codegen
frandiox Mar 31, 2023
3c41972
Update visitor implementation
frandiox Apr 3, 2023
9acb5b8
Merge branch '2023-01' into fd-codegen
frandiox Apr 12, 2023
0ea484d
Merge branch '2023-04' into fd-codegen
frandiox May 12, 2023
f255917
Make fragment and query names unique in demo-store
frandiox May 12, 2023
653e684
Change namespace and import path
frandiox May 12, 2023
c51762b
Better separate plugin from preset
frandiox May 12, 2023
03317a2
Log error
frandiox May 12, 2023
31b829a
Fix generated code order
frandiox May 12, 2023
9b4e85a
Separate preset from utilities
frandiox May 12, 2023
98f0ef9
Reorganize files
frandiox May 12, 2023
9bc15b9
Fix ESM build
frandiox May 12, 2023
620e3c9
Use JSON schema from Hydrogen package
frandiox May 12, 2023
03a8c1a
Specify extensions in imports and fix CJS build
frandiox May 15, 2023
286d04f
Change graphql-tag-pluck patching strategy
frandiox May 15, 2023
c9194f6
Add experimental-codegen command
frandiox May 15, 2023
42733cc
Add codegen to dev command with a flag
frandiox May 15, 2023
f4b87a8
Fix config path flag in dev command
frandiox May 15, 2023
33e5b4c
Generate Oclif manifest
frandiox May 15, 2023
69195e0
Remove files from templates
frandiox May 15, 2023
db2aef0
Merge branch '2023-04' into fd-codegen
frandiox May 15, 2023
736b33b
Fix pluck paths during tests
frandiox May 15, 2023
a4f3396
Add unit tests
frandiox May 15, 2023
1227c30
Fix deps
frandiox May 15, 2023
f89fa01
Fix clean-all, update package-lock
frandiox May 15, 2023
cbd5ba7
Wrap require.resolve in try-catch
frandiox May 15, 2023
a83cabd
Make schema accessible without building Hydrogen first
frandiox May 15, 2023
060533c
Patch pluck before importing codegen cli
frandiox May 15, 2023
c0a32a9
Cleanup
frandiox May 15, 2023
5f9055a
Improve error messages
frandiox May 16, 2023
70af764
Feedback update
frandiox May 16, 2023
efc7d87
Fix tests
frandiox May 16, 2023
1b6bb62
Update package.json and make package publishable
frandiox May 16, 2023
60578a1
Update flag descriptions
frandiox May 16, 2023
b6d5695
Changesets and docs
frandiox May 16, 2023
cf1604a
Change default generated file name
frandiox May 17, 2023
e53efe1
Fix paths on Windows
frandiox May 17, 2023
adcf3ef
Merge branch '2023-04' into fd-codegen
frandiox May 17, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
376 changes: 201 additions & 175 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"packages/hydrogen",
"packages/create-hydrogen",
"packages/cli",
"packages/hydrogen-react"
"packages/hydrogen-react",
"packages/hydrogen-codegen"
],
"prettier": "@shopify/prettier-config",
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/transpile-ts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export async function transpileProject(projectDir: string) {
const formatConfig = await resolveFormatConfig();

for (const entry of entries) {
if (entry.endsWith('.d.ts')) {
if (entry.endsWith('.d.ts') || entry.endsWith('codegen.ts')) {
await fs.rm(entry);
continue;
}
Expand Down
52 changes: 52 additions & 0 deletions packages/hydrogen-codegen/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"name": "@shopify/hydrogen-codegen",
"version": "0.0.0",
"private": true,
"description": "GraphQL Code Generator preset for gql magic.",
"type": "module",
"main": "dist/cjs/index.cjs",
"module": "dist/esm/index.js",
"types": "dist/esm/index.d.ts",
"scripts": {
"build": "tsup --clean --config ./tsup.config.ts",
"dev": "tsup --watch --config ./tsup.config.ts",
"typecheck": "tsc --noEmit",
"test:disabled": "cross-env SHOPIFY_UNIT_TEST=1 vitest run",
"test:watch": "cross-env SHOPIFY_UNIT_TEST=1 vitest",
"postinstall": "node ./postinstall.mjs"
frandiox marked this conversation as resolved.
Show resolved Hide resolved
},
"exports": {
".": {
"require": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/cjs/index.cjs"
},
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"default": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
}
},
"./package.json": "./package.json"
},
"repository": {
"type": "git",
"url": "https://github.com/shopify/hydrogen.git",
"directory": "packages/hydrogen-codegen"
},
"license": "MIT",
"devDependencies": {
"@graphql-codegen/plugin-helpers": "^4.1.0",
"@graphql-tools/utils": "^9.0.0"
},
"dependencies": {
"@graphql-codegen/add": "^4.0.1",
"@graphql-codegen/typescript-operations": "^3.0.1"
},
"peerDependencies": {
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0"
}
}
13 changes: 13 additions & 0 deletions packages/hydrogen-codegen/postinstall.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Monkey patch `graphl-tag-pluck` for now

import fs from 'fs';
import {createRequire} from 'module';

const require = createRequire(import.meta.url);

fs.copyFileSync(
require.resolve('./vendor/graphql-tag-pluck/visitor.js'),
require
.resolve('@graphql-tools/graphql-tag-pluck')
.replace('/index.js', '/visitor.js'),
);
113 changes: 113 additions & 0 deletions packages/hydrogen-codegen/src/dts-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// This plugin is based on `gql-tag-operations-preset`
// https://www.npmjs.com/package/@graphql-codegen/gql-tag-operations-preset
frandiox marked this conversation as resolved.
Show resolved Hide resolved

import type {PluginFunction} from '@graphql-codegen/plugin-helpers';
import type {Source} from '@graphql-tools/utils';
import type {FragmentDefinitionNode, OperationDefinitionNode} from 'graphql';

export type OperationOrFragment = {
initialName: string;
definition: OperationDefinitionNode | FragmentDefinitionNode;
};

export type SourceWithOperations = {
source: Source;
operations: Array<OperationOrFragment>;
};

export const plugin: PluginFunction<{
sourcesWithOperations: Array<SourceWithOperations>;
}> = (_, __, {sourcesWithOperations}, _info) => {
const code = getDocumentRegistryChunk(sourcesWithOperations);

code.push(`
declare module '@shopify/hydrogen' {
interface QueryTypes extends GeneratedQueryTypes {}
interface MutationTypes extends GeneratedMutationTypes {}
}`);

return code.join('');
};

const isMutationRE = /(^|}\s|\n\s*)mutation[\s({]/im;

// Iteratively replace fragment annotations with the actual fragment content
// until there are no more annotations in the operation.
const normalizeOperation = (
rawSDL: string,
variablesMap: Map<string, string>,
) => {
let variableNotFound = false;

while (/#REQUIRED_VAR=/.test(rawSDL) && !variableNotFound) {
let requiredVariables = rawSDL.matchAll(/#REQUIRED_VAR=(\w+)/g);
for (const [match, variableName] of requiredVariables) {
if (variablesMap.has(variableName)) {
rawSDL = rawSDL.replace(match, variablesMap.get(variableName)!);
} else {
// An annotation cannot be replaced, so the operation is invalid.
// This should not happen, but we'll handle it just in case
// to prevent infinite loops. This should be logged as an error and fixed.
variableNotFound = true; // break;
// TODO log error
}
}
}

return rawSDL;
};

const buildTypeLines = (name: string, operations: Map<string, string[]>) => {
const lines = [`export interface Generated${name} {\n`];

for (const [originalString, typeNames] of operations) {
lines.push(
` ${JSON.stringify(originalString)}: {return: ${
// SFAPI does not support multiple operations in a single document.
// Use 'never' here if that's the case so that the user gets a type error.
// e.g. `'query q1 {...} query q2 {...}'` is invalid.
typeNames.length === 1 ? typeNames[0] : 'never'
}, variables: ${typeNames.map((n) => n + 'Variables').join(' & ')}},\n`,
);
}

lines.push(`}\n`);

return lines;
};

function getDocumentRegistryChunk(
sourcesWithOperations: Array<SourceWithOperations> = [],
) {
const queries = new Map<string, string[]>();
const mutations = new Map<string, string[]>();

const variablesMap = new Map<string, string>();
for (const {source} of sourcesWithOperations) {
const variableName = source.rawSDL?.match(/#VAR_NAME=(\w+)/)?.[1];
if (variableName) {
source.rawSDL = source.rawSDL!.replace(/#VAR_NAME=\w+$/, '');
variablesMap.set(variableName, source.rawSDL!);
}
}

for (const {operations, source} of sourcesWithOperations) {
const actualOperations = operations.filter(
(op) => op.definition.kind === 'OperationDefinition',
);

if (actualOperations.length === 0) continue;

const sdlString = source.rawSDL!;
const target = isMutationRE.test(sdlString) ? mutations : queries;
target.set(
normalizeOperation(sdlString, variablesMap),
actualOperations.map((o) => o.initialName),
);
}

return [
...buildTypeLines('QueryTypes', queries),
...buildTypeLines('MutationTypes', mutations),
];
}
139 changes: 139 additions & 0 deletions packages/hydrogen-codegen/src/index.ts
frandiox marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import type {Types} from '@graphql-codegen/plugin-helpers';
import * as addPlugin from '@graphql-codegen/add';
import * as typescriptOperationPlugin from '@graphql-codegen/typescript-operations';
import {processSources} from './process-sources';
import * as dtsPlugin from './dts-plugin';

export type GqlTagConfig = {};

// This comment is used during ESM build:
//! import {createRequire} from 'module'; const require = createRequire(import.meta.url);
export const schema = require.resolve(
'@shopify/hydrogen-react/storefront.schema.json',
);

export const preset: Types.OutputPreset<GqlTagConfig> = {
buildGeneratesSection: (options) => {
const capitalize = (str: string) =>
str.charAt(0).toUpperCase() + str.slice(1);

const sourcesWithOperations = processSources(options.documents, (node) => {
if (node.kind === 'FragmentDefinition') {
return 'not used';
}

// Match the names generated by typescript-operations plugin:
// e.g. 'query Hello {...}' => HelloQuery
// -- Anonymous queries are not supported.
return capitalize(node.name!.value) + capitalize(node.operation);
});

const sources = sourcesWithOperations.map(({source}) => source);

const pluginMap = {
...options.pluginMap,
frandiox marked this conversation as resolved.
Show resolved Hide resolved
[`add`]: addPlugin,
[`typescript-operations`]: typescriptOperationPlugin,
[`gen-dts`]: dtsPlugin,
};

const namespacedImportName = 'HydrogenStorefront';

const plugins: Array<Types.ConfiguredPlugin> = [
// 1. Disable eslint for the generated file
{
[`add`]: {
content: `/* eslint-disable eslint-comments/disable-enable-pair */\n/* eslint-disable eslint-comments/no-unlimited-disable */\n/* eslint-disable */`,
},
},
// 2. Import all the generated API types from hydrogen-react
{
[`add`]: {
content: `import * as ${namespacedImportName} from '@shopify/hydrogen-react/storefront-api-types';\n`,
},
},
// 3. Generate the operations (i.e. queries, mutations, and fragments types)
{
[`typescript-operations`]: {
skipTypename: true, // Skip __typename fields
useTypeImports: true, // Use `import type` instead of `import`
preResolveTypes: false, // Use Pick<...> instead of primitives
frehner marked this conversation as resolved.
Show resolved Hide resolved
},
},
// 4. Augment Hydrogen query/mutation types with the generated operations
{[`gen-dts`]: {sourcesWithOperations}},
// 5. Custom plugins from the user
...options.plugins,
];

return [
{
filename: options.baseOutputDir,
plugins,
pluginMap,
schema: options.schema || schema,
config: {
...options.config,
// This is for the operations plugin
namespacedImportName,
},
documents: sources,
// @ts-expect-error
documentTransforms: options.documentTransforms,
},
];
},
};

export const pluckConfig = {
/**
* Hook to determine if a node is a gql template literal.
* By default, graphql-tag-pluck only looks for leading comments or `gql` tag.
*/
isGqlTemplateLiteral: (node: any, options: any) => {
// Check for internal gql comment: const QUERY = `#graphql ...`
const hasInternalGqlComment =
node.type === 'TemplateLiteral' &&
/\s*#graphql\s*\n/i.test(node.quasis[0]?.value?.raw || '');

if (hasInternalGqlComment) return true;

// Check for leading gql comment: const QUERY = /* GraphQL */ `...`
const {leadingComments} = node;
const leadingComment = leadingComments?.[leadingComments?.length - 1];
const leadingCommentValue = leadingComment?.value?.trim().toLowerCase();

return leadingCommentValue === options?.gqlMagicComment;
},

/**
* Instruct how to extract the gql template literal from the code.
* By default, embedded expressions in template literals (e.g. ${foo})
* are removed from the template string. This hook allows us to annotate
* the template string with the required embedded expressions instead of
* removing them. Later, we can use this information to reconstruct the
* embedded expressions.
*/
pluckStringFromFile: (code: string, {start, end, leadingComments}: any) => {
let gqlTemplate = code
// Slice quotes
.slice(start + 1, end - 1)
// Annotate embedded expressions
// e.g. ${foo} -> #REQUIRED_VAR=foo
.replace(/\$\{([^}]*)\}/g, (_, m1) => '#REQUIRED_VAR=' + m1)
.split('\\`')
.join('`');

const chunkStart = leadingComments?.[0]?.start ?? start;
const codeBeforeNode = code.slice(0, chunkStart);
const [, varName] = codeBeforeNode.match(/\s(\w+)\s*=\s*$/) || [];

if (varName) {
// Annotate with the name of the variable that stores this gql template.
// This is used to reconstruct the embedded expressions later.
gqlTemplate += '#VAR_NAME=' + varName;
}

return gqlTemplate;
},
frandiox marked this conversation as resolved.
Show resolved Hide resolved
};
Loading