@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.
@@ -0,0 +1,20 @@
1
+ export type ExternalMediaProvider = 'youtube' | 'vimeo' | 'spotify' | 'soundcloud';
2
+ export interface ExternalMediaDescriptor {
3
+ provider: ExternalMediaProvider;
4
+ url: string;
5
+ embedUrl: string;
6
+ title: string;
7
+ caption: string;
8
+ transcript: string;
9
+ poster: {
10
+ id: string;
11
+ url: string;
12
+ alt: string;
13
+ } | null;
14
+ }
15
+ /** The only relative URL accepted by first-party renderers is the private website asset proxy. */
16
+ export declare function safeRichTextURL(value: unknown, image: boolean): string | null;
17
+ /** Pure player metadata. This performs no fetches, creates no iframe and grants no playback consent. */
18
+ export declare function externalMediaDescriptor(value: unknown): ExternalMediaDescriptor;
19
+ /** Strict v2 validation protects renderers from silently dropping unknown future nodes. */
20
+ export declare function assertRichTextV2(value: unknown): void;
@@ -0,0 +1,242 @@
1
+ import { CMSResponseError } from './errors.js';
2
+ const encoder = new TextEncoder();
3
+ const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
4
+ const identifier = /^[a-z][a-z0-9_]{0,47}$/;
5
+ const imageTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
6
+ const record = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
7
+ const own = (value, keys) => Object.keys(value).every(key => keys.includes(key));
8
+ function invalid() {
9
+ throw new CMSResponseError('Invalid or unsupported rich-text v2 document.');
10
+ }
11
+ const validString = (value, max, min = 0) => typeof value === 'string' && !/[\u0000\ud800-\udfff]/u.test(value) &&
12
+ encoder.encode(value).byteLength >= min && encoder.encode(value).byteLength <= max;
13
+ /** The only relative URL accepted by first-party renderers is the private website asset proxy. */
14
+ export function safeRichTextURL(value, image) {
15
+ if (typeof value !== 'string' || value.length > (image ? 4096 : 2048) ||
16
+ /[\s\\\u0000-\u0020\u007f-\u009f]/u.test(value))
17
+ return null;
18
+ if (image && /^\/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)) {
19
+ const params = new URL(value, 'https://preview.invalid').searchParams;
20
+ const keys = [];
21
+ params.forEach((_value, key) => keys.push(key));
22
+ const recipes = ['hero-640', 'hero-1280', 'card-320', 'card-640', 'social-1200'];
23
+ if (keys.length > 4 || new Set(keys).size !== keys.length ||
24
+ keys.some(key => !['rendition', 'v', 'impact', 'composition'].includes(key)) ||
25
+ (params.has('rendition') && !recipes.includes(params.get('rendition'))) ||
26
+ (params.has('v') && !/^[A-Za-z0-9_-]{43}$/.test(params.get('v'))) ||
27
+ (params.has('impact') && !['before', 'after'].includes(params.get('impact'))) ||
28
+ (params.has('composition') && (params.get('composition').length > 2048 ||
29
+ !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(params.get('composition')))))
30
+ return null;
31
+ return value;
32
+ }
33
+ if (!/^https?:\/\//i.test(value) && (image || !/^mailto:/i.test(value)))
34
+ return null;
35
+ try {
36
+ const url = new URL(value);
37
+ if (url.username || url.password)
38
+ return null;
39
+ if ((url.protocol === 'https:' || url.protocol === 'http:') && url.hostname)
40
+ return value;
41
+ return !image && url.protocol === 'mailto:' && url.pathname ? value : null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ function external(attrs) {
48
+ if (!record(attrs) || !own(attrs, ['provider', 'url', 'title', 'caption', 'transcript', 'poster']) ||
49
+ !validString(attrs.title, 500, 1) || !/[^\p{White_Space}]/u.test(attrs.title) ||
50
+ !validString(attrs.caption, 2000) || !validString(attrs.transcript, 20_000) ||
51
+ !validString(attrs.url, 2048, 1) ||
52
+ (attrs.poster !== undefined && (typeof attrs.poster !== 'string' || !uuid.test(attrs.poster))))
53
+ invalid();
54
+ const { provider, url, title, caption, transcript } = attrs;
55
+ let embedUrl;
56
+ let match;
57
+ if (provider === 'youtube' && (match = url.match(/^https:\/\/www\.youtube\.com\/watch\?v=([A-Za-z0-9_-]{11})$/))) {
58
+ embedUrl = `https://www.youtube-nocookie.com/embed/${match[1]}`;
59
+ }
60
+ else if (provider === 'vimeo' && (match = url.match(/^https:\/\/vimeo\.com\/([1-9][0-9]{0,11})$/))) {
61
+ embedUrl = `https://player.vimeo.com/video/${match[1]}?dnt=1`;
62
+ }
63
+ else if (provider === 'spotify' && (match = url.match(/^https:\/\/open\.spotify\.com\/episode\/([A-Za-z0-9]{22})$/))) {
64
+ embedUrl = `https://open.spotify.com/embed/episode/${match[1]}`;
65
+ }
66
+ else if (provider === 'soundcloud' && (match = url.match(/^https:\/\/soundcloud\.com\/([A-Za-z0-9][A-Za-z0-9_-]{0,199})\/([A-Za-z0-9][A-Za-z0-9_-]{0,199})$/)) &&
67
+ !['sets', 'playlists', 'discover', 'charts', 'search', 'you'].includes(match[1].toLowerCase()) &&
68
+ !['sets', 'tracks', 'albums', 'reposts', 'likes', 'popular-tracks'].includes(match[2].toLowerCase())) {
69
+ embedUrl = `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&auto_play=false`;
70
+ }
71
+ else
72
+ invalid();
73
+ return { provider: provider, url, embedUrl, title, caption, transcript };
74
+ }
75
+ /** Pure player metadata. This performs no fetches, creates no iframe and grants no playback consent. */
76
+ export function externalMediaDescriptor(value) {
77
+ if (!record(value) || value.type !== 'externalMedia' || !own(value, ['type', 'attrs', 'resource']))
78
+ invalid();
79
+ const descriptor = external(value.attrs);
80
+ const attrs = value.attrs;
81
+ const resource = value.resource;
82
+ let poster = null;
83
+ if (typeof attrs.poster === 'string' && record(resource) && typeof resource.id === 'string' &&
84
+ attrs.poster.toLowerCase() === resource.id.toLowerCase() && typeof resource.content_type === 'string' && imageTypes.includes(resource.content_type)) {
85
+ const url = safeRichTextURL(resource.url, true);
86
+ const metadata = record(resource.metadata) ? resource.metadata : {};
87
+ if (url)
88
+ poster = { id: attrs.poster, url, alt: validString(metadata.alt, 2000) ? metadata.alt : '' };
89
+ }
90
+ return { ...descriptor, poster };
91
+ }
92
+ /** Strict v2 validation protects renderers from silently dropping unknown future nodes. */
93
+ export function assertRichTextV2(value) {
94
+ if (!record(value) || value.version !== 2 || !own(value, ['version', 'document']))
95
+ invalid();
96
+ let remaining = 500, embeds = 20, bytes = 65_536;
97
+ const string = (value, max, min = 0) => {
98
+ if (!validString(value, max, min))
99
+ invalid();
100
+ bytes -= encoder.encode(value).byteLength;
101
+ if (bytes < 0)
102
+ invalid();
103
+ return value;
104
+ };
105
+ function node(value, context, depth) {
106
+ if (!record(value) || depth > 8 || --remaining < 0 || typeof value.type !== 'string')
107
+ invalid();
108
+ const children = (next, min = 0) => {
109
+ const list = value.content ?? [];
110
+ if (!Array.isArray(list) || list.length < min || list.length > 200)
111
+ invalid();
112
+ for (const child of list)
113
+ node(child, next, depth + 1);
114
+ return list;
115
+ };
116
+ if (context === 'inline' || context === 'code') {
117
+ if (context === 'inline' && value.type === 'hardBreak' && own(value, ['type']))
118
+ return;
119
+ if (value.type !== 'text' || !own(value, context === 'code' ? ['type', 'text'] : ['type', 'text', 'marks']))
120
+ invalid();
121
+ string(value.text, 20_000, 1);
122
+ if (context === 'code')
123
+ return;
124
+ const marks = value.marks ?? [];
125
+ if (!Array.isArray(marks) || marks.length > 6)
126
+ invalid();
127
+ const seen = new Set();
128
+ for (const mark of marks) {
129
+ if (!record(mark) || typeof mark.type !== 'string' || seen.has(mark.type))
130
+ invalid();
131
+ seen.add(mark.type);
132
+ if (mark.type === 'link') {
133
+ if (!own(mark, ['type', 'attrs']) || !record(mark.attrs) || !own(mark.attrs, ['href']) ||
134
+ !safeRichTextURL(string(mark.attrs.href, 2048, 1), false))
135
+ invalid();
136
+ }
137
+ else if (!['bold', 'italic', 'underline', 'strike', 'code'].includes(mark.type) || !own(mark, ['type']))
138
+ invalid();
139
+ }
140
+ return;
141
+ }
142
+ if (context === 'root') {
143
+ if (value.type !== 'doc' || !own(value, ['type', 'content']))
144
+ invalid();
145
+ children('block');
146
+ return;
147
+ }
148
+ if (context === 'list') {
149
+ if (value.type !== 'listItem' || !own(value, ['type', 'content']) || !Array.isArray(value.content) ||
150
+ !record(value.content[0]) || value.content[0].type !== 'paragraph')
151
+ invalid();
152
+ children('item', 1);
153
+ return;
154
+ }
155
+ if (context === 'row' || context === 'headerRow') {
156
+ if (value.type !== (context === 'headerRow' ? 'tableHeader' : 'tableCell') ||
157
+ !own(value, ['type', 'content']) || !Array.isArray(value.content) || value.content.length !== 1)
158
+ invalid();
159
+ children('cell', 1);
160
+ return;
161
+ }
162
+ if (value.type === 'paragraph' && ['block', 'item', 'quote', 'cell'].includes(context)) {
163
+ if (!own(value, ['type', 'content']))
164
+ invalid();
165
+ children('inline');
166
+ return;
167
+ }
168
+ if (['bulletList', 'orderedList'].includes(value.type) && ['block', 'item'].includes(context)) {
169
+ if (!own(value, ['type', 'content', 'attrs']) ||
170
+ (value.attrs !== undefined && (!record(value.attrs) || !own(value.attrs, ['start']) ||
171
+ Object.keys(value.attrs).length > 0 && (value.type !== 'orderedList' || value.attrs.start !== 1))))
172
+ invalid();
173
+ children('list', 1);
174
+ return;
175
+ }
176
+ if (context !== 'block')
177
+ invalid();
178
+ if (value.type === 'heading') {
179
+ if (!own(value, ['type', 'attrs', 'content']) || !record(value.attrs) ||
180
+ !own(value.attrs, ['level']) || typeof value.attrs.level !== 'number' || ![2, 3, 4].includes(value.attrs.level))
181
+ invalid();
182
+ children('inline');
183
+ return;
184
+ }
185
+ if (value.type === 'blockquote') {
186
+ if (!own(value, ['type', 'content']))
187
+ invalid();
188
+ children('quote', 1);
189
+ return;
190
+ }
191
+ if (value.type === 'codeBlock') {
192
+ if (!own(value, ['type', 'attrs', 'content']) || !record(value.attrs) || !own(value.attrs, ['language']))
193
+ invalid();
194
+ const language = string(value.attrs.language, 32);
195
+ if (language !== '' && !/^[A-Za-z][A-Za-z0-9_+.#-]{0,31}$/.test(language))
196
+ invalid();
197
+ children('code');
198
+ return;
199
+ }
200
+ if (value.type === 'table') {
201
+ if (!own(value, ['type', 'attrs', 'content']) || !record(value.attrs) ||
202
+ !own(value.attrs, ['caption', 'header']) || typeof value.attrs.header !== 'boolean' ||
203
+ !Array.isArray(value.content) || value.content.length < 1 || value.content.length > 20)
204
+ invalid();
205
+ string(value.attrs.caption, 500);
206
+ let columns = 0;
207
+ for (let index = 0; index < value.content.length; index++) {
208
+ const row = value.content[index];
209
+ if (!record(row) || --remaining < 0 || depth + 1 > 8 || row.type !== 'tableRow' ||
210
+ !own(row, ['type', 'content']) || !Array.isArray(row.content) || row.content.length < 1 || row.content.length > 6)
211
+ invalid();
212
+ columns ||= row.content.length;
213
+ if (row.content.length !== columns)
214
+ invalid();
215
+ for (const cell of row.content)
216
+ node(cell, index === 0 && value.attrs.header ? 'headerRow' : 'row', depth + 2);
217
+ }
218
+ return;
219
+ }
220
+ if (!['image', 'entry', 'externalMedia'].includes(value.type) || --embeds < 0)
221
+ invalid();
222
+ if (value.type === 'externalMedia') {
223
+ if (!own(value, ['type', 'attrs', 'resource']))
224
+ invalid();
225
+ external(value.attrs);
226
+ const attrs = value.attrs;
227
+ string(attrs.title, 500, 1);
228
+ string(attrs.caption, 2000);
229
+ string(attrs.transcript, 20_000);
230
+ return;
231
+ }
232
+ if (!own(value, ['type', 'attrs', 'resource']) || !record(value.attrs) ||
233
+ !own(value.attrs, value.type === 'image' ? ['id', 'alt'] : ['id', 'target']) ||
234
+ typeof value.attrs.id !== 'string' || !uuid.test(value.attrs.id))
235
+ invalid();
236
+ if (value.type === 'image')
237
+ string(value.attrs.alt, 2000);
238
+ else if (typeof value.attrs.target !== 'string' || !identifier.test(value.attrs.target))
239
+ invalid();
240
+ }
241
+ node(value.document, 'root', 0);
242
+ }
@@ -1,4 +1,6 @@
1
- /** Escaped HTML for rich-text v1, including safe expanded images/entry captions. */
1
+ export { externalMediaDescriptor } from './rich-text-v2.js';
2
+ export type { ExternalMediaDescriptor, ExternalMediaProvider } from './rich-text-v2.js';
3
+ /** Escaped HTML; v2 rejects malformed/unknown nodes. External media remains a static card. */
2
4
  export declare function richTextToHtml(value: unknown): string;
3
5
  /** Authored text with block/hard-break newlines; excludes embeds and link URLs. */
4
6
  export declare function richTextToPlainText(value: unknown): string;
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
@@ -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.
@@ -299,8 +348,12 @@ export function createTransport(options, key, mode) {
299
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",