@capacms/sdk 1.0.0-next.4 → 1.0.0-next.6

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/README.md +1043 -186
  3. package/bin/capa-codegen.js +192 -5
  4. package/bin/capa.js +208 -0
  5. package/bin/graphql-project.js +142 -0
  6. package/bin/project-env.js +58 -0
  7. package/dist/client.d.ts +5 -0
  8. package/dist/client.js +17 -0
  9. package/dist/codegen.d.ts +55 -0
  10. package/dist/codegen.js +320 -39
  11. package/dist/config.d.ts +5 -1
  12. package/dist/graphql-codegen.d.ts +117 -0
  13. package/dist/graphql-codegen.js +705 -0
  14. package/dist/http.js +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +2 -1
  17. package/dist/next/attrs.d.ts +51 -12
  18. package/dist/next/attrs.js +74 -20
  19. package/dist/next/client.d.ts +111 -38
  20. package/dist/next/client.js +116 -82
  21. package/dist/next/entry-fields.d.ts +162 -0
  22. package/dist/next/entry-fields.js +2 -0
  23. package/dist/next/errors.d.ts +136 -0
  24. package/dist/next/errors.js +214 -0
  25. package/dist/next/field-names.d.ts +37 -0
  26. package/dist/next/field-names.js +145 -0
  27. package/dist/next/graphql/build.d.ts +27 -0
  28. package/dist/next/graphql/build.js +98 -0
  29. package/dist/next/graphql/documents.d.ts +67 -0
  30. package/dist/next/graphql/documents.js +35 -0
  31. package/dist/next/graphql/edit-mode.d.ts +16 -0
  32. package/dist/next/graphql/edit-mode.js +93 -0
  33. package/dist/next/graphql/filter-values.d.ts +34 -0
  34. package/dist/next/graphql/filter-values.js +96 -0
  35. package/dist/next/graphql/introspection.d.ts +89 -0
  36. package/dist/next/graphql/introspection.js +102 -0
  37. package/dist/next/graphql/plan.d.ts +115 -0
  38. package/dist/next/graphql/plan.js +531 -0
  39. package/dist/next/graphql/request.d.ts +228 -0
  40. package/dist/next/graphql/request.js +283 -0
  41. package/dist/next/graphql/rest.d.ts +66 -0
  42. package/dist/next/graphql/rest.js +502 -0
  43. package/dist/next/graphql/selection.d.ts +55 -0
  44. package/dist/next/graphql/selection.js +212 -0
  45. package/dist/next/graphql/sha256.d.ts +13 -0
  46. package/dist/next/graphql/sha256.js +86 -0
  47. package/dist/next/graphql/summary.d.ts +83 -0
  48. package/dist/next/graphql/summary.js +151 -0
  49. package/dist/next/graphql/tree-layout.d.ts +36 -0
  50. package/dist/next/graphql/tree-layout.js +20 -0
  51. package/dist/next/graphql/tree.d.ts +171 -0
  52. package/dist/next/graphql/tree.js +249 -0
  53. package/dist/next/graphql/typed.d.ts +261 -0
  54. package/dist/next/graphql/typed.js +146 -0
  55. package/dist/next/index.d.ts +28 -5
  56. package/dist/next/index.js +25 -1
  57. package/dist/next/inflate.d.ts +25 -7
  58. package/dist/next/inflate.js +46 -32
  59. package/dist/next/key-family.d.ts +34 -0
  60. package/dist/next/key-family.js +74 -0
  61. package/dist/next/select-types.d.ts +44 -8
  62. package/dist/next/system-keys.d.ts +27 -0
  63. package/dist/next/system-keys.js +42 -0
  64. package/dist/nextjs/index.d.ts +165 -12
  65. package/dist/nextjs/index.js +247 -23
  66. package/dist/nextjs/overlay.d.ts +5 -0
  67. package/dist/nextjs/overlay.js +35 -0
  68. package/package.json +35 -13
package/dist/codegen.d.ts CHANGED
@@ -36,6 +36,7 @@
36
36
  * committed file stable under renames.
37
37
  */
38
38
  import { type CapaConfig } from "./config";
39
+ import type { CapaIntrospection } from "./next/graphql/introspection";
39
40
  export interface CodegenResult {
40
41
  /** False when the schema checksum was unchanged and nothing was fetched. */
41
42
  changed: boolean;
@@ -49,6 +50,8 @@ export interface SchemaModelField {
49
50
  type: string;
50
51
  arrayType?: string | null;
51
52
  relationRef?: string | null;
53
+ enumValues?: string[] | null;
54
+ required?: boolean | null;
52
55
  }
53
56
  export interface SchemaModel {
54
57
  id?: string;
@@ -60,6 +63,23 @@ export interface SchemaForTypes {
60
63
  }
61
64
  /** Read the checksum a previous run stamped into the generated file. */
62
65
  export declare function readStampedChecksum(existing: string | null | undefined): string | null;
66
+ /**
67
+ * The interface name for a name `/v2/schema/types` wrote. That route prints
68
+ * the PascalCase of the namespace whatever it holds, which is not always an
69
+ * identifier (`2024Events`, `Blog.posts`), and it is frozen legacy, so the
70
+ * file is made to parse here. The rule is GraphQL's (N1): every character
71
+ * outside [0-9A-Za-z] dropped and `_` before a leading digit, so
72
+ * `2024_events` is `_2024Events` in REST types and GraphQL types alike.
73
+ */
74
+ export declare function interfaceName(written: string): string;
75
+ /**
76
+ * Each model's interface name, by namespace. Two namespaces can give one
77
+ * PascalCase name (`twin_a` and `twin-a` are both `TwinA`), as can a model and
78
+ * another's `Select` or `Attrs` alias, or a shared declaration, and a module
79
+ * cannot declare an alias twice. Every model caught in such a collision is
80
+ * named by `namespaceInterface` instead, whatever order the models come in.
81
+ */
82
+ export declare function modelInterfaceNames(models: readonly SchemaModel[]): Map<string, string>;
63
83
  export declare function normalizeTypes(source: string, checksum: string | null, schema?: SchemaForTypes | null): string;
64
84
  export declare function typeNames(source: string): string[];
65
85
  /**
@@ -67,3 +87,38 @@ export declare function typeNames(source: string): string[];
67
87
  * checksum stamped in `existing`.
68
88
  */
69
89
  export declare function generate(config: CapaConfig, existing?: string | null): Promise<CodegenResult>;
90
+ /**
91
+ * `/v2/schema/types`'s type map and preamble, so a `cap_` key, which `/v2`
92
+ * does not take, gets the interfaces a legacy key gets. Copied from
93
+ * `apps/api/src/routes/v2/schema.ts`, which is frozen legacy; a test holds the
94
+ * two equal.
95
+ */
96
+ export declare const LEGACY_TYPE_MAP: Readonly<Record<string, string>>;
97
+ export declare const LEGACY_PREAMBLE = "export interface CapaImage {\n url: string;\n alt?: string;\n width?: number;\n height?: number;\n filesize?: number;\n filename?: string;\n}\n\nexport interface CapaVideo {\n url: string;\n thumbnail?: string;\n duration?: number;\n width?: number;\n height?: number;\n filesize?: number;\n filename?: string;\n}\n\nexport interface CapaFile {\n url: string;\n filename: string;\n filesize?: number;\n type?: string;\n}\n\nexport interface CapaInstance<T = Record<string, unknown>> {\n id: string;\n title?: string;\n slug?: string;\n data: T;\n status: 'draft' | 'published';\n createdAt: string;\n updatedAt: string;\n publishedAt?: string;\n}\n\n";
98
+ export interface RestTypesResult {
99
+ source: string;
100
+ /** Interface names present in the output, sorted. */
101
+ types: string[];
102
+ /**
103
+ * Models the key reads that the GraphQL schema leaves out, since their type
104
+ * names would collide (N1). The schema says nothing of their fields, so no
105
+ * interface is written for them.
106
+ */
107
+ restOnly: string[];
108
+ }
109
+ /**
110
+ * The REST model interfaces for a key's GraphQL schema: what
111
+ * `/v2/schema/types` and `/v2/schema` give a legacy key, for a `cap_` key.
112
+ * The model namespaces, field namespaces, Capa types and relation targets
113
+ * come from the schema's descriptions (N9), so the interfaces are keyed as
114
+ * REST reads them (`open-time`), not as GraphQL renames them (`open_time`).
115
+ *
116
+ * The text is written as the legacy route writes it and goes through
117
+ * `normalizeTypes`, the one place that turns that text into a module. Three
118
+ * things GraphQL does not say are written as it can: every field is optional,
119
+ * since the schema has no required flag; an enum is `string`, since it lists
120
+ * no values; and a field GraphQL leaves out (its description's `Not exposed:`
121
+ * line) is `unknown`. No checksum is stamped: `CAPA_SCHEMA_CHECKSUM` is the
122
+ * `/v2/schema` checksum, which a `cap_` key cannot read.
123
+ */
124
+ export declare function restTypesFromIntrospection(introspection: CapaIntrospection): RestTypesResult;
package/dist/codegen.js CHANGED
@@ -1,9 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LEGACY_PREAMBLE = exports.LEGACY_TYPE_MAP = void 0;
3
4
  exports.readStampedChecksum = readStampedChecksum;
5
+ exports.interfaceName = interfaceName;
6
+ exports.modelInterfaceNames = modelInterfaceNames;
4
7
  exports.normalizeTypes = normalizeTypes;
5
8
  exports.typeNames = typeNames;
6
9
  exports.generate = generate;
10
+ exports.restTypesFromIntrospection = restTypesFromIntrospection;
7
11
  /**
8
12
  * codegen.ts — pull a tenant's generated types and write them to disk.
9
13
  *
@@ -43,6 +47,7 @@ exports.generate = generate;
43
47
  */
44
48
  const config_1 = require("./config");
45
49
  const http_1 = require("./http");
50
+ const summary_1 = require("./next/graphql/summary");
46
51
  const MARKER = "// @capa-schema-checksum ";
47
52
  /**
48
53
  * The same checksum as a VALUE, not just a comment, so a site can send it back.
@@ -69,48 +74,174 @@ function readStampedChecksum(existing) {
69
74
  }
70
75
  return null;
71
76
  }
72
- /**
73
- * Sort declarations by name so the file is stable regardless of the order the
74
- * server emitted them in. The leading comment block is kept as a preamble; the
75
- * shared `Capa*` helpers are kept ahead of tenant types because they are the
76
- * primitives everything else refers to.
77
- */
77
+ /** PascalCase of a namespace, as `/v2/schema/types` names a model's interface. */
78
78
  function toPascalCase(str) {
79
79
  return str
80
80
  .split(/[-_]/)
81
81
  .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
82
82
  .join("");
83
83
  }
84
- function relationTarget(ref, models) {
84
+ /** A TypeScript identifier: what an interface name must be, and a property key may be written bare. */
85
+ const IDENTIFIER = /^[A-Za-z_$][0-9A-Za-z_$]*$/;
86
+ /** A model interface's first line, as `/v2/schema/types` writes it. */
87
+ const INTERFACE_HEADER = /^export interface (.+) \{$/;
88
+ /** A property of a model interface, ` name?: type;`, the name written verbatim, or quoted by an earlier run. */
89
+ const PROPERTY = /^(\s+)(.+?)(\?)?: (.+);$/;
90
+ /**
91
+ * The interface name for a name `/v2/schema/types` wrote. That route prints
92
+ * the PascalCase of the namespace whatever it holds, which is not always an
93
+ * identifier (`2024Events`, `Blog.posts`), and it is frozen legacy, so the
94
+ * file is made to parse here. The rule is GraphQL's (N1): every character
95
+ * outside [0-9A-Za-z] dropped and `_` before a leading digit, so
96
+ * `2024_events` is `_2024Events` in REST types and GraphQL types alike.
97
+ */
98
+ function interfaceName(written) {
99
+ if (IDENTIFIER.test(written))
100
+ return written;
101
+ const letters = written.replace(/[^0-9A-Za-z]/g, "") || "_";
102
+ return /^[0-9]/.test(letters) ? `_${letters}` : letters;
103
+ }
104
+ /** The interface a model's entries are typed with, before `modelInterfaceNames` settles collisions. */
105
+ function modelInterface(namespace) {
106
+ return interfaceName(toPascalCase(namespace));
107
+ }
108
+ /** Declarations every generated file holds beside the models (`LEGACY_PREAMBLE`, `relationPreamble`). */
109
+ const SHARED_NAMES = ["CapaImage", "CapaVideo", "CapaFile", "CapaInstance", "CapaRelation", "CapaRelationList"];
110
+ /**
111
+ * A model's interface named from its whole namespace: `Model_` and the
112
+ * namespace with each character outside [0-9A-Za-z_] written `$` and its hex
113
+ * code, so `twin-a` is `Model_twin$2da`. No two namespaces give the same one.
114
+ */
115
+ function namespaceInterface(namespace) {
116
+ return `Model_${[...namespace].map((ch) => (/[0-9A-Za-z_]/.test(ch) ? ch : `$${ch.codePointAt(0).toString(16)}`)).join("")}`;
117
+ }
118
+ /**
119
+ * Each model's interface name, by namespace. Two namespaces can give one
120
+ * PascalCase name (`twin_a` and `twin-a` are both `TwinA`), as can a model and
121
+ * another's `Select` or `Attrs` alias, or a shared declaration, and a module
122
+ * cannot declare an alias twice. Every model caught in such a collision is
123
+ * named by `namespaceInterface` instead, whatever order the models come in.
124
+ */
125
+ function modelInterfaceNames(models) {
126
+ const claims = new Map(SHARED_NAMES.map((name) => [name, new Set([""])]));
127
+ for (const { namespace } of models) {
128
+ const base = modelInterface(namespace);
129
+ for (const name of [base, `${base}Select`, `${base}Attrs`]) {
130
+ if (!claims.has(name))
131
+ claims.set(name, new Set());
132
+ claims.get(name).add(namespace);
133
+ }
134
+ }
135
+ const collided = new Set([...claims.values()].filter((owners) => owners.size > 1).flatMap((owners) => [...owners]));
136
+ return new Map(models.map(({ namespace }) => [namespace, collided.has(namespace) ? namespaceInterface(namespace) : modelInterface(namespace)]));
137
+ }
138
+ /** A field namespace as a property key: bare when it is an identifier, quoted otherwise (`"am/pm_indicator"`). */
139
+ function propertyKey(namespace) {
140
+ return IDENTIFIER.test(namespace) ? namespace : JSON.stringify(namespace);
141
+ }
142
+ /** The namespace a property key names: a key an earlier run quoted is read back. */
143
+ function keyNamespace(key) {
144
+ if (!/^".*"$/.test(key))
145
+ return key;
146
+ try {
147
+ const parsed = JSON.parse(key);
148
+ return typeof parsed === "string" ? parsed : key;
149
+ }
150
+ catch {
151
+ return key;
152
+ }
153
+ }
154
+ /**
155
+ * An enum value as a TypeScript string literal. Single quotes, as
156
+ * `/v2/schema/types` writes it, so a value with nothing to escape keeps its
157
+ * bytes; that route writes `it's` and `a\b` unescaped, which does not parse or
158
+ * reads another value.
159
+ */
160
+ function enumLiteral(value) {
161
+ return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n")}'`;
162
+ }
163
+ function relationTarget(ref, models, names) {
85
164
  const target = models.find((model) => model.id === ref || model.namespace === ref);
86
- return target ? toPascalCase(target.namespace) : "unknown";
165
+ return target ? names.get(target.namespace) ?? modelInterface(target.namespace) : "unknown";
87
166
  }
88
167
  function hasRelations(schema) {
89
168
  return !!schema?.models?.some((model) => (model.fields ?? []).some((field) => (field.type === "relation" || (field.type === "array" && field.arrayType === "relation")) && field.relationRef));
90
169
  }
91
- function rewriteRelationFields(block, schema) {
92
- const name = (/^export interface (\w+)/.exec(block) || [])[1];
93
- if (!name)
94
- return block;
95
- const model = schema.models.find((candidate) => toPascalCase(candidate.namespace) === name);
96
- if (!model)
97
- return block;
98
- let out = block;
99
- for (const field of model.fields ?? []) {
100
- if (!field.relationRef)
101
- continue;
102
- const target = relationTarget(field.relationRef, schema.models);
103
- const helper = field.type === "relation"
104
- ? `CapaRelation<${target}>`
105
- : field.type === "array" && field.arrayType === "relation"
106
- ? `CapaRelationList<${target}>`
107
- : null;
108
- if (!helper)
170
+ /**
171
+ * A field's type from the schema, where the schema says more than the text:
172
+ * a relation as its branded helper (only when the schema has relations, so a
173
+ * schema without keeps its bytes), and an enum's values escaped. Null to keep
174
+ * the type the text wrote.
175
+ */
176
+ function schemaFieldType(field, models, relations, names) {
177
+ if (relations && field.relationRef) {
178
+ const target = relationTarget(field.relationRef, models, names);
179
+ if (field.type === "relation")
180
+ return `CapaRelation<${target}>`;
181
+ if (field.type === "array" && field.arrayType === "relation")
182
+ return `CapaRelationList<${target}>`;
183
+ }
184
+ if (field.type === "enum" && field.enumValues?.length)
185
+ return field.enumValues.map(enumLiteral).join(" | ");
186
+ return null;
187
+ }
188
+ /** A union of single-quoted string literals that parses: each quote and backslash inside escaped. */
189
+ const LITERAL_UNION = /^'(?:[^'\\\n]|\\.)*'(?: \| '(?:[^'\\\n]|\\.)*')*$/;
190
+ /**
191
+ * A type as the text wrote it, made to parse without the schema: a model
192
+ * interface it names (`2024Events`, `2024Events[]`) renamed as its declaration
193
+ * was, and an enum whose values the text cannot carry (`'it's fine'`) a
194
+ * `string`, since only the schema holds the values.
195
+ */
196
+ function writtenType(written, renamed) {
197
+ if (written.startsWith("'"))
198
+ return LITERAL_UNION.test(written) ? written : "string";
199
+ const [, name, list = ""] = /^(.+?)((?:\[\])?)$/.exec(written) ?? [];
200
+ return name !== undefined && renamed.has(name) ? `${renamed.get(name)}${list}` : written;
201
+ }
202
+ /**
203
+ * One model interface made to parse and typed from the schema: named `name`,
204
+ * every key that is not an identifier quoted, relations and enums as
205
+ * `schemaFieldType` says.
206
+ */
207
+ function rewriteModelBlock(block, model, name, module) {
208
+ const lines = block.split("\n");
209
+ lines[0] = `export interface ${name} {`;
210
+ for (let i = 1; i < lines.length; i += 1) {
211
+ const property = PROPERTY.exec(lines[i]);
212
+ if (!property)
109
213
  continue;
110
- const escaped = field.namespace.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
111
- out = out.replace(new RegExp(`^(\\s*${escaped}\\??:\\s*)[^;]+;`, "m"), `$1${helper};`);
214
+ const [, indent, key, optional = "", written] = property;
215
+ const namespace = keyNamespace(key);
216
+ const field = model?.fields?.find((candidate) => candidate.namespace === namespace);
217
+ const type = (field && schemaFieldType(field, module.models, module.relations, module.names)) ?? writtenType(written, module.renamed);
218
+ lines[i] = `${indent}${propertyKey(namespace)}${optional}: ${type};`;
219
+ }
220
+ return lines.join("\n");
221
+ }
222
+ /** A field's type as `/v2/schema/types` writes it from the schema: a relation as its interface, anything unmapped `unknown`. */
223
+ function legacySchemaType(field, module) {
224
+ const scalar = (type) => exports.LEGACY_TYPE_MAP[type ?? ""] ?? "unknown";
225
+ const target = () => (field.relationRef ? relationTarget(field.relationRef, module.models, module.names) : "unknown");
226
+ if (field.type === "relation" && field.relationRef)
227
+ return target();
228
+ if (field.type === "array" && field.arrayType)
229
+ return `${field.arrayType === "relation" && field.relationRef ? target() : scalar(field.arrayType)}[]`;
230
+ return scalar(field.type);
231
+ }
232
+ /**
233
+ * A model's interface written from the schema alone, as `/v2/schema/types`
234
+ * writes one. For models that route names alike (`TwinA` twice), whose text
235
+ * cannot say which block is which model.
236
+ */
237
+ function schemaModelBlock(model, module) {
238
+ const lines = [`export interface ${module.names.get(model.namespace)} {`];
239
+ for (const field of model.fields ?? []) {
240
+ const type = schemaFieldType(field, module.models, module.relations, module.names) ?? legacySchemaType(field, module);
241
+ lines.push(` ${propertyKey(field.namespace)}${field.required ? "" : "?"}: ${type};`);
112
242
  }
113
- return out;
243
+ lines.push("}");
244
+ return lines.join("\n");
114
245
  }
115
246
  function relationPreamble() {
116
247
  return [
@@ -119,13 +250,18 @@ function relationPreamble() {
119
250
  "",
120
251
  ].join("\n");
121
252
  }
122
- function selectAliases(parts, schema) {
123
- const names = new Set(parts.map((part) => (/^export interface (\w+)/.exec(part) || [])[1]).filter(Boolean));
253
+ function selectAliases(parts, schema, modelNames) {
254
+ const names = new Set(parts.map((part) => (/^export interface ([\w$]+)/.exec(part) || [])[1]).filter(Boolean));
124
255
  return schema.models
125
- .map((model) => toPascalCase(model.namespace))
256
+ .map((model) => modelNames.get(model.namespace) ?? modelInterface(model.namespace))
126
257
  .filter((name) => names.has(name))
127
258
  .sort()
128
- .map((name) => `export type ${name}Select = import("@capacms/sdk/next").Select<${name}>;`);
259
+ .flatMap((name) => [
260
+ `export type ${name}Select = import("@capacms/sdk/next").Select<${name}>;`,
261
+ // M5: fieldAttrs(entry) typed per model, so a wrong field name fails
262
+ // to compile, as in `const a: ArticleAttrs = fieldAttrs(entry)`.
263
+ `export type ${name}Attrs = import("@capacms/sdk/next").FieldAttrs<${name}>;`,
264
+ ]);
129
265
  }
130
266
  function normalizeTypes(source, checksum, schema) {
131
267
  // Strip a header a previous run wrote, so normalize(normalize(x)) === normalize(x).
@@ -141,14 +277,49 @@ function normalizeTypes(source, checksum, schema) {
141
277
  .replace(/^\n+/, "");
142
278
  const parts = withoutHeader.split(/\n(?=export (?:interface|type) )/);
143
279
  const preamble = parts.length && !/^export (?:interface|type) /.test(parts[0]) ? parts.shift() : "";
144
- const nameOf = (b) => (/^export (?:interface|type) (\w+)/.exec(b) || [])[1] ?? "";
280
+ const nameOf = (b) => (/^export (?:interface|type) ([\w$]+)/.exec(b) || [])[1] ?? "";
145
281
  const byName = (a, b) => nameOf(a).localeCompare(nameOf(b));
282
+ const isShared = (b) => SHARED_NAMES.includes(nameOf(b));
146
283
  const schemaWithRelations = hasRelations(schema) ? schema : null;
147
- const rewritten = schemaWithRelations ? parts.map((part) => rewriteRelationFields(part, schemaWithRelations)) : parts;
148
- const shared = rewritten.filter((p) => /^export (?:interface|type) Capa/.test(p)).sort(byName);
149
- const rest = rewritten.filter((p) => !/^export (?:interface|type) Capa/.test(p)).sort(byName);
284
+ const models = schema?.models ?? [];
285
+ const names = modelInterfaceNames(models);
286
+ // The models each block's header can mean: the route writes a model's
287
+ // PascalCase name, an earlier run its settled name.
288
+ const candidates = (written) => {
289
+ const name = interfaceName(written);
290
+ return models.filter((model) => modelInterface(model.namespace) === name || names.get(model.namespace) === name);
291
+ };
292
+ const renamed = new Map();
293
+ for (const part of parts) {
294
+ const written = isShared(part) ? undefined : INTERFACE_HEADER.exec(part.split("\n", 1)[0])?.[1];
295
+ if (written === undefined)
296
+ continue;
297
+ const found = candidates(written);
298
+ const name = found.length === 1 ? names.get(found[0].namespace) : interfaceName(written);
299
+ if (found.length <= 1 && name !== written)
300
+ renamed.set(written, name);
301
+ }
302
+ const module = { models, names, relations: schemaWithRelations !== null, renamed };
303
+ const rewritten = [];
304
+ const fromSchema = new Set();
305
+ for (const part of parts) {
306
+ const header = isShared(part) ? null : INTERFACE_HEADER.exec(part.split("\n", 1)[0]);
307
+ if (!header) {
308
+ rewritten.push(part);
309
+ continue;
310
+ }
311
+ const found = candidates(header[1]);
312
+ if (found.length > 1)
313
+ found.forEach((model) => fromSchema.add(model));
314
+ else
315
+ rewritten.push(rewriteModelBlock(part, found[0], found.length ? names.get(found[0].namespace) : interfaceName(header[1]), module));
316
+ }
317
+ for (const model of fromSchema)
318
+ rewritten.push(schemaModelBlock(model, module));
319
+ const shared = rewritten.filter(isShared).sort(byName);
320
+ const rest = rewritten.filter((p) => !isShared(p)).sort(byName);
150
321
  const relationHelpers = schemaWithRelations ? [relationPreamble().trimEnd()] : [];
151
- const aliases = schemaWithRelations ? selectAliases(rest, schemaWithRelations) : [];
322
+ const aliases = schemaWithRelations ? selectAliases(rest, schemaWithRelations, names) : [];
152
323
  const head = [
153
324
  "// Generated by @capacms/sdk. Do not edit by hand.",
154
325
  "// Re-run `capa-codegen` after changing a model in Capa.",
@@ -166,7 +337,7 @@ function normalizeTypes(source, checksum, schema) {
166
337
  return `${head}\n\n${kept}${body}\n`;
167
338
  }
168
339
  function typeNames(source) {
169
- return [...source.matchAll(/^export (?:interface|type) (\w+)/gm)].map((m) => m[1]).sort();
340
+ return [...source.matchAll(/^export (?:interface|type) ([\w$]+)/gm)].map((m) => m[1]).sort();
170
341
  }
171
342
  /**
172
343
  * Fetch types if — and only if — the tenant's schema has changed since the
@@ -186,3 +357,113 @@ async function generate(config, existing) {
186
357
  const source = normalizeTypes(raw, checksum, schema.body);
187
358
  return { changed: source !== (existing ?? null), checksum, source, types: typeNames(source) };
188
359
  }
360
+ // ------------------------------------------- from a key's GraphQL schema ---
361
+ /**
362
+ * `/v2/schema/types`'s type map and preamble, so a `cap_` key, which `/v2`
363
+ * does not take, gets the interfaces a legacy key gets. Copied from
364
+ * `apps/api/src/routes/v2/schema.ts`, which is frozen legacy; a test holds the
365
+ * two equal.
366
+ */
367
+ exports.LEGACY_TYPE_MAP = {
368
+ string: "string",
369
+ markdown: "string",
370
+ html: "string",
371
+ code: "string",
372
+ color: "string",
373
+ number: "number",
374
+ true_false: "boolean",
375
+ date: "string",
376
+ image: "CapaImage",
377
+ video: "CapaVideo",
378
+ file: "CapaFile",
379
+ json: "Record<string, unknown>",
380
+ rich_text: "string",
381
+ };
382
+ exports.LEGACY_PREAMBLE = `export interface CapaImage {
383
+ url: string;
384
+ alt?: string;
385
+ width?: number;
386
+ height?: number;
387
+ filesize?: number;
388
+ filename?: string;
389
+ }
390
+
391
+ export interface CapaVideo {
392
+ url: string;
393
+ thumbnail?: string;
394
+ duration?: number;
395
+ width?: number;
396
+ height?: number;
397
+ filesize?: number;
398
+ filename?: string;
399
+ }
400
+
401
+ export interface CapaFile {
402
+ url: string;
403
+ filename: string;
404
+ filesize?: number;
405
+ type?: string;
406
+ }
407
+
408
+ export interface CapaInstance<T = Record<string, unknown>> {
409
+ id: string;
410
+ title?: string;
411
+ slug?: string;
412
+ data: T;
413
+ status: 'draft' | 'published';
414
+ createdAt: string;
415
+ updatedAt: string;
416
+ publishedAt?: string;
417
+ }
418
+
419
+ `;
420
+ /** A field's type as `/v2/schema/types` writes it, from what GraphQL says of it. */
421
+ function legacyFieldType(field) {
422
+ // GraphQL types an enum as String, and lists its values for people only.
423
+ const scalar = (type) => (type === "enum" ? "string" : exports.LEGACY_TYPE_MAP[type ?? ""] ?? "unknown");
424
+ const target = field.target === null ? "unknown" : toPascalCase(field.target);
425
+ if (field.capaType === "relation")
426
+ return target;
427
+ if (field.capaType === "array")
428
+ return `${field.arrayType === "relation" ? target : scalar(field.arrayType)}[]`;
429
+ return scalar(field.capaType);
430
+ }
431
+ /**
432
+ * The REST model interfaces for a key's GraphQL schema: what
433
+ * `/v2/schema/types` and `/v2/schema` give a legacy key, for a `cap_` key.
434
+ * The model namespaces, field namespaces, Capa types and relation targets
435
+ * come from the schema's descriptions (N9), so the interfaces are keyed as
436
+ * REST reads them (`open-time`), not as GraphQL renames them (`open_time`).
437
+ *
438
+ * The text is written as the legacy route writes it and goes through
439
+ * `normalizeTypes`, the one place that turns that text into a module. Three
440
+ * things GraphQL does not say are written as it can: every field is optional,
441
+ * since the schema has no required flag; an enum is `string`, since it lists
442
+ * no values; and a field GraphQL leaves out (its description's `Not exposed:`
443
+ * line) is `unknown`. No checksum is stamped: `CAPA_SCHEMA_CHECKSUM` is the
444
+ * `/v2/schema` checksum, which a `cap_` key cannot read.
445
+ */
446
+ function restTypesFromIntrospection(introspection) {
447
+ const summary = (0, summary_1.summarizeIntrospection)(introspection);
448
+ let text = `// Auto-generated Capa CMS types\n\n${exports.LEGACY_PREAMBLE}`;
449
+ const models = [];
450
+ for (const model of summary.models) {
451
+ text += `export interface ${toPascalCase(model.namespace)} {\n`;
452
+ for (const field of model.fields)
453
+ text += ` ${field.namespace}?: ${legacyFieldType(field)};\n`;
454
+ for (const namespace of model.notExposed)
455
+ text += ` ${namespace}?: unknown;\n`;
456
+ text += "}\n\n";
457
+ models.push({
458
+ namespace: model.namespace,
459
+ fields: model.fields.map((field) => ({
460
+ namespace: field.namespace,
461
+ type: field.capaType,
462
+ arrayType: field.arrayType,
463
+ relationRef: field.target,
464
+ })),
465
+ });
466
+ }
467
+ const source = normalizeTypes(text, null, { models });
468
+ return { source, types: typeNames(source), restOnly: summary.restOnly };
469
+ }
package/dist/config.d.ts CHANGED
@@ -36,7 +36,11 @@
36
36
  export interface CapaConfig {
37
37
  /** Base URL of the Capa API, e.g. https://api.example.com. No trailing slash required. */
38
38
  baseUrl: string;
39
- /** A tenant API key. Read scope is all the SDK needs. */
39
+ /**
40
+ * A legacy tenant API key: `pk_`, `sk_` or unprefixed. Read scope is all the
41
+ * client needs. A `cap_` key reads `/api/`, with `createClient` from
42
+ * `@capacms/sdk/next`, and is refused here.
43
+ */
40
44
  apiKey: string;
41
45
  /** The tenant the key belongs to. */
42
46
  tenantId: string;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * graphql-codegen.ts — `capa-codegen --graphql` and `capa persist`, the pure half.
3
+ *
4
+ * Reads a key's schema (introspection JSON) and a project's GraphQL documents,
5
+ * validates every document against that schema, and writes ONE TypeScript
6
+ * module holding:
7
+ *
8
+ * - the schema as types, for `createClient<CapaQuery>()` and the typed
9
+ * builder (`client.graphql.query`);
10
+ * - one `TypedDocument` constant per named operation, so
11
+ * `client.graphql(ArticlesPageDocument, vars)` infers the result and the
12
+ * variables with no cast, and beside it `<Name>Models`, the models it
13
+ * reads, for its Next.js cache tags;
14
+ * - for each document written as a literal (`#graphql`, `/* capa *\/` or
15
+ * gql(`...`)), an entry in `CapaDocuments` keyed by its exact text, so
16
+ * `client.graphql(LITERAL, vars)` infers them too, with no import;
17
+ * - `capaTreeLayout`, what `toTree` reads of the schema, so a server lays a
18
+ * builder result out in REST's shape without reading the schema first.
19
+ *
20
+ * The file walk and the network live in `bin/`; everything here is a function
21
+ * of its inputs, so it is tested without either.
22
+ *
23
+ * `graphql` (graphql-js) is loaded on first use, never at import: it is an
24
+ * optional peer dependency that only these commands need, and a site that
25
+ * only calls `client.graphql()` must not pay for it.
26
+ */
27
+ import type * as GraphQLJs from "graphql";
28
+ import type { CapaIntrospection } from "./next/graphql/introspection";
29
+ /** graphql-js, or an error that says how to get it. */
30
+ export declare function loadGraphQL(): typeof GraphQLJs;
31
+ /** One GraphQL document found in a project, with where it came from. */
32
+ export interface DocumentSource {
33
+ file: string;
34
+ /** 1-based line of the document's first character in `file`. */
35
+ line: number;
36
+ /** The document's text as the program holds it at run time: a template's escapes are applied. */
37
+ text: string;
38
+ /**
39
+ * True for a literal whose type is its text (`#graphql`, `/* capa *\/`,
40
+ * gql(`...`)): it is sent exactly as written, and codegen keys its types by
41
+ * that text.
42
+ */
43
+ literal?: true;
44
+ /** The variable a literal is assigned to (`const CARD = ...`), which another literal's `${CARD}` names. */
45
+ name?: string;
46
+ /**
47
+ * For a literal with `${NAME}` in it: the text around each `${}` and the
48
+ * names in them, resolved against the project's other literals before the
49
+ * literal is read. `text` holds the template with its `${NAME}`s meanwhile.
50
+ */
51
+ template?: {
52
+ segments: string[];
53
+ names: string[];
54
+ asConst: boolean;
55
+ };
56
+ }
57
+ /** A template codegen does not read, and why: the CLI prints each one, so none is dropped in silence. */
58
+ export interface SkippedDocument extends DocumentSource {
59
+ reason: string;
60
+ }
61
+ /** A template's text at run time, from its source text: line ends as LF, escapes applied. */
62
+ export declare function cookTemplate(raw: string): string;
63
+ /**
64
+ * The GraphQL documents in one file: the whole file for `.graphql` and `.gql`,
65
+ * and elsewhere every template that is marked as one: a `#graphql` first
66
+ * line, a `/* capa *\/` comment before it, or `gql` as a tag or a function.
67
+ * One on a comment line is an example, not a document.
68
+ *
69
+ * `skipped` names every template codegen will not read that looks meant for
70
+ * it, with the reason: one with `${}` inside (its text is only known at run
71
+ * time, so it cannot be validated or hashed ahead of it), a named operation
72
+ * left unmarked, and `/nextjs`'s `graphql` used as a tag.
73
+ */
74
+ export declare function extractDocuments(file: string, text: string): {
75
+ documents: DocumentSource[];
76
+ skipped: SkippedDocument[];
77
+ };
78
+ export interface CodegenProblem {
79
+ file: string;
80
+ line: number;
81
+ column: number;
82
+ message: string;
83
+ }
84
+ export interface GeneratedOperation {
85
+ name: string;
86
+ /** The exact text of its `<Name>Document`, and hashed for persisted queries. */
87
+ document: string;
88
+ sha256: string;
89
+ /** The namespaces of the models it reads, written as `<Name>Models` for its cache tags. */
90
+ models: string[];
91
+ /** For a document written as a literal: the literal's text, which is what a call with it sends, and its hash. */
92
+ literal?: {
93
+ document: string;
94
+ sha256: string;
95
+ };
96
+ }
97
+ export interface GraphQLCodegenResult {
98
+ /** The module to write. Null when a document failed. */
99
+ source: string | null;
100
+ operations: GeneratedOperation[];
101
+ problems: CodegenProblem[];
102
+ /**
103
+ * Each use of a deprecated field, argument, input field or enum value, with
104
+ * the reason the schema gives: it still works, and a later Capa-Version
105
+ * removes it.
106
+ */
107
+ warnings: CodegenProblem[];
108
+ /** Literals with a `${}` that names no literal of the project, so their text is only known at run time. */
109
+ skipped: SkippedDocument[];
110
+ }
111
+ /**
112
+ * The generated module for a schema and a project's documents. Problems (a
113
+ * syntax error, a field the key cannot read, an unnamed operation, a name used
114
+ * twice) are returned with their file and line, and no module is produced
115
+ * while any remain.
116
+ */
117
+ export declare function generateGraphQLModule(introspection: CapaIntrospection, sources: DocumentSource[]): GraphQLCodegenResult;