@gleanql/codegen 0.1.0
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/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/index.d.mts +119 -0
- package/dist/index.mjs +243 -0
- package/package.json +46 -0
- package/src/graph.ts +78 -0
- package/src/index.ts +46 -0
- package/src/introspection.ts +84 -0
- package/src/schema-model.ts +81 -0
- package/src/ts-render.ts +41 -0
- package/src/types.ts +114 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexander Liljengard
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @gleanql/codegen
|
|
2
|
+
|
|
3
|
+
The schema generator for [GleanQL](https://github.com/gleanql/gleanql) — GraphQL
|
|
4
|
+
without writing GraphQL. From a GraphQL schema (SDL or introspection) it
|
|
5
|
+
generates:
|
|
6
|
+
|
|
7
|
+
- the **schema model** the compiler and runtime share,
|
|
8
|
+
- **branded TypeScript types** for every schema type — so API drift (a
|
|
9
|
+
removed field, a tightened nullability) becomes a type error in your
|
|
10
|
+
components,
|
|
11
|
+
- the typed **`glean` accessor** your components read from.
|
|
12
|
+
|
|
13
|
+
You normally don't install this directly — the
|
|
14
|
+
[`@gleanql/vite`](https://github.com/gleanql/gleanql/tree/main/packages/vite)
|
|
15
|
+
plugin runs it on every build from your `schema.graphql`.
|
|
16
|
+
|
|
17
|
+
## Docs
|
|
18
|
+
|
|
19
|
+
Full documentation lives in the [GleanQL repo](https://github.com/gleanql/gleanql).
|
|
20
|
+
|
|
21
|
+
MIT
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
//#region src/introspection.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* GraphQL introspection model + type-ref helpers.
|
|
4
|
+
*
|
|
5
|
+
* We consume the standard introspection result (`data.__schema`) directly rather
|
|
6
|
+
* than depend on graphql-js at runtime — the shape is stable and small. These
|
|
7
|
+
* structural types match what `introspectionFromSchema()` produces, so a real
|
|
8
|
+
* introspection drops straight in.
|
|
9
|
+
*/
|
|
10
|
+
type IntrospectionTypeKind = "SCALAR" | "OBJECT" | "INTERFACE" | "UNION" | "ENUM" | "INPUT_OBJECT" | "LIST" | "NON_NULL";
|
|
11
|
+
type IntrospectionTypeRef = {
|
|
12
|
+
readonly kind: "NON_NULL";
|
|
13
|
+
readonly ofType: IntrospectionTypeRef;
|
|
14
|
+
} | {
|
|
15
|
+
readonly kind: "LIST";
|
|
16
|
+
readonly ofType: IntrospectionTypeRef;
|
|
17
|
+
} | {
|
|
18
|
+
readonly kind: "SCALAR" | "OBJECT" | "INTERFACE" | "UNION" | "ENUM" | "INPUT_OBJECT";
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly ofType?: null;
|
|
21
|
+
};
|
|
22
|
+
interface IntrospectionInputValue {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
readonly type: IntrospectionTypeRef;
|
|
25
|
+
readonly defaultValue?: string | null;
|
|
26
|
+
}
|
|
27
|
+
interface IntrospectionField {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly args: readonly IntrospectionInputValue[];
|
|
30
|
+
readonly type: IntrospectionTypeRef;
|
|
31
|
+
}
|
|
32
|
+
interface IntrospectionType {
|
|
33
|
+
readonly kind: IntrospectionTypeKind;
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly description?: string | null;
|
|
36
|
+
readonly fields?: readonly IntrospectionField[] | null;
|
|
37
|
+
readonly inputFields?: readonly IntrospectionInputValue[] | null;
|
|
38
|
+
readonly interfaces?: readonly IntrospectionTypeRef[] | null;
|
|
39
|
+
readonly enumValues?: ReadonlyArray<{
|
|
40
|
+
readonly name: string;
|
|
41
|
+
}> | null;
|
|
42
|
+
readonly possibleTypes?: readonly IntrospectionTypeRef[] | null;
|
|
43
|
+
}
|
|
44
|
+
interface IntrospectionSchema {
|
|
45
|
+
readonly queryType: {
|
|
46
|
+
readonly name: string;
|
|
47
|
+
};
|
|
48
|
+
readonly mutationType?: {
|
|
49
|
+
readonly name: string;
|
|
50
|
+
} | null;
|
|
51
|
+
readonly subscriptionType?: {
|
|
52
|
+
readonly name: string;
|
|
53
|
+
} | null;
|
|
54
|
+
readonly types: readonly IntrospectionType[];
|
|
55
|
+
}
|
|
56
|
+
/** The named type at the bottom of a (possibly list/non-null) type ref. */
|
|
57
|
+
declare function namedTypeName(ref: IntrospectionTypeRef): string;
|
|
58
|
+
/** True if a LIST appears anywhere in the type ref. */
|
|
59
|
+
declare function isListType(ref: IntrospectionTypeRef): boolean;
|
|
60
|
+
/** True if the outermost wrapper is NON_NULL. */
|
|
61
|
+
declare function isNonNull(ref: IntrospectionTypeRef): boolean;
|
|
62
|
+
/** Render a GraphQL SDL type ref string, e.g. `[Product!]!`. */
|
|
63
|
+
declare function renderGraphQLType(ref: IntrospectionTypeRef): string;
|
|
64
|
+
/** Introspection meta types (`__Schema`, `__Type`, …) we never generate. */
|
|
65
|
+
declare function isInternalType(name: string): boolean;
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/schema-model.d.ts
|
|
68
|
+
interface GenerateSchemaModelOptions {
|
|
69
|
+
/** Exported const name for the SchemaModel. Default: `schema`. */
|
|
70
|
+
readonly exportName?: string;
|
|
71
|
+
}
|
|
72
|
+
declare function generateSchemaModel(schema: IntrospectionSchema, options?: GenerateSchemaModelOptions): string;
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/ts-render.d.ts
|
|
75
|
+
/** Shared TypeScript-type rendering for branded types and the graph accessors. */
|
|
76
|
+
declare const DEFAULT_SCALAR_TYPES: Record<string, string>;
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/types.d.ts
|
|
79
|
+
/**
|
|
80
|
+
* Generate branded TypeScript types from introspection.
|
|
81
|
+
*
|
|
82
|
+
* To app code these read as ordinary schema types (`Product`, `Image`, …); the
|
|
83
|
+
* compiler recognizes them via the `__typename` brand. Nullability, lists,
|
|
84
|
+
* callable fields (field arguments), enums, interfaces, unions and input objects
|
|
85
|
+
* all surface — so TypeScript catches API drift (a removed/renamed field, a
|
|
86
|
+
* tightened nullability) at compile time.
|
|
87
|
+
*/
|
|
88
|
+
interface GenerateTypesOptions {
|
|
89
|
+
/** TS type for each GraphQL scalar. Unlisted custom scalars default to `string`. */
|
|
90
|
+
readonly scalarTypes?: Record<string, string>;
|
|
91
|
+
}
|
|
92
|
+
declare function generateTypes(schema: IntrospectionSchema, options?: GenerateTypesOptions): string;
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/graph.d.ts
|
|
95
|
+
/**
|
|
96
|
+
* Generate the `graph` accessor object: one method per Query root field plus the
|
|
97
|
+
* `components(...)` registry helper. At build time the compiler reads these to
|
|
98
|
+
* learn the root fields and their types; at runtime they are backed by the
|
|
99
|
+
* runtime's bound graph (see `@gleanql/react`'s `bindGraph`). The generated bodies
|
|
100
|
+
* are typed stubs — the real values are proxies.
|
|
101
|
+
*/
|
|
102
|
+
interface GenerateGraphOptions {
|
|
103
|
+
readonly scalarTypes?: Record<string, string>;
|
|
104
|
+
/** Import path for the generated branded types. Default: `./schema.js`. */
|
|
105
|
+
readonly schemaImportPath?: string;
|
|
106
|
+
}
|
|
107
|
+
declare function generateGraph(schema: IntrospectionSchema, options?: GenerateGraphOptions): string;
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/index.d.ts
|
|
110
|
+
interface GeneratedSchemaPackage {
|
|
111
|
+
readonly schemaModel: string;
|
|
112
|
+
readonly types: string;
|
|
113
|
+
readonly graph: string;
|
|
114
|
+
}
|
|
115
|
+
interface GenerateSchemaPackageOptions extends GenerateSchemaModelOptions, GenerateTypesOptions, GenerateGraphOptions {}
|
|
116
|
+
/** Generate all three source files from an introspection schema. */
|
|
117
|
+
declare function generateSchemaPackage(schema: IntrospectionSchema, options?: GenerateSchemaPackageOptions): GeneratedSchemaPackage;
|
|
118
|
+
//#endregion
|
|
119
|
+
export { DEFAULT_SCALAR_TYPES, GenerateGraphOptions, GenerateSchemaModelOptions, GenerateSchemaPackageOptions, GenerateTypesOptions, GeneratedSchemaPackage, IntrospectionField, IntrospectionInputValue, IntrospectionSchema, IntrospectionType, IntrospectionTypeKind, IntrospectionTypeRef, generateGraph, generateSchemaModel, generateSchemaPackage, generateTypes, isInternalType, isListType, isNonNull, namedTypeName, renderGraphQLType };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
//#region src/introspection.ts
|
|
2
|
+
/** The named type at the bottom of a (possibly list/non-null) type ref. */
|
|
3
|
+
function namedTypeName(ref) {
|
|
4
|
+
let cur = ref;
|
|
5
|
+
while (cur.kind === "LIST" || cur.kind === "NON_NULL") cur = cur.ofType;
|
|
6
|
+
return cur.name;
|
|
7
|
+
}
|
|
8
|
+
/** True if a LIST appears anywhere in the type ref. */
|
|
9
|
+
function isListType(ref) {
|
|
10
|
+
let cur = ref;
|
|
11
|
+
while (cur.kind === "LIST" || cur.kind === "NON_NULL") {
|
|
12
|
+
if (cur.kind === "LIST") return true;
|
|
13
|
+
cur = cur.ofType;
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
/** True if the outermost wrapper is NON_NULL. */
|
|
18
|
+
function isNonNull(ref) {
|
|
19
|
+
return ref.kind === "NON_NULL";
|
|
20
|
+
}
|
|
21
|
+
/** Render a GraphQL SDL type ref string, e.g. `[Product!]!`. */
|
|
22
|
+
function renderGraphQLType(ref) {
|
|
23
|
+
switch (ref.kind) {
|
|
24
|
+
case "NON_NULL": return `${renderGraphQLType(ref.ofType)}!`;
|
|
25
|
+
case "LIST": return `[${renderGraphQLType(ref.ofType)}]`;
|
|
26
|
+
default: return ref.name;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Introspection meta types (`__Schema`, `__Type`, …) we never generate. */
|
|
30
|
+
function isInternalType(name) {
|
|
31
|
+
return name.startsWith("__");
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/ts-render.ts
|
|
35
|
+
/** Shared TypeScript-type rendering for branded types and the graph accessors. */
|
|
36
|
+
const DEFAULT_SCALAR_TYPES = {
|
|
37
|
+
String: "string",
|
|
38
|
+
ID: "string",
|
|
39
|
+
Int: "number",
|
|
40
|
+
Float: "number",
|
|
41
|
+
Boolean: "boolean"
|
|
42
|
+
};
|
|
43
|
+
/** Render a TS type from a GraphQL type ref, honoring list + nullability nesting. */
|
|
44
|
+
function renderTs(ref, scalars) {
|
|
45
|
+
if (ref.kind === "NON_NULL") return renderTsInner(ref.ofType, scalars);
|
|
46
|
+
return `${renderTsInner(ref, scalars)} | null`;
|
|
47
|
+
}
|
|
48
|
+
function renderTsInner(ref, scalars) {
|
|
49
|
+
if (ref.kind === "NON_NULL") return renderTsInner(ref.ofType, scalars);
|
|
50
|
+
if (ref.kind === "LIST") {
|
|
51
|
+
const el = renderTs(ref.ofType, scalars);
|
|
52
|
+
return `${el.includes(" ") ? `(${el})` : el}[]`;
|
|
53
|
+
}
|
|
54
|
+
if (ref.kind === "SCALAR") return scalars[ref.name] ?? "string";
|
|
55
|
+
return ref.name;
|
|
56
|
+
}
|
|
57
|
+
/** A bare property key when safe, else a quoted key. */
|
|
58
|
+
function propKey(name) {
|
|
59
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
60
|
+
}
|
|
61
|
+
/** Indent every non-empty line of `text` by `spaces` columns. */
|
|
62
|
+
function indent(text, spaces) {
|
|
63
|
+
const pad = " ".repeat(spaces);
|
|
64
|
+
return text.split("\n").map((line) => line ? pad + line : line).join("\n");
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/schema-model.ts
|
|
68
|
+
/**
|
|
69
|
+
* Generate the `SchemaModel` source (`defineSchema({...})`) the compiler and
|
|
70
|
+
* runtime consume. This is the machine-generated equivalent of the previously
|
|
71
|
+
* hand-authored `schema-model.ts`.
|
|
72
|
+
*/
|
|
73
|
+
const KIND_MAP = {
|
|
74
|
+
SCALAR: "scalar",
|
|
75
|
+
OBJECT: "object",
|
|
76
|
+
INTERFACE: "interface",
|
|
77
|
+
UNION: "union",
|
|
78
|
+
ENUM: "enum",
|
|
79
|
+
INPUT_OBJECT: "input"
|
|
80
|
+
};
|
|
81
|
+
function generateSchemaModel(schema, options = {}) {
|
|
82
|
+
const exportName = options.exportName ?? "schema";
|
|
83
|
+
const types = schema.types.filter((t) => !isInternalType(t.name)).map((t) => renderType(t)).filter((s) => s !== void 0);
|
|
84
|
+
const lines = [];
|
|
85
|
+
lines.push(`import { defineSchema, type SchemaModel } from "@gleanql/core";`);
|
|
86
|
+
lines.push("");
|
|
87
|
+
lines.push(`/** Generated from GraphQL introspection. Do not edit by hand. */`);
|
|
88
|
+
lines.push(`export const ${exportName}: SchemaModel = defineSchema({`);
|
|
89
|
+
lines.push(` queryType: ${JSON.stringify(schema.queryType.name)},`);
|
|
90
|
+
if (schema.mutationType) lines.push(` mutationType: ${JSON.stringify(schema.mutationType.name)},`);
|
|
91
|
+
if (schema.subscriptionType) lines.push(` subscriptionType: ${JSON.stringify(schema.subscriptionType.name)},`);
|
|
92
|
+
lines.push(` types: [`);
|
|
93
|
+
for (const t of types) lines.push(indent(t, 4) + ",");
|
|
94
|
+
lines.push(` ],`);
|
|
95
|
+
lines.push(`});`);
|
|
96
|
+
return lines.join("\n") + "\n";
|
|
97
|
+
}
|
|
98
|
+
function renderType(type) {
|
|
99
|
+
const kind = KIND_MAP[type.kind];
|
|
100
|
+
if (!kind) return void 0;
|
|
101
|
+
const parts = [`name: ${JSON.stringify(type.name)}`, `kind: ${JSON.stringify(kind)}`];
|
|
102
|
+
if ((type.kind === "OBJECT" || type.kind === "INTERFACE") && type.fields) {
|
|
103
|
+
const fields = type.fields.map((f) => `${propKey(f.name)}: ${renderField(f)}`);
|
|
104
|
+
parts.push(`fields: { ${fields.join(", ")} }`);
|
|
105
|
+
}
|
|
106
|
+
if (type.kind === "UNION" && type.possibleTypes) {
|
|
107
|
+
const names = type.possibleTypes.map((p) => JSON.stringify(namedTypeName(p)));
|
|
108
|
+
parts.push(`possibleTypes: [${names.join(", ")}]`);
|
|
109
|
+
}
|
|
110
|
+
return `{ ${parts.join(", ")} }`;
|
|
111
|
+
}
|
|
112
|
+
function renderField(field) {
|
|
113
|
+
const parts = [`name: ${JSON.stringify(field.name)}`, `type: ${JSON.stringify(namedTypeName(field.type))}`];
|
|
114
|
+
if (isListType(field.type)) parts.push(`list: true`);
|
|
115
|
+
if (isNonNull(field.type)) parts.push(`nonNull: true`);
|
|
116
|
+
if (field.args.length > 0) {
|
|
117
|
+
const args = field.args.map((a) => `{ name: ${JSON.stringify(a.name)}, type: ${JSON.stringify(renderGraphQLType(a.type))} }`);
|
|
118
|
+
parts.push(`args: [${args.join(", ")}]`);
|
|
119
|
+
}
|
|
120
|
+
return `{ ${parts.join(", ")} }`;
|
|
121
|
+
}
|
|
122
|
+
//#endregion
|
|
123
|
+
//#region src/types.ts
|
|
124
|
+
function generateTypes(schema, options = {}) {
|
|
125
|
+
const ctx = { scalars: {
|
|
126
|
+
...DEFAULT_SCALAR_TYPES,
|
|
127
|
+
...options.scalarTypes
|
|
128
|
+
} };
|
|
129
|
+
const blocks = [];
|
|
130
|
+
blocks.push(`/** Generated from GraphQL introspection. Do not edit by hand. */`);
|
|
131
|
+
for (const type of schema.types) {
|
|
132
|
+
if (isInternalType(type.name) || type.kind === "SCALAR") continue;
|
|
133
|
+
const block = renderTypeBlock(type, ctx);
|
|
134
|
+
if (block) blocks.push(block);
|
|
135
|
+
}
|
|
136
|
+
return blocks.join("\n\n") + "\n";
|
|
137
|
+
}
|
|
138
|
+
function renderTypeBlock(type, ctx) {
|
|
139
|
+
switch (type.kind) {
|
|
140
|
+
case "OBJECT": return renderObjectLike(type, ctx, JSON.stringify(type.name));
|
|
141
|
+
case "INTERFACE": return renderObjectLike(type, ctx, typenameUnionOf(type));
|
|
142
|
+
case "UNION": return renderUnion(type);
|
|
143
|
+
case "ENUM": return renderEnum(type);
|
|
144
|
+
case "INPUT_OBJECT": return renderInputObject(type, ctx);
|
|
145
|
+
default: return;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** An interface's `__typename` is the union of its possible types; an object's is its own name. */
|
|
149
|
+
function typenameUnionOf(type) {
|
|
150
|
+
return type.possibleTypes && type.possibleTypes.length > 0 ? type.possibleTypes.map((p) => JSON.stringify(namedTypeName(p))).join(" | ") : "string";
|
|
151
|
+
}
|
|
152
|
+
function renderObjectLike(type, ctx, typenameTs) {
|
|
153
|
+
const members = [` __typename: ${typenameTs};`];
|
|
154
|
+
for (const field of type.fields ?? []) members.push(" " + renderFieldMember(field.name, field.args, field.type, ctx));
|
|
155
|
+
return `export interface ${type.name} {\n${members.join("\n")}\n}`;
|
|
156
|
+
}
|
|
157
|
+
function renderUnion(type) {
|
|
158
|
+
const members = (type.possibleTypes ?? []).map((p) => namedTypeName(p));
|
|
159
|
+
const body = members.length > 0 ? members.join(" | ") : "never";
|
|
160
|
+
return `export type ${type.name} = ${body};`;
|
|
161
|
+
}
|
|
162
|
+
function renderEnum(type) {
|
|
163
|
+
const values = (type.enumValues ?? []).map((v) => JSON.stringify(v.name));
|
|
164
|
+
const body = values.length > 0 ? values.join(" | ") : "never";
|
|
165
|
+
return `export type ${type.name} = ${body};`;
|
|
166
|
+
}
|
|
167
|
+
function renderInputObject(type, ctx) {
|
|
168
|
+
const members = (type.inputFields ?? []).map((f) => " " + renderInputMember(f, ctx));
|
|
169
|
+
return `export interface ${type.name} {\n${members.join("\n")}\n}`;
|
|
170
|
+
}
|
|
171
|
+
/** A field is callable when it declares arguments; otherwise it is a property. */
|
|
172
|
+
function renderFieldMember(name, args, type, ctx) {
|
|
173
|
+
const ret = renderTs(type, ctx.scalars);
|
|
174
|
+
if (args.length > 0) {
|
|
175
|
+
const argList = args.map((a) => renderInputMember(a, ctx)).join(" ");
|
|
176
|
+
return `${propKey(name)}(args: { ${argList} }): ${ret};`;
|
|
177
|
+
}
|
|
178
|
+
return `${propKey(name)}: ${ret};`;
|
|
179
|
+
}
|
|
180
|
+
function renderInputMember(input, ctx) {
|
|
181
|
+
const optional = input.type.kind !== "NON_NULL";
|
|
182
|
+
return `${propKey(input.name)}${optional ? "?" : ""}: ${renderTs(input.type, ctx.scalars)};`;
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/graph.ts
|
|
186
|
+
function generateGraph(schema, options = {}) {
|
|
187
|
+
const scalars = {
|
|
188
|
+
...DEFAULT_SCALAR_TYPES,
|
|
189
|
+
...options.scalarTypes
|
|
190
|
+
};
|
|
191
|
+
const schemaImportPath = options.schemaImportPath ?? "./schema.js";
|
|
192
|
+
const rootFields = schema.types.find((t) => t.name === schema.queryType.name)?.fields ?? [];
|
|
193
|
+
const emitted = /* @__PURE__ */ new Map();
|
|
194
|
+
for (const t of schema.types) if (!isInternalType(t.name) && t.kind !== "SCALAR") emitted.set(t.name, t.kind);
|
|
195
|
+
const imports = /* @__PURE__ */ new Set();
|
|
196
|
+
const collect = (ref) => {
|
|
197
|
+
const name = namedTypeName(ref);
|
|
198
|
+
if (emitted.has(name)) imports.add(name);
|
|
199
|
+
};
|
|
200
|
+
for (const field of rootFields) {
|
|
201
|
+
collect(field.type);
|
|
202
|
+
for (const arg of field.args) collect(arg.type);
|
|
203
|
+
}
|
|
204
|
+
const lines = [];
|
|
205
|
+
lines.push(`/** Generated from GraphQL introspection. Do not edit by hand. */`);
|
|
206
|
+
if (imports.size > 0) {
|
|
207
|
+
const names = [...imports].sort().join(", ");
|
|
208
|
+
lines.push(`import type { ${names} } from ${JSON.stringify(schemaImportPath)};`);
|
|
209
|
+
lines.push("");
|
|
210
|
+
}
|
|
211
|
+
lines.push(`export const glean = {`);
|
|
212
|
+
for (const field of rootFields) lines.push(indent(renderRoot(field, scalars), 2));
|
|
213
|
+
lines.push(` components<T extends Record<string, unknown>>(map: T): T {`);
|
|
214
|
+
lines.push(` return map;`);
|
|
215
|
+
lines.push(` },`);
|
|
216
|
+
lines.push(`};`);
|
|
217
|
+
return lines.join("\n") + "\n";
|
|
218
|
+
}
|
|
219
|
+
function renderRoot(field, scalars) {
|
|
220
|
+
const ret = renderTs(field.type, scalars);
|
|
221
|
+
const params = field.args.length > 0 ? `args: { ${field.args.map((a) => renderArg(a.name, a.type, scalars)).join(" ")} }` : "";
|
|
222
|
+
return [
|
|
223
|
+
`${propKey(field.name)}(${params}): ${ret} {`,
|
|
224
|
+
` return undefined as unknown as ${ret};`,
|
|
225
|
+
`},`
|
|
226
|
+
].join("\n");
|
|
227
|
+
}
|
|
228
|
+
function renderArg(name, type, scalars) {
|
|
229
|
+
const optional = type.kind !== "NON_NULL";
|
|
230
|
+
return `${propKey(name)}${optional ? "?" : ""}: ${renderTs(type, scalars)};`;
|
|
231
|
+
}
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region src/index.ts
|
|
234
|
+
/** Generate all three source files from an introspection schema. */
|
|
235
|
+
function generateSchemaPackage(schema, options = {}) {
|
|
236
|
+
return {
|
|
237
|
+
schemaModel: generateSchemaModel(schema, options),
|
|
238
|
+
types: generateTypes(schema, options),
|
|
239
|
+
graph: generateGraph(schema, options)
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
//#endregion
|
|
243
|
+
export { DEFAULT_SCALAR_TYPES, generateGraph, generateSchemaModel, generateSchemaPackage, generateTypes, isInternalType, isListType, isNonNull, namedTypeName, renderGraphQLType };
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gleanql/codegen",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Glean's schema codegen: introspection to schema model, branded types and graph accessors",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Alexander Liljengard",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/gleanql/gleanql.git",
|
|
10
|
+
"directory": "packages/codegen"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "./dist/index.mjs",
|
|
14
|
+
"types": "./dist/index.d.mts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.mts",
|
|
18
|
+
"default": "./dist/index.mjs"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"src"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@gleanql/core": "0.1.0"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://gleanql.com",
|
|
32
|
+
"bugs": "https://github.com/gleanql/gleanql/issues",
|
|
33
|
+
"keywords": [
|
|
34
|
+
"graphql",
|
|
35
|
+
"codegen",
|
|
36
|
+
"typescript",
|
|
37
|
+
"introspection"
|
|
38
|
+
],
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20"
|
|
41
|
+
},
|
|
42
|
+
"sideEffects": false,
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsdown src/index.ts --format esm --dts.eager"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/graph.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isInternalType,
|
|
3
|
+
namedTypeName,
|
|
4
|
+
type IntrospectionField,
|
|
5
|
+
type IntrospectionSchema,
|
|
6
|
+
type IntrospectionTypeRef,
|
|
7
|
+
} from "./introspection.js";
|
|
8
|
+
import { DEFAULT_SCALAR_TYPES, indent, propKey, renderTs } from "./ts-render.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate the `graph` accessor object: one method per Query root field plus the
|
|
12
|
+
* `components(...)` registry helper. At build time the compiler reads these to
|
|
13
|
+
* learn the root fields and their types; at runtime they are backed by the
|
|
14
|
+
* runtime's bound graph (see `@gleanql/react`'s `bindGraph`). The generated bodies
|
|
15
|
+
* are typed stubs — the real values are proxies.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export interface GenerateGraphOptions {
|
|
19
|
+
readonly scalarTypes?: Record<string, string>;
|
|
20
|
+
/** Import path for the generated branded types. Default: `./schema.js`. */
|
|
21
|
+
readonly schemaImportPath?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function generateGraph(schema: IntrospectionSchema, options: GenerateGraphOptions = {}): string {
|
|
25
|
+
const scalars = { ...DEFAULT_SCALAR_TYPES, ...options.scalarTypes };
|
|
26
|
+
const schemaImportPath = options.schemaImportPath ?? "./schema.js";
|
|
27
|
+
|
|
28
|
+
const queryType = schema.types.find((t) => t.name === schema.queryType.name);
|
|
29
|
+
const rootFields = queryType?.fields ?? [];
|
|
30
|
+
|
|
31
|
+
// Which named types are emitted in schema.ts (so we can import them).
|
|
32
|
+
const emitted = new Map<string, string>();
|
|
33
|
+
for (const t of schema.types) {
|
|
34
|
+
if (!isInternalType(t.name) && t.kind !== "SCALAR") emitted.set(t.name, t.kind);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const imports = new Set<string>();
|
|
38
|
+
const collect = (ref: IntrospectionTypeRef): void => {
|
|
39
|
+
const name = namedTypeName(ref);
|
|
40
|
+
if (emitted.has(name)) imports.add(name);
|
|
41
|
+
};
|
|
42
|
+
for (const field of rootFields) {
|
|
43
|
+
collect(field.type);
|
|
44
|
+
for (const arg of field.args) collect(arg.type);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const lines: string[] = [];
|
|
48
|
+
lines.push(`/** Generated from GraphQL introspection. Do not edit by hand. */`);
|
|
49
|
+
if (imports.size > 0) {
|
|
50
|
+
const names = [...imports].sort().join(", ");
|
|
51
|
+
lines.push(`import type { ${names} } from ${JSON.stringify(schemaImportPath)};`);
|
|
52
|
+
lines.push("");
|
|
53
|
+
}
|
|
54
|
+
lines.push(`export const glean = {`);
|
|
55
|
+
for (const field of rootFields) {
|
|
56
|
+
lines.push(indent(renderRoot(field, scalars), 2));
|
|
57
|
+
}
|
|
58
|
+
lines.push(` components<T extends Record<string, unknown>>(map: T): T {`);
|
|
59
|
+
lines.push(` return map;`);
|
|
60
|
+
lines.push(` },`);
|
|
61
|
+
lines.push(`};`);
|
|
62
|
+
return lines.join("\n") + "\n";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderRoot(field: IntrospectionField, scalars: Record<string, string>): string {
|
|
66
|
+
const ret = renderTs(field.type, scalars);
|
|
67
|
+
const params = field.args.length > 0 ? `args: { ${field.args.map((a) => renderArg(a.name, a.type, scalars)).join(" ")} }` : "";
|
|
68
|
+
return [
|
|
69
|
+
`${propKey(field.name)}(${params}): ${ret} {`,
|
|
70
|
+
` return undefined as unknown as ${ret};`,
|
|
71
|
+
`},`,
|
|
72
|
+
].join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderArg(name: string, type: IntrospectionTypeRef, scalars: Record<string, string>): string {
|
|
76
|
+
const optional = type.kind !== "NON_NULL";
|
|
77
|
+
return `${propKey(name)}${optional ? "?" : ""}: ${renderTs(type, scalars)};`;
|
|
78
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { IntrospectionSchema } from "./introspection.js";
|
|
2
|
+
import { generateSchemaModel, type GenerateSchemaModelOptions } from "./schema-model.js";
|
|
3
|
+
import { generateTypes, type GenerateTypesOptions } from "./types.js";
|
|
4
|
+
import { generateGraph, type GenerateGraphOptions } from "./graph.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `@gleanql/codegen` — generate a schema package from GraphQL introspection.
|
|
8
|
+
*
|
|
9
|
+
* The brief: "Generate a schema package from GraphQL introspection when the API
|
|
10
|
+
* changes." This produces the three files the rest of the system consumes —
|
|
11
|
+
* machine-generated equivalents of what was previously hand-authored:
|
|
12
|
+
*
|
|
13
|
+
* schema-model.ts the SchemaModel the compiler + runtime read
|
|
14
|
+
* schema.ts branded TS types (so TypeScript catches API drift)
|
|
15
|
+
* graph.ts the `graph.product(...)` accessors + `components(...)`
|
|
16
|
+
*
|
|
17
|
+
* Consume a real introspection result (`introspectionFromSchema(schema).__schema`
|
|
18
|
+
* or the `data.__schema` from an introspection query).
|
|
19
|
+
*/
|
|
20
|
+
export * from "./introspection.js";
|
|
21
|
+
export * from "./schema-model.js";
|
|
22
|
+
export * from "./types.js";
|
|
23
|
+
export * from "./graph.js";
|
|
24
|
+
|
|
25
|
+
export interface GeneratedSchemaPackage {
|
|
26
|
+
readonly schemaModel: string;
|
|
27
|
+
readonly types: string;
|
|
28
|
+
readonly graph: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface GenerateSchemaPackageOptions
|
|
32
|
+
extends GenerateSchemaModelOptions,
|
|
33
|
+
GenerateTypesOptions,
|
|
34
|
+
GenerateGraphOptions {}
|
|
35
|
+
|
|
36
|
+
/** Generate all three source files from an introspection schema. */
|
|
37
|
+
export function generateSchemaPackage(
|
|
38
|
+
schema: IntrospectionSchema,
|
|
39
|
+
options: GenerateSchemaPackageOptions = {},
|
|
40
|
+
): GeneratedSchemaPackage {
|
|
41
|
+
return {
|
|
42
|
+
schemaModel: generateSchemaModel(schema, options),
|
|
43
|
+
types: generateTypes(schema, options),
|
|
44
|
+
graph: generateGraph(schema, options),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphQL introspection model + type-ref helpers.
|
|
3
|
+
*
|
|
4
|
+
* We consume the standard introspection result (`data.__schema`) directly rather
|
|
5
|
+
* than depend on graphql-js at runtime — the shape is stable and small. These
|
|
6
|
+
* structural types match what `introspectionFromSchema()` produces, so a real
|
|
7
|
+
* introspection drops straight in.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type IntrospectionTypeKind = "SCALAR" | "OBJECT" | "INTERFACE" | "UNION" | "ENUM" | "INPUT_OBJECT" | "LIST" | "NON_NULL";
|
|
11
|
+
|
|
12
|
+
export type IntrospectionTypeRef =
|
|
13
|
+
| { readonly kind: "NON_NULL"; readonly ofType: IntrospectionTypeRef }
|
|
14
|
+
| { readonly kind: "LIST"; readonly ofType: IntrospectionTypeRef }
|
|
15
|
+
| { readonly kind: "SCALAR" | "OBJECT" | "INTERFACE" | "UNION" | "ENUM" | "INPUT_OBJECT"; readonly name: string; readonly ofType?: null };
|
|
16
|
+
|
|
17
|
+
export interface IntrospectionInputValue {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly type: IntrospectionTypeRef;
|
|
20
|
+
readonly defaultValue?: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface IntrospectionField {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly args: readonly IntrospectionInputValue[];
|
|
26
|
+
readonly type: IntrospectionTypeRef;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface IntrospectionType {
|
|
30
|
+
readonly kind: IntrospectionTypeKind;
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly description?: string | null;
|
|
33
|
+
readonly fields?: readonly IntrospectionField[] | null;
|
|
34
|
+
readonly inputFields?: readonly IntrospectionInputValue[] | null;
|
|
35
|
+
readonly interfaces?: readonly IntrospectionTypeRef[] | null;
|
|
36
|
+
readonly enumValues?: ReadonlyArray<{ readonly name: string }> | null;
|
|
37
|
+
readonly possibleTypes?: readonly IntrospectionTypeRef[] | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface IntrospectionSchema {
|
|
41
|
+
readonly queryType: { readonly name: string };
|
|
42
|
+
readonly mutationType?: { readonly name: string } | null;
|
|
43
|
+
readonly subscriptionType?: { readonly name: string } | null;
|
|
44
|
+
readonly types: readonly IntrospectionType[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The named type at the bottom of a (possibly list/non-null) type ref. */
|
|
48
|
+
export function namedTypeName(ref: IntrospectionTypeRef): string {
|
|
49
|
+
let cur: IntrospectionTypeRef = ref;
|
|
50
|
+
while (cur.kind === "LIST" || cur.kind === "NON_NULL") cur = cur.ofType;
|
|
51
|
+
return cur.name;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True if a LIST appears anywhere in the type ref. */
|
|
55
|
+
export function isListType(ref: IntrospectionTypeRef): boolean {
|
|
56
|
+
let cur: IntrospectionTypeRef = ref;
|
|
57
|
+
while (cur.kind === "LIST" || cur.kind === "NON_NULL") {
|
|
58
|
+
if (cur.kind === "LIST") return true;
|
|
59
|
+
cur = cur.ofType;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** True if the outermost wrapper is NON_NULL. */
|
|
65
|
+
export function isNonNull(ref: IntrospectionTypeRef): boolean {
|
|
66
|
+
return ref.kind === "NON_NULL";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Render a GraphQL SDL type ref string, e.g. `[Product!]!`. */
|
|
70
|
+
export function renderGraphQLType(ref: IntrospectionTypeRef): string {
|
|
71
|
+
switch (ref.kind) {
|
|
72
|
+
case "NON_NULL":
|
|
73
|
+
return `${renderGraphQLType(ref.ofType)}!`;
|
|
74
|
+
case "LIST":
|
|
75
|
+
return `[${renderGraphQLType(ref.ofType)}]`;
|
|
76
|
+
default:
|
|
77
|
+
return ref.name;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Introspection meta types (`__Schema`, `__Type`, …) we never generate. */
|
|
82
|
+
export function isInternalType(name: string): boolean {
|
|
83
|
+
return name.startsWith("__");
|
|
84
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isInternalType,
|
|
3
|
+
isListType,
|
|
4
|
+
isNonNull,
|
|
5
|
+
namedTypeName,
|
|
6
|
+
renderGraphQLType,
|
|
7
|
+
type IntrospectionField,
|
|
8
|
+
type IntrospectionSchema,
|
|
9
|
+
type IntrospectionType,
|
|
10
|
+
} from "./introspection.js";
|
|
11
|
+
import { indent, propKey } from "./ts-render.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generate the `SchemaModel` source (`defineSchema({...})`) the compiler and
|
|
15
|
+
* runtime consume. This is the machine-generated equivalent of the previously
|
|
16
|
+
* hand-authored `schema-model.ts`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const KIND_MAP: Record<string, string> = {
|
|
20
|
+
SCALAR: "scalar",
|
|
21
|
+
OBJECT: "object",
|
|
22
|
+
INTERFACE: "interface",
|
|
23
|
+
UNION: "union",
|
|
24
|
+
ENUM: "enum",
|
|
25
|
+
INPUT_OBJECT: "input",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface GenerateSchemaModelOptions {
|
|
29
|
+
/** Exported const name for the SchemaModel. Default: `schema`. */
|
|
30
|
+
readonly exportName?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function generateSchemaModel(schema: IntrospectionSchema, options: GenerateSchemaModelOptions = {}): string {
|
|
34
|
+
const exportName = options.exportName ?? "schema";
|
|
35
|
+
const types = schema.types
|
|
36
|
+
.filter((t) => !isInternalType(t.name))
|
|
37
|
+
.map((t) => renderType(t))
|
|
38
|
+
.filter((s): s is string => s !== undefined);
|
|
39
|
+
|
|
40
|
+
const lines: string[] = [];
|
|
41
|
+
lines.push(`import { defineSchema, type SchemaModel } from "@gleanql/core";`);
|
|
42
|
+
lines.push("");
|
|
43
|
+
lines.push(`/** Generated from GraphQL introspection. Do not edit by hand. */`);
|
|
44
|
+
lines.push(`export const ${exportName}: SchemaModel = defineSchema({`);
|
|
45
|
+
lines.push(` queryType: ${JSON.stringify(schema.queryType.name)},`);
|
|
46
|
+
if (schema.mutationType) lines.push(` mutationType: ${JSON.stringify(schema.mutationType.name)},`);
|
|
47
|
+
if (schema.subscriptionType) lines.push(` subscriptionType: ${JSON.stringify(schema.subscriptionType.name)},`);
|
|
48
|
+
lines.push(` types: [`);
|
|
49
|
+
for (const t of types) lines.push(indent(t, 4) + ",");
|
|
50
|
+
lines.push(` ],`);
|
|
51
|
+
lines.push(`});`);
|
|
52
|
+
return lines.join("\n") + "\n";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function renderType(type: IntrospectionType): string | undefined {
|
|
56
|
+
const kind = KIND_MAP[type.kind];
|
|
57
|
+
if (!kind) return undefined; // LIST/NON_NULL never appear at top level
|
|
58
|
+
|
|
59
|
+
const parts: string[] = [`name: ${JSON.stringify(type.name)}`, `kind: ${JSON.stringify(kind)}`];
|
|
60
|
+
|
|
61
|
+
if ((type.kind === "OBJECT" || type.kind === "INTERFACE") && type.fields) {
|
|
62
|
+
const fields = type.fields.map((f) => `${propKey(f.name)}: ${renderField(f)}`);
|
|
63
|
+
parts.push(`fields: { ${fields.join(", ")} }`);
|
|
64
|
+
}
|
|
65
|
+
if (type.kind === "UNION" && type.possibleTypes) {
|
|
66
|
+
const names = type.possibleTypes.map((p) => JSON.stringify(namedTypeName(p)));
|
|
67
|
+
parts.push(`possibleTypes: [${names.join(", ")}]`);
|
|
68
|
+
}
|
|
69
|
+
return `{ ${parts.join(", ")} }`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderField(field: IntrospectionField): string {
|
|
73
|
+
const parts: string[] = [`name: ${JSON.stringify(field.name)}`, `type: ${JSON.stringify(namedTypeName(field.type))}`];
|
|
74
|
+
if (isListType(field.type)) parts.push(`list: true`);
|
|
75
|
+
if (isNonNull(field.type)) parts.push(`nonNull: true`);
|
|
76
|
+
if (field.args.length > 0) {
|
|
77
|
+
const args = field.args.map((a) => `{ name: ${JSON.stringify(a.name)}, type: ${JSON.stringify(renderGraphQLType(a.type))} }`);
|
|
78
|
+
parts.push(`args: [${args.join(", ")}]`);
|
|
79
|
+
}
|
|
80
|
+
return `{ ${parts.join(", ")} }`;
|
|
81
|
+
}
|
package/src/ts-render.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { IntrospectionTypeRef } from "./introspection.js";
|
|
2
|
+
|
|
3
|
+
/** Shared TypeScript-type rendering for branded types and the graph accessors. */
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_SCALAR_TYPES: Record<string, string> = {
|
|
6
|
+
String: "string",
|
|
7
|
+
ID: "string",
|
|
8
|
+
Int: "number",
|
|
9
|
+
Float: "number",
|
|
10
|
+
Boolean: "boolean",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Render a TS type from a GraphQL type ref, honoring list + nullability nesting. */
|
|
14
|
+
export function renderTs(ref: IntrospectionTypeRef, scalars: Record<string, string>): string {
|
|
15
|
+
if (ref.kind === "NON_NULL") return renderTsInner(ref.ofType, scalars);
|
|
16
|
+
return `${renderTsInner(ref, scalars)} | null`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function renderTsInner(ref: IntrospectionTypeRef, scalars: Record<string, string>): string {
|
|
20
|
+
if (ref.kind === "NON_NULL") return renderTsInner(ref.ofType, scalars);
|
|
21
|
+
if (ref.kind === "LIST") {
|
|
22
|
+
const el = renderTs(ref.ofType, scalars);
|
|
23
|
+
return `${el.includes(" ") ? `(${el})` : el}[]`;
|
|
24
|
+
}
|
|
25
|
+
if (ref.kind === "SCALAR") return scalars[ref.name] ?? "string";
|
|
26
|
+
return ref.name; // OBJECT / INTERFACE / UNION / ENUM / INPUT_OBJECT
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A bare property key when safe, else a quoted key. */
|
|
30
|
+
export function propKey(name: string): string {
|
|
31
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Indent every non-empty line of `text` by `spaces` columns. */
|
|
35
|
+
export function indent(text: string, spaces: number): string {
|
|
36
|
+
const pad = " ".repeat(spaces);
|
|
37
|
+
return text
|
|
38
|
+
.split("\n")
|
|
39
|
+
.map((line) => (line ? pad + line : line))
|
|
40
|
+
.join("\n");
|
|
41
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isInternalType,
|
|
3
|
+
namedTypeName,
|
|
4
|
+
type IntrospectionInputValue,
|
|
5
|
+
type IntrospectionSchema,
|
|
6
|
+
type IntrospectionType,
|
|
7
|
+
type IntrospectionTypeRef,
|
|
8
|
+
} from "./introspection.js";
|
|
9
|
+
import { DEFAULT_SCALAR_TYPES, propKey, renderTs } from "./ts-render.js";
|
|
10
|
+
|
|
11
|
+
export { DEFAULT_SCALAR_TYPES } from "./ts-render.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Generate branded TypeScript types from introspection.
|
|
15
|
+
*
|
|
16
|
+
* To app code these read as ordinary schema types (`Product`, `Image`, …); the
|
|
17
|
+
* compiler recognizes them via the `__typename` brand. Nullability, lists,
|
|
18
|
+
* callable fields (field arguments), enums, interfaces, unions and input objects
|
|
19
|
+
* all surface — so TypeScript catches API drift (a removed/renamed field, a
|
|
20
|
+
* tightened nullability) at compile time.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface GenerateTypesOptions {
|
|
24
|
+
/** TS type for each GraphQL scalar. Unlisted custom scalars default to `string`. */
|
|
25
|
+
readonly scalarTypes?: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function generateTypes(schema: IntrospectionSchema, options: GenerateTypesOptions = {}): string {
|
|
29
|
+
const scalars = { ...DEFAULT_SCALAR_TYPES, ...options.scalarTypes };
|
|
30
|
+
const ctx = { scalars };
|
|
31
|
+
|
|
32
|
+
const blocks: string[] = [];
|
|
33
|
+
blocks.push(`/** Generated from GraphQL introspection. Do not edit by hand. */`);
|
|
34
|
+
|
|
35
|
+
for (const type of schema.types) {
|
|
36
|
+
if (isInternalType(type.name) || type.kind === "SCALAR") continue;
|
|
37
|
+
const block = renderTypeBlock(type, ctx);
|
|
38
|
+
if (block) blocks.push(block);
|
|
39
|
+
}
|
|
40
|
+
return blocks.join("\n\n") + "\n";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Ctx {
|
|
44
|
+
readonly scalars: Record<string, string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderTypeBlock(type: IntrospectionType, ctx: Ctx): string | undefined {
|
|
48
|
+
switch (type.kind) {
|
|
49
|
+
case "OBJECT":
|
|
50
|
+
return renderObjectLike(type, ctx, JSON.stringify(type.name));
|
|
51
|
+
case "INTERFACE":
|
|
52
|
+
return renderObjectLike(type, ctx, typenameUnionOf(type));
|
|
53
|
+
case "UNION":
|
|
54
|
+
return renderUnion(type);
|
|
55
|
+
case "ENUM":
|
|
56
|
+
return renderEnum(type);
|
|
57
|
+
case "INPUT_OBJECT":
|
|
58
|
+
return renderInputObject(type, ctx);
|
|
59
|
+
default:
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** An interface's `__typename` is the union of its possible types; an object's is its own name. */
|
|
65
|
+
function typenameUnionOf(type: IntrospectionType): string {
|
|
66
|
+
return type.possibleTypes && type.possibleTypes.length > 0
|
|
67
|
+
? type.possibleTypes.map((p) => JSON.stringify(namedTypeName(p))).join(" | ")
|
|
68
|
+
: "string";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderObjectLike(type: IntrospectionType, ctx: Ctx, typenameTs: string): string {
|
|
72
|
+
const members: string[] = [` __typename: ${typenameTs};`];
|
|
73
|
+
for (const field of type.fields ?? []) {
|
|
74
|
+
members.push(" " + renderFieldMember(field.name, field.args, field.type, ctx));
|
|
75
|
+
}
|
|
76
|
+
return `export interface ${type.name} {\n${members.join("\n")}\n}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function renderUnion(type: IntrospectionType): string {
|
|
80
|
+
const members = (type.possibleTypes ?? []).map((p) => namedTypeName(p));
|
|
81
|
+
const body = members.length > 0 ? members.join(" | ") : "never";
|
|
82
|
+
return `export type ${type.name} = ${body};`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderEnum(type: IntrospectionType): string {
|
|
86
|
+
const values = (type.enumValues ?? []).map((v) => JSON.stringify(v.name));
|
|
87
|
+
const body = values.length > 0 ? values.join(" | ") : "never";
|
|
88
|
+
return `export type ${type.name} = ${body};`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function renderInputObject(type: IntrospectionType, ctx: Ctx): string {
|
|
92
|
+
const members = (type.inputFields ?? []).map((f) => " " + renderInputMember(f, ctx));
|
|
93
|
+
return `export interface ${type.name} {\n${members.join("\n")}\n}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A field is callable when it declares arguments; otherwise it is a property. */
|
|
97
|
+
function renderFieldMember(
|
|
98
|
+
name: string,
|
|
99
|
+
args: readonly IntrospectionInputValue[],
|
|
100
|
+
type: IntrospectionTypeRef,
|
|
101
|
+
ctx: Ctx,
|
|
102
|
+
): string {
|
|
103
|
+
const ret = renderTs(type, ctx.scalars);
|
|
104
|
+
if (args.length > 0) {
|
|
105
|
+
const argList = args.map((a) => renderInputMember(a, ctx)).join(" ");
|
|
106
|
+
return `${propKey(name)}(args: { ${argList} }): ${ret};`;
|
|
107
|
+
}
|
|
108
|
+
return `${propKey(name)}: ${ret};`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderInputMember(input: IntrospectionInputValue, ctx: Ctx): string {
|
|
112
|
+
const optional = input.type.kind !== "NON_NULL";
|
|
113
|
+
return `${propKey(input.name)}${optional ? "?" : ""}: ${renderTs(input.type, ctx.scalars)};`;
|
|
114
|
+
}
|