@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/CHANGELOG.md +50 -1
- package/README.md +60 -13
- package/bin/cli/config.mjs +1 -1
- package/bin/cli/create-migration.mjs +10 -3
- package/bin/cli/network.mjs +2 -1
- package/bin/cli/schema.mjs +24 -7
- package/bin/codegen/csharp.mjs +6 -1
- package/bin/codegen/python.mjs +6 -1
- package/bin/generate-types.mjs +25 -10
- package/bin/relationships.mjs +87 -0
- package/bin/rich-text-schema.mjs +54 -0
- package/bin/schema-extensions.mjs +6 -0
- package/bin/unique-identifiers.mjs +50 -0
- package/dist/composer.d.ts +27 -0
- package/dist/composer.js +240 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +1 -1
- package/dist/rich-text-v2.d.ts +20 -0
- package/dist/rich-text-v2.js +242 -0
- package/dist/rich-text.d.ts +3 -1
- package/dist/rich-text.js +60 -25
- package/dist/server.d.ts +5 -4
- package/dist/server.js +1 -1
- package/dist/transport.d.ts +5 -2
- package/dist/transport.js +99 -11
- package/dist/types.d.ts +57 -8
- package/package.json +5 -1
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
|
-
|
|
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
|
|
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";
|
package/dist/transport.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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, ...
|
|
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
|
-
|
|
7
|
-
|
|
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.
|
|
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": {
|