@capacms/sdk 1.0.0-next.1 → 1.0.0-next.10

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 (92) hide show
  1. package/CHANGELOG.md +450 -0
  2. package/README.md +1698 -193
  3. package/bin/capa-codegen.js +192 -5
  4. package/bin/capa.js +235 -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 -36
  12. package/dist/config.js +47 -1
  13. package/dist/esm/image/index.d.ts +120 -0
  14. package/dist/esm/image/index.js +250 -0
  15. package/dist/esm/image/shared-params.generated.d.ts +190 -0
  16. package/dist/esm/image/shared-params.generated.js +461 -0
  17. package/dist/esm/nextjs/image-loader.d.ts +60 -0
  18. package/dist/esm/nextjs/image-loader.js +67 -0
  19. package/dist/esm/nextjs/overlay.d.ts +30 -0
  20. package/dist/esm/nextjs/overlay.js +75 -0
  21. package/dist/esm/overlay/index.d.ts +32 -0
  22. package/dist/esm/overlay/index.js +576 -0
  23. package/dist/esm/overlay/protocol.d.ts +187 -0
  24. package/dist/esm/overlay/protocol.js +240 -0
  25. package/dist/esm/package.json +4 -0
  26. package/dist/graphql-codegen.d.ts +117 -0
  27. package/dist/graphql-codegen.js +705 -0
  28. package/dist/http.js +1 -1
  29. package/dist/image/index.d.ts +120 -0
  30. package/dist/image/index.js +257 -0
  31. package/dist/image/shared-params.generated.d.ts +190 -0
  32. package/dist/image/shared-params.generated.js +471 -0
  33. package/dist/index.d.ts +2 -2
  34. package/dist/index.js +2 -1
  35. package/dist/next/attrs.d.ts +84 -10
  36. package/dist/next/attrs.js +119 -2
  37. package/dist/next/client.d.ts +176 -32
  38. package/dist/next/client.js +212 -90
  39. package/dist/next/entry-fields.d.ts +162 -0
  40. package/dist/next/entry-fields.js +2 -0
  41. package/dist/next/errors.d.ts +136 -0
  42. package/dist/next/errors.js +214 -0
  43. package/dist/next/field-names.d.ts +37 -0
  44. package/dist/next/field-names.js +145 -0
  45. package/dist/next/graphql/build.d.ts +27 -0
  46. package/dist/next/graphql/build.js +98 -0
  47. package/dist/next/graphql/documents.d.ts +67 -0
  48. package/dist/next/graphql/documents.js +35 -0
  49. package/dist/next/graphql/edit-mode.d.ts +16 -0
  50. package/dist/next/graphql/edit-mode.js +93 -0
  51. package/dist/next/graphql/filter-values.d.ts +34 -0
  52. package/dist/next/graphql/filter-values.js +96 -0
  53. package/dist/next/graphql/introspection.d.ts +89 -0
  54. package/dist/next/graphql/introspection.js +102 -0
  55. package/dist/next/graphql/plan.d.ts +115 -0
  56. package/dist/next/graphql/plan.js +531 -0
  57. package/dist/next/graphql/request.d.ts +228 -0
  58. package/dist/next/graphql/request.js +283 -0
  59. package/dist/next/graphql/rest.d.ts +66 -0
  60. package/dist/next/graphql/rest.js +502 -0
  61. package/dist/next/graphql/selection.d.ts +55 -0
  62. package/dist/next/graphql/selection.js +212 -0
  63. package/dist/next/graphql/sha256.d.ts +13 -0
  64. package/dist/next/graphql/sha256.js +86 -0
  65. package/dist/next/graphql/summary.d.ts +83 -0
  66. package/dist/next/graphql/summary.js +151 -0
  67. package/dist/next/graphql/tree-layout.d.ts +36 -0
  68. package/dist/next/graphql/tree-layout.js +20 -0
  69. package/dist/next/graphql/tree.d.ts +171 -0
  70. package/dist/next/graphql/tree.js +249 -0
  71. package/dist/next/graphql/typed.d.ts +261 -0
  72. package/dist/next/graphql/typed.js +146 -0
  73. package/dist/next/index.d.ts +30 -5
  74. package/dist/next/index.js +32 -1
  75. package/dist/next/inflate.d.ts +51 -0
  76. package/dist/next/inflate.js +243 -0
  77. package/dist/next/key-family.d.ts +31 -0
  78. package/dist/next/key-family.js +66 -0
  79. package/dist/next/select-types.d.ts +58 -5
  80. package/dist/next/system-keys.d.ts +27 -0
  81. package/dist/next/system-keys.js +42 -0
  82. package/dist/nextjs/image-loader.d.ts +60 -0
  83. package/dist/nextjs/image-loader.js +71 -0
  84. package/dist/nextjs/index.d.ts +484 -5
  85. package/dist/nextjs/index.js +688 -6
  86. package/dist/nextjs/overlay.d.ts +30 -0
  87. package/dist/nextjs/overlay.js +78 -0
  88. package/dist/overlay/index.d.ts +14 -2
  89. package/dist/overlay/index.js +282 -43
  90. package/dist/overlay/protocol.d.ts +98 -2
  91. package/dist/overlay/protocol.js +151 -4
  92. package/package.json +63 -15
package/dist/client.js CHANGED
@@ -19,6 +19,7 @@ exports.createClient = createClient;
19
19
  */
20
20
  const config_1 = require("./config");
21
21
  const http_1 = require("./http");
22
+ const key_family_1 = require("./next/key-family");
22
23
  const webhooks_1 = require("./webhooks");
23
24
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
24
25
  /**
@@ -64,7 +65,23 @@ function contentQuery(options = {}) {
64
65
  q[k] = v;
65
66
  return q;
66
67
  }
68
+ /**
69
+ * `/v2` answers every `cap_` key "Invalid API key", which reads as a bad key
70
+ * rather than the wrong client, so one is refused here before the tenant id is
71
+ * asked for or anything is sent. The rule is the API's: `cap_`, case-sensitive.
72
+ */
73
+ function refuseCapKey(apiKey) {
74
+ if ((0, key_family_1.keyFamily)(apiKey) !== "cap")
75
+ return;
76
+ throw new TypeError('@capacms/sdk: this is the legacy /v2/api client, and cap_ keys read /api/: import { createClient } from "@capacms/sdk/next".');
77
+ }
78
+ /**
79
+ * The legacy `/v2/api` client, for a legacy key (`pk_`, `sk_` or unprefixed)
80
+ * and its tenant's id. For `/api/`, GraphQL and `cap_` keys, import
81
+ * `createClient` from `@capacms/sdk/next`.
82
+ */
67
83
  function createClient(config) {
84
+ refuseCapKey(config.apiKey);
68
85
  const resolved = (0, config_1.resolveConfig)(config);
69
86
  async function listContent(namespace, options = {}) {
70
87
  const { body, cacheTags } = await (0, http_1.getJson)(resolved, `/v2/api/${encodeURIComponent(namespace)}`, contentQuery(options));
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
@@ -1,42 +1,11 @@
1
- /**
2
- * Client configuration, and the one thing about it that is not obvious.
3
- *
4
- * PREVIEW IS A KEY, NOT A FLAG
5
- * Capa gates unpublished content on the API key's `environment` column, not on
6
- * anything in the request:
7
- *
8
- * // apps/api/src/routes/v2/api.ts:203
9
- * const environment = req.apiKeyEnvironment || "production";
10
- * const includeDrafted = environment === "production" ? false : true;
11
- *
12
- * So there is no `?preview=true` to pass, and `getContent(ns, id, {preview})`
13
- * CANNOT be implemented against a published key — the server would ignore it.
14
- * The honest surface is one client per key:
15
- *
16
- * const capa = createClient({ ...cfg, apiKey: PUBLISHED_KEY })
17
- * const preview = createClient({ ...cfg, apiKey: PREVIEW_KEY })
18
- *
19
- * Note also that the comparison above is exact and case-sensitive against a
20
- * free-text column, so ANY environment that is not literally "production"
21
- * returns drafts — `staging`, `development`, `draft`, and equally a typo like
22
- * "Production".
23
- *
24
- * AND A CLIENT CANNOT FIND OUT WHICH IT HAS.
25
- * There is deliberately no `includesDrafts()` here, because it cannot be
26
- * implemented: `apiKeyEnvironment` is set in verifyApiKey.ts:57, consumed
27
- * internally to compute `includeDrafted`, and returned to the caller by NO
28
- * endpoint. /v2/schema carries only {models, relations, checksum, generatedAt}.
29
- *
30
- * So a site handed a `draft`, `staging` or `development` key serves unpublished
31
- * content to the public, and has no way to detect it — not at startup, not at
32
- * runtime, not from any response. In production-shaped data 29 of 74 keys are
33
- * non-production. Whatever this SDK offers, it cannot make that safe; the fix
34
- * is for Capa to report the key's environment on a read a client already makes.
35
- */
36
1
  export interface CapaConfig {
37
2
  /** Base URL of the Capa API, e.g. https://api.example.com. No trailing slash required. */
38
3
  baseUrl: string;
39
- /** A tenant API key. Read scope is all the SDK needs. */
4
+ /**
5
+ * A legacy tenant API key: `pk_`, `sk_` or unprefixed. Read scope is all the
6
+ * client needs. A `cap_` key reads `/api/`, with `createClient` from
7
+ * `@capacms/sdk/next`, and is refused here.
8
+ */
40
9
  apiKey: string;
41
10
  /** The tenant the key belongs to. */
42
11
  tenantId: string;
package/dist/config.js CHANGED
@@ -1,13 +1,59 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.resolveConfig = resolveConfig;
4
+ /**
5
+ * Client configuration, and the one thing about it that is not obvious.
6
+ *
7
+ * PREVIEW IS A KEY, NOT A FLAG
8
+ * Capa gates unpublished content on the API key's `environment` column, not on
9
+ * anything in the request:
10
+ *
11
+ * // apps/api/src/routes/v2/api.ts:203
12
+ * const environment = req.apiKeyEnvironment || "production";
13
+ * const includeDrafted = environment === "production" ? false : true;
14
+ *
15
+ * So there is no `?preview=true` to pass, and `getContent(ns, id, {preview})`
16
+ * CANNOT be implemented against a published key — the server would ignore it.
17
+ * The honest surface is one client per key:
18
+ *
19
+ * const capa = createClient({ ...cfg, apiKey: PUBLISHED_KEY })
20
+ * const preview = createClient({ ...cfg, apiKey: PREVIEW_KEY })
21
+ *
22
+ * Note also that the comparison above is exact and case-sensitive against a
23
+ * free-text column, so ANY environment that is not literally "production"
24
+ * returns drafts — `staging`, `development`, `draft`, and equally a typo like
25
+ * "Production".
26
+ *
27
+ * AND A CLIENT CANNOT FIND OUT WHICH IT HAS.
28
+ * There is deliberately no `includesDrafts()` here, because it cannot be
29
+ * implemented: `apiKeyEnvironment` is set in verifyApiKey.ts:57, consumed
30
+ * internally to compute `includeDrafted`, and returned to the caller by NO
31
+ * endpoint. /v2/schema carries only {models, relations, checksum, generatedAt}.
32
+ *
33
+ * So a site handed a `draft`, `staging` or `development` key serves unpublished
34
+ * content to the public, and has no way to detect it — not at startup, not at
35
+ * runtime, not from any response. In production-shaped data 29 of 74 keys are
36
+ * non-production. Whatever this SDK offers, it cannot make that safe; the fix
37
+ * is for Capa to report the key's environment on a read a client already makes.
38
+ */
39
+ /**
40
+ * The platform fetch, called through `globalThis` on every request. Storing
41
+ * `globalThis.fetch` and calling it as a method of the config object gives it
42
+ * the wrong `this`, and a browser refuses that ("Illegal invocation"). Looking
43
+ * it up per call also picks up a fetch a framework patches in later.
44
+ */
45
+ function defaultFetch() {
46
+ if (typeof globalThis.fetch !== "function")
47
+ return undefined;
48
+ return ((input, init) => globalThis.fetch(input, init));
49
+ }
4
50
  function resolveConfig(config) {
5
51
  const missing = ["baseUrl", "apiKey", "tenantId"].filter((k) => !config[k]);
6
52
  if (missing.length) {
7
53
  throw new Error(`@capacms/sdk: missing ${missing.join(", ")}. ` +
8
54
  `createClient needs baseUrl, apiKey and tenantId.`);
9
55
  }
10
- const fetchImpl = config.fetch ?? globalThis.fetch;
56
+ const fetchImpl = config.fetch ?? defaultFetch();
11
57
  if (typeof fetchImpl !== "function") {
12
58
  throw new Error("@capacms/sdk: no fetch available. Pass one via config.fetch on older runtimes.");
13
59
  }