@lemonadecms/client 0.1.1 → 0.2.1

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/dist/rich-text.js CHANGED
@@ -1,3 +1,9 @@
1
+ // Pure presentation helpers for delivered Lemonade rich-text documents.
2
+ // Values are deliberately unknown: generated types are not runtime validation.
3
+ // Never follow resource fields recursively or infer routes/authorization from IDs.
4
+ import { CMSResponseError } from './errors.js';
5
+ import { assertRichTextV2, externalMediaDescriptor, safeRichTextURL } from './rich-text-v2.js';
6
+ export { externalMediaDescriptor } from './rich-text-v2.js';
1
7
  const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
2
8
  const model = /^[a-z][a-z0-9_]{0,47}$/;
3
9
  const encoder = new TextEncoder();
@@ -20,29 +26,7 @@ function escape(value) {
20
26
  }
21
27
  });
22
28
  }
23
- function safeUrl(value, image) {
24
- if (typeof value !== 'string' ||
25
- value.length > (image ? 4096 : 2048) ||
26
- /[\s\\\u0000-\u0020\u007f-\u009f]/u.test(value))
27
- return null;
28
- // Only the consuming-site preview proxy's fixed same-origin route is relative.
29
- if (image &&
30
- /^\/lemonade\/preview\/assets\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value))
31
- return value;
32
- if (!/^https?:\/\//i.test(value) && (image || !/^mailto:/i.test(value)))
33
- return null;
34
- try {
35
- const url = new URL(value);
36
- if (url.username || url.password)
37
- return null;
38
- if ((url.protocol === 'https:' || url.protocol === 'http:') && url.hostname)
39
- return value;
40
- return !image && url.protocol === 'mailto:' && url.pathname ? value : null;
41
- }
42
- catch {
43
- return null;
44
- }
45
- }
29
+ const safeUrl = safeRichTextURL;
46
30
  function matchingResource(node) {
47
31
  const attrs = node.attrs, resource = node.resource;
48
32
  if (!record(attrs) ||
@@ -57,8 +41,13 @@ function matchingResource(node) {
57
41
  return resource;
58
42
  }
59
43
  function convert(value, format) {
44
+ if (record(value) && value.version !== undefined && value.version !== 1 && value.version !== 2)
45
+ throw new CMSResponseError('Unsupported rich-text document version.');
46
+ const version2 = record(value) && value.version === 2;
47
+ if (version2)
48
+ assertRichTextV2(value);
60
49
  if (!record(value) ||
61
- value.version !== 1 ||
50
+ (value.version !== 1 && value.version !== 2) ||
62
51
  !record(value.document) ||
63
52
  value.document.type !== 'doc')
64
53
  return '';
@@ -159,6 +148,38 @@ function convert(value, format) {
159
148
  ? `<p>${content}</p>`
160
149
  : content + (context === 'item' ? '\n' : '\n\n');
161
150
  }
151
+ if (version2 && value.type === 'blockquote' && context === 'block') {
152
+ const content = children('quote');
153
+ return format === 'html' ? `<blockquote>${content}</blockquote>` : content;
154
+ }
155
+ if (version2 && value.type === 'codeBlock' && context === 'block') {
156
+ const content = Array.isArray(value.content)
157
+ ? value.content.map(node => record(node) ? string(node.text) : '').join('') : '';
158
+ const language = record(value.attrs) && typeof value.attrs.language === 'string' ? value.attrs.language : '';
159
+ return format === 'html'
160
+ ? `<pre><code${language ? ` class="language-${escape(language)}"` : ''}>${escape(content)}</code></pre>`
161
+ : content + '\n\n';
162
+ }
163
+ if (version2 && value.type === 'table' && context === 'block') {
164
+ const caption = record(value.attrs) ? string(value.attrs.caption, 500) : '';
165
+ const header = record(value.attrs) && value.attrs.header === true;
166
+ const rows = Array.isArray(value.content) ? value.content.map((row, index) => {
167
+ const cells = record(row) && Array.isArray(row.content) ? row.content.map(cell => visit(cell, 'row', depth + 2)).join(format === 'html' ? '' : '\t') : '';
168
+ if (format !== 'html')
169
+ return cells;
170
+ const rendered = `<tr>${cells}</tr>`;
171
+ return header && index === 0 ? `<thead>${rendered}</thead><tbody>` : rendered;
172
+ }) : [];
173
+ return format === 'html'
174
+ ? `<table><caption>${escape(caption)}</caption>${header ? '' : '<tbody>'}${rows.join('')}</tbody></table>`
175
+ : (caption ? caption + '\n' : '') + rows.join('\n') + '\n\n';
176
+ }
177
+ if (version2 && ['tableCell', 'tableHeader'].includes(String(value.type)) && context === 'row') {
178
+ const content = children('cell');
179
+ if (format === 'text')
180
+ return content.trimEnd();
181
+ return value.type === 'tableHeader' ? `<th scope="col">${content}</th>` : `<td>${content}</td>`;
182
+ }
162
183
  if (value.type === 'bulletList' || value.type === 'orderedList') {
163
184
  const content = children('list');
164
185
  const tag = value.type === 'bulletList' ? 'ul' : 'ol';
@@ -178,6 +199,18 @@ function convert(value, format) {
178
199
  ? `<h${value.attrs.level}>${content}</h${value.attrs.level}>`
179
200
  : content + '\n\n';
180
201
  }
202
+ if (version2 && value.type === 'externalMedia') {
203
+ if (--embeds < 0) {
204
+ exhausted = true;
205
+ return '';
206
+ }
207
+ const media = externalMediaDescriptor(value);
208
+ const title = string(media.title, 500), caption = string(media.caption, 2000), transcript = string(media.transcript);
209
+ if (format === 'text')
210
+ return [title, caption, transcript].filter(Boolean).join('\n') + '\n\n';
211
+ const poster = media.poster ? `<img src="${escape(string(media.poster.url, 4096))}" alt="${escape(string(media.poster.alt, 2000))}" loading="lazy" decoding="async">` : '';
212
+ return `<figure class="rich-text-external-media" data-lemonade-media-provider="${media.provider}" data-lemonade-media-url="${escape(string(media.url, 2048))}"><a href="${escape(media.url)}" rel="noopener noreferrer">${poster}<span data-lemonade-media-title>${escape(title)}</span><span class="rich-text-provider-link">Open on ${media.provider}</span></a><p class="rich-text-provider">External media: ${media.provider}</p>${caption ? `<figcaption>${escape(caption)}</figcaption>` : ''}${transcript ? `<div class="rich-text-transcript"><p class="rich-text-transcript-label">Transcript</p><p>${escape(transcript).replaceAll('\n', '<br>')}</p></div>` : ''}</figure>`;
213
+ }
181
214
  if (value.type !== 'image' && value.type !== 'entry')
182
215
  return '';
183
216
  if (--embeds < 0) {
@@ -216,9 +249,11 @@ function convert(value, format) {
216
249
  : '';
217
250
  }
218
251
  const output = visit(value.document, 'root', 0);
252
+ if (version2 && exhausted)
253
+ throw new CMSResponseError('Rich-text v2 exceeds the rendering budget.');
219
254
  return exhausted ? '' : format === 'text' ? output.trim() : output;
220
255
  }
221
- /** Escaped HTML for rich-text v1, including safe expanded images/entry captions. */
256
+ /** Escaped HTML; v2 rejects malformed/unknown nodes. External media remains a static card. */
222
257
  export function richTextToHtml(value) {
223
258
  return convert(value, 'html');
224
259
  }
package/dist/server.d.ts CHANGED
@@ -1,10 +1,11 @@
1
- import type { CMSClient, CMSPreviewOptions, ModelMap, PreviewModels, UntypedModels } from "./types.js";
1
+ import type { CMSClient, CMSPreviewOptions, ModelMap, RelationshipMap, PreviewModels, UntypedModels } from "./types.js";
2
2
  export * from "./errors.js";
3
- export type { CountOptions, GetManyOptions, ContentSearch, SearchOptions, TextField } from "./types.js";
3
+ export type { CMSQueryDefaults, RelationshipMap, CountOptions, GetManyOptions, ContentSearch, SearchOptions, TextField } from "./types.js";
4
4
  export type { CMSPreviewOptions, PreviewModels, ContentEntry, ContentPage, ContentTypeIdentity, ContentEnvironment, FindOneOptions } from "./types.js";
5
5
  /** Import only from server-owned code. Draft fields may be missing or null. */
6
- export declare function createCMSPreviewClient<Models extends ModelMap<Models> = UntypedModels>(options: CMSPreviewOptions): CMSClient<PreviewModels<Models>>;
6
+ export declare function createCMSPreviewClient<Models extends ModelMap<Models> = UntypedModels, Relationships extends RelationshipMap<Models> = {}>(options: CMSPreviewOptions<Models>): CMSClient<PreviewModels<Models>, Relationships>;
7
7
  export { imageSrcSet } from "./images.js";
8
8
  export type { RemoteValue } from "./types.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/server.js CHANGED
@@ -14,4 +14,4 @@ export function createCMSPreviewClient(options) {
14
14
  return createTransport(options, options?.secretKey, "preview");
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";
@@ -1,3 +1,6 @@
1
- import type { CMSBaseOptions, CMSClient, ModelMap } from "./types.js";
1
+ import type { CMSBaseOptions, CMSClient, ModelMap, RelationshipMap } from "./types.js";
2
2
  /** Internal shared transport; public exports choose the credential and route. */
3
- export declare function createTransport<Models extends ModelMap<Models>>(options: CMSBaseOptions, key: unknown, mode: "content" | "preview"): CMSClient<Models>;
3
+ export declare function createTransport<Models extends ModelMap<Models>, Relationships extends RelationshipMap<Models> = {}>(options: Omit<CMSBaseOptions, "defaults" | "collections"> & {
4
+ defaults?: unknown;
5
+ collections?: unknown;
6
+ }, key: unknown, mode: "content" | "preview"): CMSClient<Models, Relationships>;
package/dist/transport.js CHANGED
@@ -7,7 +7,7 @@ import { CMSConfigurationError, CMSRequestError, CMSResponseError, CMSTimeoutErr
7
7
  const identifier = /^[a-z][a-z0-9_]{0,47}$/;
8
8
  const cursor = (value) => uuid.test(value) || (value.length <= 8192 && /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value));
9
9
  const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
- const serviceURL = "https://lemonadecms.com";
10
+ const serviceURL = "https://api.lemonadecms.com";
11
11
  const localeTag = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?$/;
12
12
  // Narrow unknown JSON to an object before accessing properties. Arrays and null
13
13
  // are not valid objects for the API envelope even though typeof reports object.
@@ -190,6 +190,55 @@ function query(options) {
190
190
  }
191
191
  return params;
192
192
  }
193
+ function snapshotDefaults(value) {
194
+ if (value === undefined)
195
+ return {};
196
+ try {
197
+ readOptions(value, ["locale", "limit", "filters", "sort", "includeRemote", "maxPages"], "defaults");
198
+ const { maxPages, ...wire } = value;
199
+ if (maxPages !== undefined && (!Number.isInteger(maxPages) || maxPages < 1 || maxPages > 10000))
200
+ throw new TypeError();
201
+ query(wire);
202
+ return JSON.parse(JSON.stringify(value));
203
+ }
204
+ catch {
205
+ throw new CMSConfigurationError("Invalid query defaults. Use locale, limit (1–100), filters, sort, includeRemote and maxPages (1–10000) within existing query limits.");
206
+ }
207
+ }
208
+ function mergeDefaults(global, collection, options, kind = "list") {
209
+ if (!record(options))
210
+ throw new TypeError("Query options must be an object.");
211
+ const result = { ...options };
212
+ const scalars = kind === "id" ? ["locale", "includeRemote"] :
213
+ kind === "statistic" ? ["locale"] : ["locale", "includeRemote", "limit", "sort"];
214
+ for (const key of scalars) {
215
+ if (result[key] === undefined) {
216
+ const fallback = collection[key] ?? global[key];
217
+ if (fallback !== undefined)
218
+ Object.assign(result, { [key]: fallback });
219
+ }
220
+ }
221
+ if (kind !== "id") {
222
+ const predicates = [global.filters, collection.filters, options.filters].filter(value => value !== undefined);
223
+ // Reject oversized/sparse request arrays before materializing a combined tree.
224
+ if (predicates.length > 1 && options.filters !== undefined)
225
+ validateFilters(options.filters);
226
+ if (predicates.length === 1)
227
+ result.filters = predicates[0];
228
+ else if (predicates.length > 1)
229
+ result.filters = predicates.flatMap(value => Array.isArray(value) ? Array.from(value) : [value]);
230
+ }
231
+ if (options.page !== undefined || options.pageSize !== undefined) {
232
+ // Explicit mixed modes remain invalid. Only inherited cursor size is mapped.
233
+ if (options.limit === undefined) {
234
+ const size = options.pageSize ?? collection.limit ?? global.limit;
235
+ if (size !== undefined)
236
+ result.pageSize = size;
237
+ delete result.limit;
238
+ }
239
+ }
240
+ return result;
241
+ }
193
242
  // Network responses start as unknown even in TypeScript. Check structural
194
243
  // properties before returning them, and translate HTTP snake_case metadata into
195
244
  // SDK camelCase. This does not validate the caller-declared model field types.
@@ -296,11 +345,15 @@ export function createTransport(options, key, mode) {
296
345
  // credentials to production. Check inherited options too: spreads and plain
297
346
  // JavaScript callers must receive the same migration error as TypeScript.
298
347
  if (["url", "baseURL", "baseUrl", "host", "endpoint"].some(option => option in options)) {
299
- throw new CMSConfigurationError("CMS clients connect to https://lemonadecms.com. Remove custom URL configuration.");
348
+ throw new CMSConfigurationError("CMS clients connect to https://api.lemonadecms.com. Remove custom URL configuration.");
300
349
  }
301
350
  const credential = mode === "content" ? "publicKey" : "secretKey";
302
- if (Object.keys(options).some(option => !["projectId", credential, "fetch", "timeoutMs"].includes(option)) || "environment" in options) {
303
- throw new CMSConfigurationError("Supported CMS options are projectId, the delivery key, fetch and timeoutMs. Select a content environment with its delivery key.");
351
+ if (Object.keys(options).some(option => !["projectId", credential, "fetch", "timeoutMs", "richTextVersion", "defaults", "collections"].includes(option)) || "environment" in options) {
352
+ throw new CMSConfigurationError("Supported CMS options are projectId, the delivery key, fetch, timeoutMs, richTextVersion, defaults and collections. Select a content environment with its delivery key.");
353
+ }
354
+ const richTextVersion = options.richTextVersion === undefined ? 1 : options.richTextVersion;
355
+ if (richTextVersion !== 1 && richTextVersion !== 2) {
356
+ throw new CMSConfigurationError("CMS richTextVersion must be 1 or 2.");
304
357
  }
305
358
  const timeoutMs = options.timeoutMs;
306
359
  if (timeoutMs !== undefined && (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2147483647)) {
@@ -326,6 +379,16 @@ export function createTransport(options, key, mode) {
326
379
  if (typeof fetcher !== "function") {
327
380
  throw new CMSConfigurationError("This environment needs fetch; supply a compatible fetch implementation.");
328
381
  }
382
+ const globalDefaults = snapshotDefaults(options.defaults);
383
+ const collectionDefaults = new Map();
384
+ if (options.collections !== undefined) {
385
+ if (!record(options.collections) || Object.keys(options.collections).length > 256 ||
386
+ Object.keys(options.collections).some(model => !identifier.test(model))) {
387
+ throw new CMSConfigurationError("collections must map at most 256 model identifiers to query defaults.");
388
+ }
389
+ for (const [model, defaults] of Object.entries(options.collections))
390
+ collectionDefaults.set(model, snapshotDefaults(defaults));
391
+ }
329
392
  const projectId = options.projectId;
330
393
  const endpoint = `${serviceURL}/api/v1/projects/${projectId}/${mode}`;
331
394
  return {
@@ -333,15 +396,37 @@ export function createTransport(options, key, mode) {
333
396
  if (typeof model !== "string" || !identifier.test(model)) {
334
397
  throw new TypeError("Content type must be a lowercase model identifier.");
335
398
  }
399
+ const collectionDefaultsForModel = collectionDefaults.get(model) ?? {};
400
+ const effective = (request, kind = "list") => mergeDefaults(globalDefaults, collectionDefaultsForModel, request, kind);
336
401
  return {
402
+ async related(id, relation, options = {}) {
403
+ if (typeof id !== "string" || !uuid.test(id))
404
+ throw new TypeError("id must be an entry UUID.");
405
+ if (typeof relation !== "string" || !identifier.test(relation))
406
+ throw new TypeError("relation must be a lowercase relationship identifier.");
407
+ options = mergeDefaults(globalDefaults, {}, options);
408
+ const requestedLocale = options.locale;
409
+ return await send(options, `/${id}/relations/${relation}`, body => {
410
+ // Reverse lists return the source model, rather than the parent model.
411
+ // New relationship endpoints always include its identity. Reuse ordinary
412
+ // envelope checks and reject mixed-model pages or missing identities.
413
+ if (!record(body) || !Array.isArray(body.data))
414
+ throw new CMSResponseError("CMS returned an invalid related content page.");
415
+ const source = body.data[0]?.content_type?.key;
416
+ if (body.data.length && (typeof source !== "string" || !identifier.test(source) ||
417
+ !body.data.every(entry => record(entry) && record(entry.content_type))))
418
+ throw new CMSResponseError("CMS returned invalid related content-type metadata.");
419
+ return decodePage(body, projectId, source ?? model, requestedLocale);
420
+ });
421
+ },
337
422
  async list(options = {}) {
338
- return await request(options);
423
+ return await request(effective(options));
339
424
  },
340
425
  async search(text, options = {}) {
341
426
  if (!record(options) || "search" in options)
342
427
  throw new TypeError("search accepts a query string and list options with optional fields.");
343
428
  const { fields, ...list } = options;
344
- return await request({ ...list, search: { query: text, ...(fields !== undefined ? { fields } : {}) } });
429
+ return await request(effective({ ...list, search: { query: text, ...(fields !== undefined ? { fields } : {}) } }));
345
430
  },
346
431
  async findOne(options = {}) {
347
432
  return await firstMatch(options);
@@ -363,6 +448,7 @@ export function createTransport(options, key, mode) {
363
448
  if (!Array.isArray(ids) || ids.length > 100 || !Array.from(ids).every(id => typeof id === "string" && uuid.test(id))) {
364
449
  throw new TypeError("getMany ids must contain at most 100 entry UUIDs.");
365
450
  }
451
+ options = effective(options, "id");
366
452
  query(options);
367
453
  options.signal?.throwIfAborted();
368
454
  const ordered = ids.map(id => id.toLowerCase());
@@ -402,19 +488,19 @@ export function createTransport(options, key, mode) {
402
488
  throw new TypeError("id must be an entry UUID.");
403
489
  if (!record(options) || Object.keys(options).some(k => !["select", "locale", "signal", "includeRemote"].includes(k)))
404
490
  throw new TypeError("get supports select, locale, includeRemote and signal.");
405
- return await request(options, id);
491
+ return await request(effective(options, "id"), id);
406
492
  },
407
493
  };
408
494
  async function firstMatch(options) {
409
495
  readOptions(options, ["select", "locale", "filters", "sort", "search", "signal", "includeRemote"], "findOne/findOneOrThrow");
410
- const page = await request({ ...options, limit: 1 });
496
+ const page = await request({ ...effective(options), limit: 1 });
411
497
  if (page.meta.limit !== 1)
412
498
  throw new CMSResponseError("CMS returned an invalid single-match page.");
413
499
  return (page.data[0] ?? null);
414
500
  }
415
501
  async function statistic(operation, options) {
416
502
  readOptions(options, ["locale", "filters", "search", "signal"], operation);
417
- return await send(options, `/_${operation}`, body => {
503
+ return await send(effective(options, "statistic"), `/_${operation}`, body => {
418
504
  if (!record(body) || !record(body.data))
419
505
  throw new CMSResponseError("CMS returned an invalid statistic.");
420
506
  const value = body.data[operation];
@@ -429,7 +515,8 @@ export function createTransport(options, key, mode) {
429
515
  async function* iteratePages(options = {}) {
430
516
  if (!record(options) || "before" in options || "page" in options || "pageSize" in options)
431
517
  throw new TypeError("pages/entries support forward traversal with after.");
432
- const { maxPages = 1000, ...list } = options;
518
+ const { maxPages = collectionDefaultsForModel.maxPages ?? globalDefaults.maxPages ?? 1000, ...input } = options;
519
+ const list = effective(input);
433
520
  if (!Number.isInteger(maxPages) || maxPages < 1 || maxPages > 10000)
434
521
  throw new TypeError("maxPages must be an integer between 1 and 10000.");
435
522
  query(list);
@@ -484,7 +571,8 @@ export function createTransport(options, key, mode) {
484
571
  // propagate to the caller; this client does not silently retry a request.
485
572
  const response = await fetcher(`${endpoint}/${model}${path}${suffix}`, {
486
573
  method: "GET",
487
- headers: { Accept: "application/json", Authorization: `Bearer ${key}` },
574
+ headers: { Accept: "application/json", Authorization: `Bearer ${key}`,
575
+ ...(richTextVersion === 2 ? { "Lemonade-Accept-Content": "rich-text.v2" } : {}) },
488
576
  credentials: "omit",
489
577
  redirect: "error",
490
578
  cache: "no-store",
package/dist/types.d.ts CHANGED
@@ -3,8 +3,15 @@ export type ModelMap<Models> = {
3
3
  };
4
4
  export type UntypedModels = Record<string, Record<string, unknown>>;
5
5
  export type FieldName<Fields> = Extract<keyof Fields, string>;
6
- export interface CMSBaseOptions {
7
- /** Requests always use https://lemonadecms.com. Custom CMS hosts are unsupported. */
6
+ /** Generated reverse names point to source model keys; no relationship data is fetched eagerly. */
7
+ export type RelationshipMap<Models> = {
8
+ [Model in Extract<keyof Models, string>]?: Record<string, Extract<keyof Models, string>>;
9
+ };
10
+ export type RelationshipName<Relationships, Model extends string> = keyof Relationships extends never ? string : Model extends keyof Relationships ? Extract<keyof Relationships[Model], string> : never;
11
+ export type RelatedModel<Models, Relationships, Model extends string, Relation extends string> = Model extends keyof Relationships ? Relation extends keyof Relationships[Model] ? Extract<Relationships[Model][Relation], Extract<keyof Models, string>> : never : string;
12
+ export type RelatedFields<Models, Relationships, Model extends string, Relation extends string> = RelatedModel<Models, Relationships, Model, Relation> extends keyof Models ? Models[RelatedModel<Models, Relationships, Model, Relation>] : Record<string, unknown>;
13
+ export interface CMSBaseOptions<Models extends ModelMap<Models> = {}> {
14
+ /** Requests always use https://api.lemonadecms.com. Custom CMS hosts are unsupported. */
8
15
  url?: never;
9
16
  projectId: string;
10
17
  /** Compatible transport for SSR, testing or polyfills; receives production URLs. */
@@ -14,11 +21,19 @@ export interface CMSBaseOptions {
14
21
  * No default deadline. Combining with a caller signal requires AbortSignal.any.
15
22
  */
16
23
  timeoutMs?: number;
24
+ /** Explicitly accept opt-in rich-text v2 documents. Defaults to the v1 contract. */
25
+ richTextVersion?: 1 | 2;
26
+ /** Query conveniences, never an authorization boundary. Filters combine with AND. */
27
+ defaults?: keyof Models extends never ? never : CMSQueryDefaults<Models[Extract<keyof Models, string>]>;
28
+ /** Checked model names and source fields; scalar values override global defaults. */
29
+ collections?: keyof Models extends never ? never : {
30
+ [Model in Extract<keyof Models, string>]?: CMSQueryDefaults<Models[Model]>;
31
+ };
17
32
  }
18
- export interface CMSClientOptions extends CMSBaseOptions {
33
+ export interface CMSClientOptions<Models extends ModelMap<Models> = {}> extends CMSBaseOptions<Models> {
19
34
  publicKey: string;
20
35
  }
21
- export interface CMSPreviewOptions extends CMSBaseOptions {
36
+ export interface CMSPreviewOptions<Models extends ModelMap<Models> = {}> extends CMSBaseOptions<Models> {
22
37
  secretKey: string;
23
38
  }
24
39
  type Scalar = string | number | boolean;
@@ -51,6 +66,30 @@ type ChoicesContentFilter<Fields> = {
51
66
  value: readonly ChoicesValue<Fields[Key]>[];
52
67
  };
53
68
  }[ChoicesField<Fields>];
69
+ type ReferenceIdentity = {
70
+ id: string;
71
+ url?: never;
72
+ };
73
+ type ReferenceField<Fields, Many extends boolean> = {
74
+ [Key in FieldName<Fields>]: unknown extends Fields[Key] ? never : [NonNullable<Fields[Key]>] extends [never] ? never : NonNullable<Fields[Key]> extends (Many extends true ? readonly ReferenceIdentity[] : ReferenceIdentity) ? Key : never;
75
+ }[FieldName<Fields>];
76
+ type ReferenceContentFilter<Fields> = {
77
+ [Key in ReferenceField<Fields, false>]: {
78
+ field: Key;
79
+ op: "eq" | "ne";
80
+ value: string | null;
81
+ } | {
82
+ field: Key;
83
+ op: "in" | "not_in";
84
+ value: readonly string[];
85
+ };
86
+ }[ReferenceField<Fields, false>] | {
87
+ [Key in ReferenceField<Fields, true>]: {
88
+ field: Key;
89
+ op: "contains_any" | "contains_all";
90
+ value: readonly string[];
91
+ };
92
+ }[ReferenceField<Fields, true>];
54
93
  export type TextField<Fields> = {
55
94
  [Key in FieldName<Fields>]: unknown extends Fields[Key] ? Key : NonNullable<Fields[Key]> extends string ? Key : never;
56
95
  }[FieldName<Fields>];
@@ -61,7 +100,7 @@ type TextContentFilter<Fields> = {
61
100
  value: string;
62
101
  };
63
102
  }[TextField<Fields>];
64
- export type ContentFilter<Fields> = ScalarContentFilter<Fields> | ChoicesContentFilter<Fields> | TextContentFilter<Fields> | {
103
+ export type ContentFilter<Fields> = ScalarContentFilter<Fields> | ChoicesContentFilter<Fields> | TextContentFilter<Fields> | ReferenceContentFilter<Fields> | {
65
104
  and: readonly ContentFilter<Fields>[];
66
105
  } | {
67
106
  or: readonly ContentFilter<Fields>[];
@@ -90,6 +129,14 @@ interface BaseListOptions<Key extends string, Fields> {
90
129
  /** Explicit live enrichment, independently cached from the saved publication. */
91
130
  includeRemote?: boolean;
92
131
  }
132
+ /** Defaults never include projections, cursors, search text or cancellation signals.
133
+ * ID reads inherit locale/includeRemote only; statistics inherit locale/filters only.
134
+ * Global fields must be valid on every collection queried with those defaults.
135
+ */
136
+ export type CMSQueryDefaults<Fields = Record<string, unknown>> = Pick<BaseListOptions<string, Fields>, "locale" | "limit" | "filters" | "sort" | "includeRemote"> & {
137
+ /** Forward traversal only. Default 1,000; maximum 10,000. */
138
+ maxPages?: number;
139
+ };
93
140
  /** Follow a returned boundary in exactly one direction; or request a numbered page; modes cannot be combined. */
94
141
  export type ListOptions<Key extends string = string, Fields = Record<string, unknown>> = BaseListOptions<Key, Fields> & ({
95
142
  after?: string;
@@ -170,7 +217,9 @@ export interface ContentPage<Fields, Model extends string = string> {
170
217
  hasPreviousPage?: boolean;
171
218
  };
172
219
  }
173
- export interface ContentCollection<Fields extends object, Model extends string = string> {
220
+ export interface ContentCollection<Fields extends object, Model extends string = string, Models extends ModelMap<Models> = UntypedModels, Relationships extends RelationshipMap<Models> = {}> {
221
+ /** Read one explicitly configured reverse collection; edit its source references in Studio. */
222
+ related<Relation extends RelationshipName<Relationships, Model>, const Key extends FieldName<RelatedFields<Models, Relationships, Model, Relation>> = FieldName<RelatedFields<Models, Relationships, Model, Relation>>>(id: string, relation: Relation, options?: ListOptions<Key, RelatedFields<Models, Relationships, Model, Relation>>): Promise<ContentPage<Pick<RelatedFields<Models, Relationships, Model, Relation>, Key>, RelatedModel<Models, Relationships, Model, Relation>>>;
174
223
  list<const Key extends FieldName<Fields> = FieldName<Fields>>(options?: ListOptions<Key, Fields>): Promise<ContentPage<Pick<Fields, Key>, Model>>;
175
224
  /** One page of plain text matches; relevance order unless an explicit sort is supplied. */
176
225
  search<const Key extends FieldName<Fields> = FieldName<Fields>>(query: string, options?: SearchOptions<Key, Fields>): Promise<ContentPage<Pick<Fields, Key>, Model>>;
@@ -190,8 +239,8 @@ export interface ContentCollection<Fields extends object, Model extends string =
190
239
  entries<const Key extends FieldName<Fields> = FieldName<Fields>>(options?: PaginationOptions<Key, Fields>): AsyncIterableIterator<ContentEntry<Pick<Fields, Key>, Model>>;
191
240
  get<const Key extends FieldName<Fields> = FieldName<Fields>>(id: string, options?: Pick<ListOptions<Key, Fields>, "select" | "locale" | "signal" | "includeRemote">): Promise<ContentEntry<Pick<Fields, Key>, Model>>;
192
241
  }
193
- export interface CMSClient<Models extends ModelMap<Models> = UntypedModels> {
194
- content<Model extends Extract<keyof Models, string>>(model: Model): ContentCollection<Models[Model], Model>;
242
+ export interface CMSClient<Models extends ModelMap<Models> = UntypedModels, Relationships extends RelationshipMap<Models> = {}> {
243
+ content<Model extends Extract<keyof Models, string>>(model: Model): ContentCollection<Models[Model], Model, Models, Relationships>;
195
244
  }
196
245
  /** A draft may omit required fields or contain nulls until it is published. */
197
246
  export type PreviewModels<Models> = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lemonadecms/client",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Framework-independent content delivery and developer tooling for Lemonade CMS",
5
5
  "license": "MIT",
6
6
  "homepage": "https://lemonadecms.com/guides/sdk",
@@ -44,6 +44,10 @@
44
44
  "./preview": {
45
45
  "types": "./dist/preview.d.ts",
46
46
  "default": "./dist/preview.js"
47
+ },
48
+ "./composer": {
49
+ "types": "./dist/composer.d.ts",
50
+ "default": "./dist/composer.js"
47
51
  }
48
52
  },
49
53
  "bin": {