@lemonadecms/client 0.2.0 → 0.2.2

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/CHANGELOG.md CHANGED
@@ -2,6 +2,45 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.2
6
+
7
+ - Correct the npm-specific README with global/collection defaults, direct
8
+ reference-ID filtering, authorization limits and accurate 0.2.1 feature
9
+ availability. No SDK runtime, CLI or dependency changes from 0.2.1.
10
+
11
+ ## 0.2.1
12
+
13
+ - Filter top-level Reference fields by linked entry UUID with eq/ne/in/not_in,
14
+ and References fields with contains_any/contains_all, including typed global
15
+ and per-collection defaults. Requires the matching CMS deployment.
16
+
17
+ - Add optional typed global and collection query defaults, immutable configuration,
18
+ AND-composed filters and bounded default traversal. ID reads inherit locale/remote
19
+ options only; related reads never inherit the parent collection's defaults.
20
+ - Keep fixed API URLs, explicit credentials, existing method contracts and zero
21
+ runtime dependencies.
22
+
23
+ - Negotiate `unique-identifiers.v1` schema metadata for declared shared-text
24
+ identifiers. Capture, drift and compatible migration additions retain the
25
+ declaration; generated delivery fields stay strings and legacy exports retain
26
+ their shape. Requires an updated backend.
27
+ - Add explicit `richTextVersion: 2` delivery negotiation and opt-in rich-text v2
28
+ quotations, escaped code blocks and accessible rectangular tables. V1 fields
29
+ retain their existing types and values; malformed/unknown v2 nodes and future
30
+ versions raise `CMSResponseError` instead of silently dropping content.
31
+ - Add pure `externalMediaDescriptor` and safe static rich-text cards for supported
32
+ public YouTube/Vimeo videos, Spotify episodes and SoundCloud tracks. Rendering
33
+ does not fetch metadata or start a player; playback consent belongs to the site.
34
+ - Request `relationships.v1,rich-text.v2` schema extensions together, validate and
35
+ round-trip field capabilities, and generate opt-in TypeScript/Python/C# types.
36
+ These capabilities require the matching backend deployment.
37
+ - Add on-demand `related(parentId, relationship, options)` reads for explicitly
38
+ configured reverse collections, including server previews. Optional generated
39
+ `Relationships` types check relation names and source-field selections without
40
+ changing existing `createCMSClient<Models>` calls or entry fields.
41
+ - Negotiate the versioned relationship schema extension for type generation and
42
+ drift checks, retaining legacy schemas. Requires the updated backend. No runtime dependencies.
43
+
5
44
  ## 0.2.0
6
45
 
7
46
  - Use `https://api.lemonadecms.com` for new SDK/CLI defaults. The server's strict
package/README.md CHANGED
@@ -30,7 +30,7 @@ runtime library or framework dependency is required.
30
30
 
31
31
  ## Configure and read content
32
32
 
33
- Version 0.2.0 connects to `https://api.lemonadecms.com` automatically. Strict hostname routing returns 404 for API URLs on `https://lemonadecms.com`. Upgrade earlier clients to 0.2.0 and change explicit CLI/configured-target URLs to `https://api.lemonadecms.com`. Explicit CLI settings still take precedence; no redirect or fallback is attempted. Configure your
33
+ Version 0.2.0 connects to `https://api.lemonadecms.com` automatically. Strict hostname routing returns 404 for API URLs on `https://lemonadecms.com`. Upgrade earlier clients to 0.2.2 and change explicit CLI/configured-target URLs to `https://api.lemonadecms.com`. Explicit CLI settings still take precedence; no redirect or fallback is attempted. Configure your
34
34
  workspace and delivery key; there is no URL or local CMS option. Your website
35
35
  can run locally while reading content from Lemonade production. Installing the
36
36
  package does not create an account, workspace or delivery key.
@@ -85,6 +85,64 @@ environment override. Ask your Owner for a credential belonging to the intended
85
85
  environment. The server rechecks current access, expiry and revocation on every
86
86
  request.
87
87
 
88
+ ## Global and collection defaults (0.2.1+)
89
+
90
+ Use `defaults` for shared locale, limit, filters, sort, includeRemote and maxPages.
91
+ Use `collections` for typed per-model settings. Request values override collection
92
+ values, then global values. Global, collection and request filters combine with
93
+ AND; `filters: []` does not remove configured predicates. Configuration is copied
94
+ at client construction. No schema fetch, retry or runtime dependency is added.
95
+
96
+ The example assumes generated `article` and `product` models, each with a top-level
97
+ Reference field named `tenant`. Filter directly by the linked entry UUID; a
98
+ separate `tenant_id` text field is unnecessary. Regenerate your model types after
99
+ adding the relation. Relation predicates require the matching CMS backend.
100
+
101
+ ```ts
102
+ import { createCMSClient } from '@lemonadecms/client';
103
+ import type { Models } from './cms-models';
104
+
105
+ type TenantContent = Pick<Models, 'article' | 'product'>;
106
+ const tenantId = 'YOUR_TENANT_ENTRY_UUID';
107
+ const cms = createCMSClient<TenantContent>({
108
+ projectId: 'YOUR_WORKSPACE_UUID',
109
+ publicKey: 'YOUR_PUBLIC_DELIVERY_KEY',
110
+ defaults: {
111
+ locale: 'en',
112
+ limit: 25,
113
+ filters: [{ field: 'tenant', op: 'eq', value: tenantId }],
114
+ },
115
+ });
116
+ const articles = await cms.content('article').list({ limit: 10 });
117
+ ```
118
+
119
+ `tenantId` must come from your application's intended tenant entry UUID. Global
120
+ fields must exist on every queried model; use collection defaults when only some
121
+ models have the relation. Use another client for the tenant directory itself.
122
+
123
+ | Field | Operators | Values |
124
+ | --- | --- | --- |
125
+ | Reference | `eq`, `ne` | Linked entry UUID or `null` |
126
+ | Reference | `in`, `not_in` | 1–100 UUIDs |
127
+ | References | `contains_any`, `contains_all` | 1–100 unique UUIDs |
128
+
129
+ IDs are compared case-insensitively in the requested published or saved-draft
130
+ snapshot. These filters do not fetch or traverse target fields. Both source and
131
+ target models must be accessible in the requested language. Dotted `tenant.id`,
132
+ nested relations, reverse-list predicates and relation sorting are unsupported.
133
+ Existing ten-leaf, depth-four, 31-node and 4 KiB filter limits still apply.
134
+
135
+ Defaults are query conveniences, **not tenant authorization**. `get()`/`getMany()`
136
+ inherit locale/includeRemote only and do not inherit filters. Reference expansion
137
+ is not constrained by defaults. A shared public key can read every record it
138
+ permits. Use separate workspace credentials for hard isolation, or trusted server
139
+ authorization for every entry and referenced resource. Never rely on a caller's
140
+ unverified tenant ID as authorization.
141
+
142
+ Statistics inherit locale/filters; `findOne()` keeps limit 1. Related reads inherit
143
+ global defaults only, never the parent collection's settings. Traversal snapshots
144
+ its effective query and honors maxPages (default 1,000; maximum 10,000).
145
+
88
146
  ## Find one entry
89
147
 
90
148
  In the updated source build, `findOne()` returns the first matching complete
@@ -195,18 +253,35 @@ const html = richTextToHtml(richEntry?.fields.rich_body);
195
253
  const plainText = richTextToPlainText(richEntry?.fields.rich_body);
196
254
  ```
197
255
 
198
- Both accept nullable/unknown version-1 rich-text JSON and work in browsers or
199
- servers without a framework or DOM. HTML escapes text/attributes and renders only
256
+ Both accept nullable/unknown rich-text JSON and work in browsers or servers
257
+ without a framework or DOM. HTML escapes text/attributes and renders only
200
258
  supported nodes/marks, safe links and matching expanded image/entry-caption
201
259
  resources. Unavailable embeds are omitted; entry routes are never invented.
202
- Plain text retains authored text with block/list/hard-break newlines, omitting
203
- embeds and link URLs. Plain text is not escaped HTML: use text interpolation.
260
+ Plain text retains authored text with block/list/hard-break newlines. Plain text
261
+ is not escaped HTML: use text interpolation.
262
+
263
+ SDK 0.2.1 and later support opt-in v2 quotations, escaped code blocks, accessible
264
+ rectangular tables and external-media cards. Configure the field's
265
+ `document_version: 2` and enabled features, then set `richTextVersion: 2` on the
266
+ published or server preview client. This explicitly sends
267
+ `Lemonade-Accept-Content: rich-text.v2`; default reads remain v1. The updated type
268
+ generator requests `relationships.v1,rich-text.v2,unique-identifiers.v1` schema extensions.
269
+ These additions require the updated backend and website renderer, together with
270
+ SDK 0.2.1 or later. Earlier packages do not contain these advanced formats.
271
+
272
+ External-media HTML stays static: no iframe, autoplay or provider request is
273
+ created by these helpers. `externalMediaDescriptor(node)` returns validated,
274
+ fixed provider URLs without I/O so a website can add an explicit click-to-load
275
+ player. The Astro source reference demonstrates disclosure, constrained frames,
276
+ timeout recovery and a transcript that remains available.
204
277
 
205
278
  Traversal is bounded to 500 nodes, depth 8, 200 children per node, 20 embeds,
206
279
  20,000 UTF-8 bytes per text and 64 KiB of rendered source strings. Unsupported
207
- versions, invalid roots/containers or exhausted budgets return empty strings;
208
- unsupported parts are omitted. No raw HTML, resource-field recursion, I/O or
209
- automatic Markdown conversion is performed. HTML URL/alt bounds and exact
280
+ legacy roots/containers or exhausted legacy budgets return empty strings;
281
+ unsupported legacy parts are omitted. Unknown/future versions and malformed or
282
+ unsupported v2 documents throw `CMSResponseError`; handle the error explicitly.
283
+ No raw HTML, resource-field recursion, I/O or automatic Markdown conversion is
284
+ performed. HTML URL/alt bounds and exact
210
285
  preview-image conventions are documented in the SDK guide. The helpers are also
211
286
  exported from the server entry; preview authorization remains your responsibility.
212
287
 
@@ -316,3 +391,20 @@ saved-draft previews remain supported. The browser helper is the dependency-free
316
391
  `@lemonadecms/client/composer` export.
317
392
 
318
393
  See [setup, editor workflow, Astro reference and protocol](https://github.com/ilara-booqself/lemonadecms/blob/main/docs/visual-composer.md).
394
+
395
+
396
+ ## Reverse relationships (0.2.1+)
397
+
398
+ SDK 0.2.1 adds opt-in rich-text v2 (described above) and on-demand
399
+ reverse collections through
400
+ `cms.content('author').related(authorId, 'articles', {limit: 10})`. Updated
401
+ `lemonade types generate` negotiates the relationship extension and generates a
402
+ separate `Relationships` map for `createCMSClient<Models, Relationships>`.
403
+ Ordinary entries and `fields` retain their shape. This requires the updated
404
+ backend and SDK 0.2.1 or later. Installing npm does not deploy the backend.
405
+ The SDK retains zero runtime dependencies.
406
+
407
+ Management migrations configuring a reverse list need both source and target
408
+ model keys and actions in the management credential, independently of the
409
+ actor's grants. Existing targets require `model.update`; targets created in the
410
+ same migration require `model.create`. See the [reviewed migration guide](https://github.com/ilara-booqself/lemonadecms/blob/main/docs/schema-migrations.md).
@@ -3,6 +3,8 @@ import { validateSchema, canonical } from './schema.mjs';
3
3
  import { parseJSON, readText, writeOutput } from './io.mjs';
4
4
  import { record } from './config.mjs';
5
5
  import { fail } from './errors.mjs';
6
+ import { modelsWithRelationships } from '../relationships.mjs';
7
+ import { modelsWithUniqueIdentifiers } from '../unique-identifiers.mjs';
6
8
 
7
9
  const equal = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
8
10
  const rejected = () =>
@@ -58,8 +60,13 @@ export async function createMigration(opts, cwd) {
58
60
  const paths = [opts.baseline, opts.file, opts.status].map((name) =>
59
61
  resolve(cwd, name),
60
62
  );
61
- const baseline = validateSchema(parseJSON(await readText(paths[0])));
62
- const desired = validateSchema(parseJSON(await readText(paths[1])));
63
+ const baselineSchema = validateSchema(parseJSON(await readText(paths[0])));
64
+ const desiredSchema = validateSchema(parseJSON(await readText(paths[1])));
65
+ // Materialize opted-in definitions only in management operations. Published
66
+ // schema fields retain their legacy shape; unsupported edits still reject.
67
+ const managementModels = schema => modelsWithRelationships({...schema, models: modelsWithUniqueIdentifiers(schema)});
68
+ const baseline = {...baselineSchema, models: managementModels(baselineSchema)};
69
+ const desired = {...desiredSchema, models: managementModels(desiredSchema)};
63
70
  const status = parseJSON(await readText(paths[2]));
64
71
  const environment = baseline.environment?.id || baseline.project_id;
65
72
  const modelSummary = (schema) =>
@@ -94,7 +101,7 @@ export async function createMigration(opts, cwd) {
94
101
  );
95
102
  // Require an exact portable editing baseline. Never turn unsupported edits into
96
103
  // omissions, removals, or a blind desired-state replacement.
97
- const withoutModels = ({ models, ...rest }) => rest;
104
+ const withoutModels = ({ models, extensions, ...rest }) => rest;
98
105
  if (!equal(withoutModels(baseline), withoutModels(desired))) rejected();
99
106
  const operations = [];
100
107
  const prior = new Map(baseline.models.map((model) => [model.key, model]));
@@ -33,7 +33,7 @@ export async function fetchSchema(target, fetcher = fetch) {
33
33
  const schema = validateSchema(
34
34
  await requestJSON(
35
35
  target,
36
- `/api/v1/projects/${target.workspace}/schema`,
36
+ `/api/v1/projects/${target.workspace}/schema?extensions=${schemaExtensions}`,
37
37
  {},
38
38
  fetcher,
39
39
  ),
@@ -132,3 +132,4 @@ export async function requestJSON(
132
132
  } catch {}
133
133
  }
134
134
  }
135
+ import { schemaExtensions } from '../rich-text-schema.mjs';
@@ -1,4 +1,7 @@
1
1
  import { generateTypes } from '../generate-types.mjs';
2
+ import { relationshipDefinitions } from '../relationships.mjs';
3
+ import { uniqueIdentifierDefinitions } from '../unique-identifiers.mjs';
4
+ import { validateRichTextExtension } from '../rich-text-schema.mjs';
2
5
  import { record, uuid } from './config.mjs';
3
6
  import { fail } from './errors.mjs';
4
7
  const identifier = /^[a-z][a-z0-9_]{0,47}$/;
@@ -107,7 +110,7 @@ function fields(values, keys, component = false, ancestors = []) {
107
110
  : ['reference', 'references'].includes(field.type)
108
111
  ? ['target']
109
112
  : field.type === 'rich_text'
110
- ? ['images', 'targets']
113
+ ? ['images', 'targets', 'document_version', 'features']
111
114
  : composed.includes(field.type)
112
115
  ? ['components']
113
116
  : [];
@@ -259,6 +262,7 @@ export function validateSchema(schema) {
259
262
  'default_locale',
260
263
  'models',
261
264
  'environment',
265
+ 'extensions',
262
266
  ]) ||
263
267
  schema.format_version !== 1 ||
264
268
  !uuid.test(schema.project_id || '') ||
@@ -318,6 +322,7 @@ export function validateSchema(schema) {
318
322
  invalid();
319
323
  }
320
324
  }
325
+ try { relationshipDefinitions(schema); validateRichTextExtension(schema); uniqueIdentifierDefinitions(schema); } catch { invalid(); }
321
326
  // One type generator remains authoritative for delivery types, references and SEO roles.
322
327
  try {
323
328
  generateTypes(schema);
@@ -341,6 +346,12 @@ export function serialize(schema) {
341
346
  JSON.stringify(
342
347
  canonical({
343
348
  ...schema,
349
+ ...(schema.extensions ? {extensions: {...schema.extensions, ...(schema.extensions.relationships ? {relationships: {...schema.extensions.relationships,
350
+ definitions: [...schema.extensions.relationships.definitions].sort((a, b) =>
351
+ `${a.model}.${a.key}`.localeCompare(`${b.model}.${b.key}`))}} : {}),
352
+ ...(schema.extensions.unique_identifiers ? {unique_identifiers: {...schema.extensions.unique_identifiers,
353
+ definitions: [...schema.extensions.unique_identifiers.definitions].sort((a, b) =>
354
+ `${a.model}.${a.field}`.localeCompare(`${b.model}.${b.field}`))}} : {})}} : {}),
344
355
  models: [...schema.models].sort((a, b) =>
345
356
  a.key < b.key ? -1 : a.key > b.key ? 1 : 0,
346
357
  ),
@@ -393,5 +404,11 @@ export function compareSchemas(baseline, current) {
393
404
  ]),
394
405
  );
395
406
  compare(modelMap(baseline), modelMap(current), 'models');
407
+ const relationshipMap = schema => Object.fromEntries(relationshipDefinitions(schema).map(definition =>
408
+ [`${definition.model}.${definition.key}`, definition]));
409
+ compare(relationshipMap(baseline), relationshipMap(current), 'relationships');
410
+ const identifierMap = schema => Object.fromEntries(uniqueIdentifierDefinitions(schema).map(definition =>
411
+ [`${definition.model}.${definition.field}`, definition]));
412
+ compare(identifierMap(baseline), identifierMap(current), 'unique_identifiers');
396
413
  return changes;
397
414
  }
@@ -27,7 +27,7 @@ export function generateCSharp(schema) {
27
27
  case 'file':
28
28
  return 'ContentAsset';
29
29
  case 'rich_text':
30
- return 'ContentRichText';
30
+ return field.document_version === 2 ? 'ContentRichTextV2' : 'ContentRichText';
31
31
  case 'component':
32
32
  return field.variants[0].name;
33
33
  case 'components':
@@ -147,6 +147,11 @@ ${property('id', 'string', 'Id', ' = "";')}${property('url', 'string', 'Url', '
147
147
  public sealed record ContentRichText
148
148
  {
149
149
  ${property('version', 'int', 'Version')}${property('document', 'JsonElement', 'Document')}}
150
+ ${schema.extensions?.rich_text ? `// This opt-in field can retain historical v1 or authored v2 documents.
151
+ public sealed record ContentRichTextV2
152
+ {
153
+ ${property('version', 'int', 'Version')}${property('document', 'JsonElement', 'Document')}}
154
+ ` : ''}
150
155
  public sealed record ContentEntry<TFields>
151
156
  {
152
157
  ${property('id', 'string', 'Id', ' = "";')}${property('fields', 'TFields?', 'Fields')}${property('published_at', 'string?', 'PublishedAt')}${property('remote', 'JsonElement?', 'Remote')}}
@@ -21,7 +21,7 @@ export function generatePython(schema) {
21
21
  case 'file':
22
22
  return 'ContentAsset';
23
23
  case 'rich_text':
24
- return 'ContentRichText';
24
+ return field.document_version === 2 ? 'ContentRichTextV2' : 'ContentRichText';
25
25
  case 'component':
26
26
  return field.variants[0].name;
27
27
  case 'components':
@@ -98,6 +98,11 @@ class RichTextDocument(TypedDict):
98
98
  class ContentRichText(TypedDict):
99
99
  version: Literal[1]
100
100
  document: RichTextDocument
101
+ ${schema.extensions?.rich_text ? `
102
+ class ContentRichTextV2(TypedDict):
103
+ version: Literal[1, 2]
104
+ document: RichTextDocument
105
+ ` : ''}
101
106
 
102
107
  class ContentEntry(TypedDict, Generic[TFields]):
103
108
  id: str
@@ -2,6 +2,9 @@
2
2
  import { writeFile, mkdir, realpath } from 'node:fs/promises';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
+ import { relationshipDefinitions } from './relationships.mjs';
6
+ import { uniqueIdentifierDefinitions } from './unique-identifiers.mjs';
7
+ import { richTextSettings, richTextV2Types, validateRichTextExtension, schemaExtensions } from './rich-text-schema.mjs';
5
8
  const identifier = /^[a-z][a-z0-9_]{0,47}$/;
6
9
  export function generateTypes(schema) {
7
10
  if (
@@ -17,6 +20,9 @@ export function generateTypes(schema) {
17
20
  )
18
21
  )
19
22
  throw new Error('Invalid schema export.');
23
+ const relationships = relationshipDefinitions(schema);
24
+ uniqueIdentifierDefinitions(schema);
25
+ validateRichTextExtension(schema);
20
26
  const keys = new Set(schema.models.map((model) => model.key));
21
27
  let fieldNodes = 0;
22
28
  function type(field, ancestors = []) {
@@ -63,7 +69,10 @@ export function generateTypes(schema) {
63
69
  const targets =
64
70
  (field.targets || []).map((target) => JSON.stringify(target)).join(' | ') ||
65
71
  'never';
66
- return `ContentRichText<${targets}>`;
72
+ const settings = richTextSettings(field);
73
+ return settings.version === 2
74
+ ? `ContentRichTextV2<${targets}, ${settings.features.map(JSON.stringify).join(' | ') || 'never'}>`
75
+ : `ContentRichText<${targets}>`;
67
76
  }
68
77
  case 'number':
69
78
  return 'number';
@@ -106,6 +115,11 @@ export function generateTypes(schema) {
106
115
  ` ${JSON.stringify(model.key)}: {\n${model.fields.map((field) => ` ${JSON.stringify(field.key)}?: ${type(field)} | null;`).join('\n')}\n };`,
107
116
  )
108
117
  .join('\n');
118
+ const relationshipMap = [...schema.models].sort((a, b) => a.key.localeCompare(b.key)).map(model => {
119
+ const fields = relationships.filter(relation => relation.model === model.key).sort((a, b) => a.key.localeCompare(b.key))
120
+ .map(relation => ` ${JSON.stringify(relation.key)}: ${JSON.stringify(relation.source_model)};`).join('\n');
121
+ return ` ${JSON.stringify(model.key)}: {${fields ? `\n${fields}\n ` : ''}};`;
122
+ }).join('\n');
109
123
  const seoModels = Object.fromEntries(
110
124
  schema.models.map((model) => {
111
125
  const mappings = {};
@@ -131,7 +145,7 @@ export function generateTypes(schema) {
131
145
  return [model.key, mappings];
132
146
  }),
133
147
  );
134
- return `// Generated by @lemonadecms/client codegen. Do not edit.\n// Every field allows historical absence/null; previews may be incomplete.\nexport interface ContentReference<Fields> { id: string; fields?: Fields; published_at?: string | null; content_type?: { id: string; key: string; name: string }; project_id?: string; environment?: { id: string; key: string }; locale?: string }\nexport interface ContentRendition { key: string; preset: "hero" | "card" | "social"; width: number; height: number; content_type: string; url: string }\nexport interface ContentAsset { id: string; url: string; content_type: string; byte_size: number; metadata: { title?: string; alt?: string; description?: string; focal_point?: { x: number; y: number } }; renditions?: ContentRendition[] }\nexport type RichTextMark = { type: 'bold' | 'italic' | 'underline' | 'strike' | 'code' } | { type: 'link'; attrs: { href: string } };\nexport type RichTextInline = { type: 'text'; text: string; marks?: RichTextMark[] } | { type: 'hardBreak' };\nexport type RichTextEntry<Target extends keyof Models> = { [K in Target]: { type: 'entry'; attrs: { id: string; target: K }; resource?: ContentReference<Models[K]> | null } }[Target];\nexport type RichTextBlock<Target extends keyof Models = keyof Models> = { type: 'paragraph'; content?: RichTextInline[] } | { type: 'heading'; attrs: { level: 2 | 3 | 4 }; content?: RichTextInline[] } | { type: 'bulletList' | 'orderedList'; attrs?: { start: 1 }; content: { type: 'listItem'; content: RichTextBlock<Target>[] }[] } | { type: 'image'; attrs: { id: string; alt: string }; resource?: ContentAsset | null } | RichTextEntry<Target>;\nexport interface ContentRichText<Target extends keyof Models = keyof Models> { version: 1; document: { type: 'doc'; content?: RichTextBlock<Target>[] } }\nexport interface Models {\n${models}\n}\n\n// Presentation roles, refreshed by codegen after schema changes.\nexport const seoModels = ${JSON.stringify(seoModels, null, 2)} as const;\n`;
148
+ return `// Generated by @lemonadecms/client codegen. Do not edit.\n// Every field allows historical absence/null; previews may be incomplete.\nexport interface ContentReference<Fields> { id: string; fields?: Fields; published_at?: string | null; content_type?: { id: string; key: string; name: string }; project_id?: string; environment?: { id: string; key: string }; locale?: string }\nexport interface ContentRendition { key: string; preset: "hero" | "card" | "social"; width: number; height: number; content_type: string; url: string }\nexport interface ContentAsset { id: string; url: string; content_type: string; byte_size: number; metadata: { title?: string; alt?: string; description?: string; focal_point?: { x: number; y: number } }; renditions?: ContentRendition[] }\nexport type RichTextMark = { type: 'bold' | 'italic' | 'underline' | 'strike' | 'code' } | { type: 'link'; attrs: { href: string } };\nexport type RichTextInline = { type: 'text'; text: string; marks?: RichTextMark[] } | { type: 'hardBreak' };\nexport type RichTextEntry<Target extends keyof Models> = { [K in Target]: { type: 'entry'; attrs: { id: string; target: K }; resource?: ContentReference<Models[K]> | null } }[Target];\nexport type RichTextBlock<Target extends keyof Models = keyof Models> = { type: 'paragraph'; content?: RichTextInline[] } | { type: 'heading'; attrs: { level: 2 | 3 | 4 }; content?: RichTextInline[] } | { type: 'bulletList' | 'orderedList'; attrs?: { start: 1 }; content: { type: 'listItem'; content: RichTextBlock<Target>[] }[] } | { type: 'image'; attrs: { id: string; alt: string }; resource?: ContentAsset | null } | RichTextEntry<Target>;\nexport interface ContentRichText<Target extends keyof Models = keyof Models> { version: 1; document: { type: 'doc'; content?: RichTextBlock<Target>[] } }\n${richTextV2Types(schema)}export interface Models {\n${models}\n}\n\n// Opt-in read-only reverse collections: parent model -> relation -> source model.\nexport interface Relationships {\n${relationshipMap}\n}\n\n// Presentation roles, refreshed by codegen after schema changes.\nexport const seoModels = ${JSON.stringify(seoModels, null, 2)} as const;\n`;
135
149
  }
136
150
  async function main() {
137
151
  const args = process.argv.slice(2);
@@ -173,7 +187,7 @@ async function main() {
173
187
  throw new Error('Set CMS_PUBLIC_KEY or CMS_SECRET_KEY.');
174
188
  if (!options.out) throw new Error('--out is required.');
175
189
  const response = await fetch(
176
- `${url.href.replace(/\/+$/, '')}/api/v1/projects/${project}/schema`,
190
+ `${url.href.replace(/\/+$/, '')}/api/v1/projects/${project}/schema?extensions=${schemaExtensions}`,
177
191
  {
178
192
  headers: { Accept: 'application/json', Authorization: `Bearer ${key}` },
179
193
  credentials: 'omit',
@@ -0,0 +1,87 @@
1
+ // Versioned, explicitly requested delivery metadata. It never adds stored fields
2
+ // or forward-reference edges and is validated before generating or persisting it.
3
+ import {schemaExtensionKeys} from './schema-extensions.mjs';
4
+ const identifier = /^[a-z][a-z0-9_]{0,47}$/;
5
+ const record = (value) =>
6
+ typeof value === 'object' && value !== null && !Array.isArray(value);
7
+ const exact = (value, keys) =>
8
+ record(value) &&
9
+ Object.keys(value).length === keys.length &&
10
+ keys.every((key) => Object.hasOwn(value, key));
11
+ const invalid = () => {
12
+ throw new Error('Invalid relationship extension.');
13
+ };
14
+ function validLabel(value) {
15
+ return (
16
+ typeof value === 'string' &&
17
+ !/^\p{White_Space}|\p{White_Space}$/u.test(value) &&
18
+ Buffer.byteLength(value) >= 1 &&
19
+ Buffer.byteLength(value) <= 80 &&
20
+ !/[\u0000\r\n\ud800-\udfff]/u.test(value)
21
+ );
22
+ }
23
+ export function relationshipDefinitions(schema) {
24
+ if (!Object.hasOwn(schema, 'extensions')) return [];
25
+ if (!record(schema.extensions) || Object.keys(schema.extensions).length === 0 ||
26
+ Object.keys(schema.extensions).some(key => !schemaExtensionKeys.includes(key))) invalid();
27
+ if (!Object.hasOwn(schema.extensions, 'relationships')) return [];
28
+ const extension = schema.extensions.relationships;
29
+ if (
30
+ !exact(extension, ['version', 'definitions']) ||
31
+ extension.version !== 1 ||
32
+ !Array.isArray(extension.definitions) ||
33
+ extension.definitions.length > 6144
34
+ )
35
+ invalid();
36
+ const models = new Map(schema.models.map((model) => [model.key, model]));
37
+ const seen = new Set();
38
+ const sourceFields = new Set();
39
+ for (const definition of extension.definitions) {
40
+ if (
41
+ !exact(definition, ['model', 'key', 'label', 'source_model', 'source_field']) ||
42
+ !['model', 'key', 'source_model', 'source_field'].every(
43
+ (key) =>
44
+ typeof definition[key] === 'string' && identifier.test(definition[key]),
45
+ ) ||
46
+ !validLabel(definition.label)
47
+ )
48
+ invalid();
49
+ const parent = models.get(definition.model);
50
+ const source = models.get(definition.source_model);
51
+ const field = source?.fields.find((field) => field.key === definition.source_field);
52
+ const identity = `${definition.model}:${definition.key}`;
53
+ const origin = `${definition.source_model}:${definition.source_field}`;
54
+ if (
55
+ !parent ||
56
+ !field ||
57
+ !['reference', 'references'].includes(field.type) ||
58
+ field.target !== definition.model ||
59
+ parent.fields.some((field) => field.key === definition.key) ||
60
+ seen.has(identity) ||
61
+ sourceFields.has(origin)
62
+ )
63
+ invalid();
64
+ seen.add(identity);
65
+ sourceFields.add(origin);
66
+ }
67
+ return extension.definitions;
68
+ }
69
+
70
+ export function modelsWithRelationships(schema) {
71
+ const definitions = relationshipDefinitions(schema);
72
+ const bySource = new Map(
73
+ definitions.map((definition) => [
74
+ `${definition.source_model}:${definition.source_field}`,
75
+ definition,
76
+ ]),
77
+ );
78
+ return schema.models.map((model) => ({
79
+ ...model,
80
+ fields: model.fields.map((field) => {
81
+ const reverse = bySource.get(`${model.key}:${field.key}`);
82
+ return reverse
83
+ ? { ...field, reverse: { key: reverse.key, label: reverse.label } }
84
+ : field;
85
+ }),
86
+ }));
87
+ }
@@ -0,0 +1,54 @@
1
+ // Explicit schema capability metadata, separate from ordinary delivery fields.
2
+ import {schemaExtensionKeys} from './schema-extensions.mjs';
3
+ export {schemaExtensions} from './schema-extensions.mjs';
4
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
5
+ const features = ['blockquote', 'code_block', 'table', 'external_media'];
6
+ const invalid = () => { throw new Error('Invalid rich-text schema extension.'); };
7
+ export function richTextSettings(field) {
8
+ if ((Object.hasOwn(field, 'document_version') || Object.hasOwn(field, 'features')) && field.type !== 'rich_text') invalid();
9
+ if (!Object.hasOwn(field, 'document_version') || field.document_version === 1) {
10
+ if (Object.hasOwn(field, 'features')) invalid();
11
+ return {version: 1, features: []};
12
+ }
13
+ const enabled = Object.hasOwn(field, 'features') ? field.features : [];
14
+ if (field.type !== 'rich_text' || field.document_version !== 2 || !Array.isArray(enabled) ||
15
+ enabled.length > features.length || new Set(enabled).size !== enabled.length ||
16
+ !enabled.every(feature => features.includes(feature))) invalid();
17
+ return {version: 2, features: enabled};
18
+ }
19
+
20
+ export function validateRichTextExtension(schema) {
21
+ if (Object.hasOwn(schema, 'extensions')) {
22
+ if (!record(schema.extensions) || Object.keys(schema.extensions).length === 0 ||
23
+ Object.keys(schema.extensions).some(key => !schemaExtensionKeys.includes(key))) invalid();
24
+ if (Object.hasOwn(schema.extensions, 'rich_text')) {
25
+ const extension = schema.extensions.rich_text;
26
+ if (!record(extension) || Object.keys(extension).length !== 1 || extension.version !== 2) invalid();
27
+ }
28
+ }
29
+ function fields(values) {
30
+ for (const field of values) {
31
+ const settings = richTextSettings(field);
32
+ if (settings.version === 2 && schema.extensions?.rich_text?.version !== 2) invalid();
33
+ for (const definition of field.components || []) fields(definition.fields);
34
+ }
35
+ }
36
+ for (const model of schema.models) fields(model.fields);
37
+ }
38
+
39
+ export function richTextV2Types(schema) {
40
+ if (!schema.extensions?.rich_text) return '';
41
+ return `\n// V2 is explicitly enabled per field; historical v1 values remain valid.
42
+ export type RichTextFeature = 'blockquote' | 'code_block' | 'table' | 'external_media';
43
+ export type RichTextParagraph = { type: 'paragraph'; content?: RichTextInline[] };
44
+ export type RichTextCodeBlock = { type: 'codeBlock'; attrs: { language: string }; content?: { type: 'text'; text: string }[] };
45
+ export type RichTextTable = { type: 'table'; attrs: { caption: string; header: boolean }; content: { type: 'tableRow'; content: { type: 'tableCell' | 'tableHeader'; content: [RichTextParagraph] }[] }[] };
46
+ export type RichTextExternalMedia = { type: 'externalMedia'; attrs: { provider: 'youtube' | 'vimeo' | 'spotify' | 'soundcloud'; url: string; title: string; caption: string; transcript: string; poster?: string }; resource?: ContentAsset | null };
47
+ export type RichTextAdvanced<Features extends RichTextFeature> =
48
+ ('blockquote' extends Features ? { type: 'blockquote'; content: RichTextParagraph[] } : never) |
49
+ ('code_block' extends Features ? RichTextCodeBlock : never) |
50
+ ('table' extends Features ? RichTextTable : never) |
51
+ ('external_media' extends Features ? RichTextExternalMedia : never);
52
+ export type RichTextBlockV2<Target extends keyof Models, Features extends RichTextFeature> = RichTextBlock<Target> | RichTextAdvanced<Features>;
53
+ export type ContentRichTextV2<Target extends keyof Models = keyof Models, Features extends RichTextFeature = RichTextFeature> = ContentRichText<Target> | { version: 2; document: { type: 'doc'; content?: RichTextBlockV2<Target, Features>[] } };\n`;
54
+ }
@@ -0,0 +1,6 @@
1
+ // Negotiation affects schema tooling only; ordinary delivery/SDK reads retain
2
+ // their existing response contract and zero runtime dependency boundary.
3
+ export const schemaExtensionKeys = Object.freeze([
4
+ 'relationships', 'rich_text', 'unique_identifiers',
5
+ ]);
6
+ export const schemaExtensions = 'relationships.v1,rich-text.v2,unique-identifiers.v1';
@@ -0,0 +1,50 @@
1
+ import {schemaExtensionKeys} from './schema-extensions.mjs';
2
+
3
+ const identifier = /^[a-z][a-z0-9_]{0,47}$/;
4
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
5
+ const exact = (value, keys) => record(value) && Object.keys(value).length === keys.length &&
6
+ keys.every(key => Object.hasOwn(value, key));
7
+ const invalid = () => { throw new Error('Invalid unique-identifier schema extension.'); };
8
+
9
+ export function uniqueIdentifierDefinitions(schema) {
10
+ // Inline declarations belong to management definitions, never portable
11
+ // delivery exports. Requiring the extension prevents silently lost metadata.
12
+ let nodes = 0;
13
+ function fields(values, depth = 0) {
14
+ if (!Array.isArray(values) || values.length > 24 || depth > 3) invalid();
15
+ for (const field of values) {
16
+ if (++nodes > 150000 || !record(field) || Object.hasOwn(field, 'unique')) invalid();
17
+ const components = field.components || [];
18
+ if (!Array.isArray(components) || components.length > 12) invalid();
19
+ for (const component of components) {
20
+ if (!record(component)) invalid();
21
+ fields(component.fields, depth + 1);
22
+ }
23
+ }
24
+ }
25
+ for (const model of schema.models) fields(model.fields);
26
+ if (!Object.hasOwn(schema, 'extensions')) return [];
27
+ if (!record(schema.extensions) || Object.keys(schema.extensions).length === 0 ||
28
+ Object.keys(schema.extensions).some(key => !schemaExtensionKeys.includes(key))) invalid();
29
+ if (!Object.hasOwn(schema.extensions, 'unique_identifiers')) return [];
30
+ const extension = schema.extensions.unique_identifiers;
31
+ if (!exact(extension, ['version', 'definitions']) || extension.version !== 1 ||
32
+ !Array.isArray(extension.definitions) || extension.definitions.length > 6144) invalid();
33
+ const models = new Map(schema.models.map(model => [model.key, model]));
34
+ const seen = new Set();
35
+ for (const definition of extension.definitions) {
36
+ if (!exact(definition, ['model', 'field']) ||
37
+ !['model', 'field'].every(key => typeof definition[key] === 'string' && identifier.test(definition[key]))) invalid();
38
+ const field = models.get(definition.model)?.fields.find(field => field.key === definition.field);
39
+ const identity = `${definition.model}.${definition.field}`;
40
+ if (!field || field.type !== 'text' || field.localized === true || seen.has(identity)) invalid();
41
+ seen.add(identity);
42
+ }
43
+ return extension.definitions;
44
+ }
45
+
46
+ export function modelsWithUniqueIdentifiers(schema) {
47
+ const definitions = new Set(uniqueIdentifierDefinitions(schema).map(value => `${value.model}.${value.field}`));
48
+ return schema.models.map(model => ({...model, fields: model.fields.map(field =>
49
+ definitions.has(`${model.key}.${field.key}`) ? {...field, unique: true} : field)}));
50
+ }
package/dist/index.d.ts CHANGED
@@ -1,10 +1,11 @@
1
- import type { CMSClient, CMSClientOptions, ModelMap, UntypedModels } from "./types.js";
1
+ import type { CMSClient, CMSClientOptions, ModelMap, RelationshipMap, UntypedModels } from "./types.js";
2
2
  export * from "./errors.js";
3
3
  export type { RemoteValue } from "./types.js";
4
- export type { CountOptions, GetManyOptions, ContentSearch, SearchOptions, TextField } from "./types.js";
4
+ export type { CMSQueryDefaults, RelationshipMap, CountOptions, GetManyOptions, ContentSearch, SearchOptions, TextField } from "./types.js";
5
5
  export type { CMSClient, CMSClientOptions, ContentCollection, ContentEntry, ContentPage, ContentTypeIdentity, ContentEnvironment, ListOptions, FindOneOptions, PaginationOptions, ContentFilter, ContentSort } from "./types.js";
6
6
  /** Published content only. Framework environment variables must be passed explicitly. */
7
- export declare function createCMSClient<Models extends ModelMap<Models> = UntypedModels>(options: CMSClientOptions): CMSClient<Models>;
7
+ export declare function createCMSClient<Models extends ModelMap<Models> = UntypedModels, Relationships extends RelationshipMap<Models> = {}>(options: CMSClientOptions<Models>): CMSClient<Models, Relationships>;
8
8
  export { imageSrcSet } from "./images.js";
9
9
  export type { ContentRendition } from "./images.js";
10
- export { richTextToHtml, richTextToPlainText } from "./rich-text.js";
10
+ export { richTextToHtml, richTextToPlainText, externalMediaDescriptor } from "./rich-text.js";
11
+ export type { ExternalMediaDescriptor, ExternalMediaProvider } from "./rich-text.js";
package/dist/index.js CHANGED
@@ -14,4 +14,4 @@ export function createCMSClient(options) {
14
14
  return createTransport(options, options?.publicKey, "content");
15
15
  }
16
16
  export { imageSrcSet } from "./images.js";
17
- export { richTextToHtml, richTextToPlainText } from "./rich-text.js";
17
+ export { richTextToHtml, richTextToPlainText, externalMediaDescriptor } from "./rich-text.js";