@lemonadecms/client 0.1.0 → 0.2.0
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 +85 -4
- package/README.md +206 -5
- package/bin/cli/config.mjs +63 -8
- package/bin/cli/network.mjs +50 -15
- package/bin/cli/picker.mjs +118 -0
- package/bin/cli/run.mjs +55 -5
- package/bin/cli/schema.mjs +6 -6
- package/bin/generate-types.mjs +9 -8
- package/dist/composer.d.ts +27 -0
- package/dist/composer.js +240 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +9 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/rich-text.d.ts +4 -0
- package/dist/rich-text.js +228 -0
- package/dist/server.d.ts +3 -1
- package/dist/server.js +1 -0
- package/dist/transport.js +198 -30
- package/dist/types.d.ts +79 -16
- package/package.json +5 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
|
2
|
+
const model = /^[a-z][a-z0-9_]{0,47}$/;
|
|
3
|
+
const encoder = new TextEncoder();
|
|
4
|
+
function record(value) {
|
|
5
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function escape(value) {
|
|
8
|
+
return value.replace(/[&<>"']/g, (character) => {
|
|
9
|
+
switch (character) {
|
|
10
|
+
case '&':
|
|
11
|
+
return '&';
|
|
12
|
+
case '<':
|
|
13
|
+
return '<';
|
|
14
|
+
case '>':
|
|
15
|
+
return '>';
|
|
16
|
+
case '"':
|
|
17
|
+
return '"';
|
|
18
|
+
default:
|
|
19
|
+
return ''';
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
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
|
+
}
|
|
46
|
+
function matchingResource(node) {
|
|
47
|
+
const attrs = node.attrs, resource = node.resource;
|
|
48
|
+
if (!record(attrs) ||
|
|
49
|
+
!record(resource) ||
|
|
50
|
+
typeof attrs.id !== 'string' ||
|
|
51
|
+
attrs.id.length !== 36 ||
|
|
52
|
+
!uuid.test(attrs.id) ||
|
|
53
|
+
typeof resource.id !== 'string' ||
|
|
54
|
+
resource.id.length !== 36 ||
|
|
55
|
+
attrs.id.toLowerCase() !== resource.id.toLowerCase())
|
|
56
|
+
return null;
|
|
57
|
+
return resource;
|
|
58
|
+
}
|
|
59
|
+
function convert(value, format) {
|
|
60
|
+
if (!record(value) ||
|
|
61
|
+
value.version !== 1 ||
|
|
62
|
+
!record(value.document) ||
|
|
63
|
+
value.document.type !== 'doc')
|
|
64
|
+
return '';
|
|
65
|
+
// The CMS bounds the authored document. Apply independent bounds to unknown
|
|
66
|
+
// consumer input and expanded strings as well; exhaustion discards all output.
|
|
67
|
+
let remaining = 500, embeds = 20, bytes = 65_536, exhausted = false;
|
|
68
|
+
function string(value, maximum = 20_000) {
|
|
69
|
+
if (typeof value !== 'string')
|
|
70
|
+
return '';
|
|
71
|
+
if (value.length > maximum) {
|
|
72
|
+
exhausted = true;
|
|
73
|
+
return '';
|
|
74
|
+
}
|
|
75
|
+
const size = encoder.encode(value).byteLength;
|
|
76
|
+
bytes -= size;
|
|
77
|
+
if (size > maximum || bytes < 0) {
|
|
78
|
+
exhausted = true;
|
|
79
|
+
return '';
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
function visit(value, context, depth) {
|
|
84
|
+
if (exhausted)
|
|
85
|
+
return '';
|
|
86
|
+
if (depth > 8 || --remaining < 0) {
|
|
87
|
+
exhausted = true;
|
|
88
|
+
return '';
|
|
89
|
+
}
|
|
90
|
+
if (!record(value))
|
|
91
|
+
return '';
|
|
92
|
+
const children = (next) => {
|
|
93
|
+
if (value.content === undefined)
|
|
94
|
+
return '';
|
|
95
|
+
if (!Array.isArray(value.content) || value.content.length > 200) {
|
|
96
|
+
exhausted = true;
|
|
97
|
+
return '';
|
|
98
|
+
}
|
|
99
|
+
return value.content.map((child) => visit(child, next, depth + 1)).join('');
|
|
100
|
+
};
|
|
101
|
+
if (context === 'inline') {
|
|
102
|
+
if (value.type === 'hardBreak')
|
|
103
|
+
return format === 'html' ? '<br>' : '\n';
|
|
104
|
+
if (value.type !== 'text')
|
|
105
|
+
return '';
|
|
106
|
+
const source = string(value.text);
|
|
107
|
+
if (format === 'text')
|
|
108
|
+
return source;
|
|
109
|
+
let text = escape(source);
|
|
110
|
+
const marks = value.marks;
|
|
111
|
+
if (marks !== undefined && (!Array.isArray(marks) || marks.length > 6)) {
|
|
112
|
+
exhausted = true;
|
|
113
|
+
return '';
|
|
114
|
+
}
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
for (const mark of Array.isArray(marks) ? marks : []) {
|
|
117
|
+
if (!record(mark) ||
|
|
118
|
+
typeof mark.type !== 'string' ||
|
|
119
|
+
mark.type.length > 9 ||
|
|
120
|
+
seen.has(mark.type))
|
|
121
|
+
continue;
|
|
122
|
+
seen.add(mark.type);
|
|
123
|
+
switch (mark.type) {
|
|
124
|
+
case 'bold':
|
|
125
|
+
text = `<strong>${text}</strong>`;
|
|
126
|
+
break;
|
|
127
|
+
case 'italic':
|
|
128
|
+
text = `<em>${text}</em>`;
|
|
129
|
+
break;
|
|
130
|
+
case 'underline':
|
|
131
|
+
text = `<u>${text}</u>`;
|
|
132
|
+
break;
|
|
133
|
+
case 'strike':
|
|
134
|
+
text = `<s>${text}</s>`;
|
|
135
|
+
break;
|
|
136
|
+
case 'code':
|
|
137
|
+
text = `<code>${text}</code>`;
|
|
138
|
+
break;
|
|
139
|
+
case 'link': {
|
|
140
|
+
const href = safeUrl(record(mark.attrs) ? mark.attrs.href : null, false);
|
|
141
|
+
if (href)
|
|
142
|
+
text = `<a href="${escape(string(href, 2048))}" rel="noopener noreferrer">${text}</a>`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return text;
|
|
147
|
+
}
|
|
148
|
+
if (context === 'root')
|
|
149
|
+
return value.type === 'doc' ? children('block') : '';
|
|
150
|
+
if (context === 'list') {
|
|
151
|
+
if (value.type !== 'listItem')
|
|
152
|
+
return '';
|
|
153
|
+
const content = children('item');
|
|
154
|
+
return format === 'html' ? `<li>${content}</li>` : content.trimEnd() + '\n';
|
|
155
|
+
}
|
|
156
|
+
if (value.type === 'paragraph') {
|
|
157
|
+
const content = children('inline');
|
|
158
|
+
return format === 'html'
|
|
159
|
+
? `<p>${content}</p>`
|
|
160
|
+
: content + (context === 'item' ? '\n' : '\n\n');
|
|
161
|
+
}
|
|
162
|
+
if (value.type === 'bulletList' || value.type === 'orderedList') {
|
|
163
|
+
const content = children('list');
|
|
164
|
+
const tag = value.type === 'bulletList' ? 'ul' : 'ol';
|
|
165
|
+
return format === 'html'
|
|
166
|
+
? `<${tag}>${content}</${tag}>`
|
|
167
|
+
: content + (context === 'item' ? '' : '\n');
|
|
168
|
+
}
|
|
169
|
+
if (context !== 'block')
|
|
170
|
+
return '';
|
|
171
|
+
if (value.type === 'heading') {
|
|
172
|
+
if (!record(value.attrs) ||
|
|
173
|
+
typeof value.attrs.level !== 'number' ||
|
|
174
|
+
![2, 3, 4].includes(value.attrs.level))
|
|
175
|
+
return '';
|
|
176
|
+
const content = children('inline');
|
|
177
|
+
return format === 'html'
|
|
178
|
+
? `<h${value.attrs.level}>${content}</h${value.attrs.level}>`
|
|
179
|
+
: content + '\n\n';
|
|
180
|
+
}
|
|
181
|
+
if (value.type !== 'image' && value.type !== 'entry')
|
|
182
|
+
return '';
|
|
183
|
+
if (--embeds < 0) {
|
|
184
|
+
exhausted = true;
|
|
185
|
+
return '';
|
|
186
|
+
}
|
|
187
|
+
if (format === 'text')
|
|
188
|
+
return '';
|
|
189
|
+
const resource = matchingResource(value);
|
|
190
|
+
if (!resource || !record(value.attrs))
|
|
191
|
+
return '';
|
|
192
|
+
if (value.type === 'image') {
|
|
193
|
+
if (typeof resource.content_type !== 'string' ||
|
|
194
|
+
!['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(resource.content_type))
|
|
195
|
+
return '';
|
|
196
|
+
const src = safeUrl(resource.url, true);
|
|
197
|
+
if (!src)
|
|
198
|
+
return '';
|
|
199
|
+
const metadata = record(resource.metadata) ? resource.metadata : {};
|
|
200
|
+
const alt = typeof value.attrs.alt === 'string' ? value.attrs.alt : metadata.alt;
|
|
201
|
+
return `<figure><img src="${escape(string(src, 4096))}" alt="${escape(string(alt, 2000))}" loading="lazy" decoding="async"></figure>`;
|
|
202
|
+
}
|
|
203
|
+
if (typeof value.attrs.target !== 'string' ||
|
|
204
|
+
value.attrs.target.length > 48 ||
|
|
205
|
+
model.exec(value.attrs.target)?.[0] !== value.attrs.target ||
|
|
206
|
+
(resource.content_type !== undefined &&
|
|
207
|
+
(!record(resource.content_type) ||
|
|
208
|
+
resource.content_type.key !== value.attrs.target)) ||
|
|
209
|
+
!record(resource.fields))
|
|
210
|
+
return '';
|
|
211
|
+
const caption = typeof resource.fields.title === 'string'
|
|
212
|
+
? resource.fields.title
|
|
213
|
+
: resource.fields.name;
|
|
214
|
+
return typeof caption === 'string'
|
|
215
|
+
? `<aside class="rich-text-reference"><p>${escape(string(caption))}</p></aside>`
|
|
216
|
+
: '';
|
|
217
|
+
}
|
|
218
|
+
const output = visit(value.document, 'root', 0);
|
|
219
|
+
return exhausted ? '' : format === 'text' ? output.trim() : output;
|
|
220
|
+
}
|
|
221
|
+
/** Escaped HTML for rich-text v1, including safe expanded images/entry captions. */
|
|
222
|
+
export function richTextToHtml(value) {
|
|
223
|
+
return convert(value, 'html');
|
|
224
|
+
}
|
|
225
|
+
/** Authored text with block/hard-break newlines; excludes embeds and link URLs. */
|
|
226
|
+
export function richTextToPlainText(value) {
|
|
227
|
+
return convert(value, 'text');
|
|
228
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { CMSClient, CMSPreviewOptions, ModelMap, PreviewModels, UntypedModels } from "./types.js";
|
|
2
2
|
export * from "./errors.js";
|
|
3
|
-
export type {
|
|
3
|
+
export type { CountOptions, GetManyOptions, ContentSearch, SearchOptions, TextField } from "./types.js";
|
|
4
|
+
export type { CMSPreviewOptions, PreviewModels, ContentEntry, ContentPage, ContentTypeIdentity, ContentEnvironment, FindOneOptions } from "./types.js";
|
|
4
5
|
/** Import only from server-owned code. Draft fields may be missing or null. */
|
|
5
6
|
export declare function createCMSPreviewClient<Models extends ModelMap<Models> = UntypedModels>(options: CMSPreviewOptions): CMSClient<PreviewModels<Models>>;
|
|
6
7
|
export { imageSrcSet } from "./images.js";
|
|
7
8
|
export type { RemoteValue } from "./types.js";
|
|
8
9
|
export type { ContentRendition } from "./images.js";
|
|
10
|
+
export { richTextToHtml, richTextToPlainText } from "./rich-text.js";
|
package/dist/server.js
CHANGED
package/dist/transport.js
CHANGED
|
@@ -3,16 +3,24 @@
|
|
|
3
3
|
// selected factory supplies the credential and route; callers cannot switch modes
|
|
4
4
|
// through list options. Responses are checked as unknown data before their envelope
|
|
5
5
|
// is converted from API snake_case to SDK camelCase. No automatic retries occur.
|
|
6
|
-
import { CMSConfigurationError, CMSRequestError, CMSResponseError, CMSTimeoutError } from "./errors.js";
|
|
6
|
+
import { CMSConfigurationError, CMSRequestError, CMSResponseError, CMSTimeoutError, CMSNotFoundError } from "./errors.js";
|
|
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
|
+
const localeTag = /^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?$/;
|
|
11
12
|
// Narrow unknown JSON to an object before accessing properties. Arrays and null
|
|
12
13
|
// are not valid objects for the API envelope even though typeof reports object.
|
|
13
14
|
function record(value) {
|
|
14
15
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15
16
|
}
|
|
17
|
+
function readOptions(value, allowed, method) {
|
|
18
|
+
const controls = ["limit", "page", "pageSize", "after", "before", "maxPages", "select", "sort", "filters", "includeRemote", "ids", "search"];
|
|
19
|
+
if (!record(value) || Object.keys(value).some(key => !allowed.includes(key)) ||
|
|
20
|
+
controls.some(key => !allowed.includes(key) && key in value)) {
|
|
21
|
+
throw new TypeError(`${method} supports ${allowed.join(", ")}.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
16
24
|
// Only configured deadlines need a local controller/race. Keep the default
|
|
17
25
|
// fetch signal unchanged, while rejecting already-cancelled work before I/O.
|
|
18
26
|
// Native composition preserves fetch-style cancellation even when another caller
|
|
@@ -61,9 +69,50 @@ function withinDeadline(timeoutMs, callerSignal, execute) {
|
|
|
61
69
|
}
|
|
62
70
|
});
|
|
63
71
|
}
|
|
72
|
+
function validateFilters(value) {
|
|
73
|
+
let nodes = 0, leaves = 0, stringUnits = 0;
|
|
74
|
+
const scalar = (v) => {
|
|
75
|
+
if (typeof v === "string") {
|
|
76
|
+
stringUnits += v.length;
|
|
77
|
+
return stringUnits <= 4096 && !v.includes("\0");
|
|
78
|
+
}
|
|
79
|
+
return typeof v === "boolean" || typeof v === "number" && Number.isFinite(v);
|
|
80
|
+
};
|
|
81
|
+
const walk = (node, depth) => {
|
|
82
|
+
if (!record(node) || depth > 4 || ++nodes > 31)
|
|
83
|
+
return false;
|
|
84
|
+
const keys = Object.keys(node);
|
|
85
|
+
if (keys.length === 1 && ["and", "or", "not"].includes(keys[0])) {
|
|
86
|
+
const group = keys[0];
|
|
87
|
+
if (group === "not")
|
|
88
|
+
return walk(node.not, depth + 1);
|
|
89
|
+
const children = node[group];
|
|
90
|
+
return Array.isArray(children) && children.length >= 1 && children.length <= 10 && Array.from(children).every(child => walk(child, depth + 1));
|
|
91
|
+
}
|
|
92
|
+
if (++leaves > 10 || keys.sort().join(",") !== "field,op,value" || typeof node.field !== "string" ||
|
|
93
|
+
!identifier.test(node.field) || typeof node.op !== "string")
|
|
94
|
+
return false;
|
|
95
|
+
const v = node.value;
|
|
96
|
+
if (["eq", "ne"].includes(node.op))
|
|
97
|
+
return v === null || scalar(v);
|
|
98
|
+
if (["in", "not_in"].includes(node.op))
|
|
99
|
+
return Array.isArray(v) && v.length >= 1 && v.length <= 100 && Array.from(v).every(scalar);
|
|
100
|
+
if (["contains_any", "contains_all"].includes(node.op))
|
|
101
|
+
return Array.isArray(v) && v.length >= 1 && v.length <= 100 && new Set(v).size === v.length &&
|
|
102
|
+
Array.from(v).every(option => typeof option === "string" && option.trim().length > 0 && scalar(option));
|
|
103
|
+
if (["gt", "gte", "lt", "lte"].includes(node.op))
|
|
104
|
+
return (typeof v === "string" || typeof v === "number") && scalar(v);
|
|
105
|
+
if (["eqi", "contains", "containsi", "starts_with", "starts_withi", "ends_with", "ends_withi"].includes(node.op))
|
|
106
|
+
return typeof v === "string" && (node.op === "eqi" || v.length > 0) && scalar(v);
|
|
107
|
+
return false;
|
|
108
|
+
};
|
|
109
|
+
const valid = Array.isArray(value) ? value.length <= 10 && Array.from(value).every(node => walk(node, 0)) : walk(value, 0);
|
|
110
|
+
if (!valid)
|
|
111
|
+
throw new TypeError("filters require valid conditions or nonempty and/or/not groups, at most ten leaves, 31 nodes and depth four.");
|
|
112
|
+
}
|
|
64
113
|
function query(options) {
|
|
65
|
-
if (!record(options) || Object.keys(options).some(key => !["select", "limit", "page", "pageSize", "after", "before", "signal", "locale", "filters", "sort", "includeRemote"].includes(key))) {
|
|
66
|
-
throw new TypeError("Supported list options are select, limit, page, pageSize, after, before, locale, filters, sort and signal.");
|
|
114
|
+
if (!record(options) || Object.keys(options).some(key => !["select", "limit", "page", "pageSize", "after", "before", "signal", "locale", "filters", "sort", "search", "includeRemote"].includes(key))) {
|
|
115
|
+
throw new TypeError("Supported list options are select, limit, page, pageSize, after, before, locale, filters, sort, search, includeRemote and signal.");
|
|
67
116
|
}
|
|
68
117
|
const params = new URLSearchParams();
|
|
69
118
|
if (options.includeRemote !== undefined) {
|
|
@@ -95,7 +144,7 @@ function query(options) {
|
|
|
95
144
|
params.set("limit", String(options.limit));
|
|
96
145
|
}
|
|
97
146
|
if (options.locale !== undefined) {
|
|
98
|
-
if (typeof options.locale !== "string" ||
|
|
147
|
+
if (typeof options.locale !== "string" || !localeTag.test(options.locale)) {
|
|
99
148
|
throw new TypeError("locale must be a language code such as en, es or pt-BR.");
|
|
100
149
|
}
|
|
101
150
|
params.set("locale", options.locale);
|
|
@@ -113,23 +162,30 @@ function query(options) {
|
|
|
113
162
|
}
|
|
114
163
|
}
|
|
115
164
|
if (options.filters !== undefined) {
|
|
116
|
-
|
|
117
|
-
const choices = (value) => Array.isArray(value) && value.length >= 1 && value.length <= 100 &&
|
|
118
|
-
new Set(value).size === value.length && value.every(option => typeof option === "string" && option.trim().length > 0 && option.length <= 20000);
|
|
119
|
-
if (!Array.isArray(options.filters) || options.filters.length > 10 || !options.filters.every(clause => record(clause) && Object.keys(clause).sort().join(",") === "field,op,value" && typeof clause.field === "string" && identifier.test(clause.field) &&
|
|
120
|
-
(clause.op === "eq" ? clause.value === null || scalar(clause.value) : clause.op === "in" ? Array.isArray(clause.value) && clause.value.length >= 1 && clause.value.length <= 100 && clause.value.every(scalar) :
|
|
121
|
-
["contains_any", "contains_all"].includes(String(clause.op)) ? choices(clause.value) :
|
|
122
|
-
["gt", "gte", "lt", "lte"].includes(String(clause.op)) && (typeof clause.value === "string" || typeof clause.value === "number" && Number.isFinite(clause.value))))) {
|
|
123
|
-
throw new TypeError("filters must contain at most ten valid scalar or multiple-choice AND clauses.");
|
|
124
|
-
}
|
|
165
|
+
validateFilters(options.filters);
|
|
125
166
|
const encoded = JSON.stringify(options.filters);
|
|
126
167
|
if (new TextEncoder().encode(encoded).length > 4096)
|
|
127
|
-
throw new TypeError("filters exceed 4
|
|
168
|
+
throw new TypeError("filters exceed 4 KiB.");
|
|
128
169
|
params.set("filters", encoded);
|
|
129
170
|
}
|
|
171
|
+
if (options.search !== undefined) {
|
|
172
|
+
const search = options.search;
|
|
173
|
+
if (!record(search) || Object.keys(search).some(key => !["query", "fields"].includes(key)) ||
|
|
174
|
+
typeof search.query !== "string" || search.query.includes("\0") ||
|
|
175
|
+
new TextEncoder().encode(search.query).length > 512 || Array.from(search.query.trim()).length < 1 ||
|
|
176
|
+
Array.from(search.query.trim()).length > 200 || search.query.trim().split(/\s+/u).length > 16 ||
|
|
177
|
+
search.fields !== undefined && (!Array.isArray(search.fields) || search.fields.length < 1 || search.fields.length > 24 ||
|
|
178
|
+
new Set(search.fields).size !== search.fields.length || !Array.from(search.fields).every(key => typeof key === "string" && identifier.test(key)))) {
|
|
179
|
+
throw new TypeError("search requires 1–200 characters/512 UTF-8 bytes, at most 16 terms, and optionally 1–24 unique text field keys.");
|
|
180
|
+
}
|
|
181
|
+
params.set("search", JSON.stringify({ query: search.query.trim(), ...(search.fields !== undefined ? { fields: search.fields } : {}) }));
|
|
182
|
+
}
|
|
130
183
|
if (options.sort !== undefined) {
|
|
131
|
-
|
|
132
|
-
|
|
184
|
+
const orders = Array.isArray(options.sort) ? options.sort : [options.sort];
|
|
185
|
+
if (orders.length < 1 || orders.length > 3 || !Array.from(orders).every(order => record(order) && Object.keys(order).sort().join(",") === "direction,field" &&
|
|
186
|
+
typeof order.field === "string" && identifier.test(order.field) && ["asc", "desc"].includes(String(order.direction))) ||
|
|
187
|
+
new Set(orders.map(order => order.field)).size !== orders.length)
|
|
188
|
+
throw new TypeError("sort requires 1–3 unique scalar fields and asc/desc directions.");
|
|
133
189
|
params.set("sort", JSON.stringify(options.sort));
|
|
134
190
|
}
|
|
135
191
|
return params;
|
|
@@ -137,14 +193,14 @@ function query(options) {
|
|
|
137
193
|
// Network responses start as unknown even in TypeScript. Check structural
|
|
138
194
|
// properties before returning them, and translate HTTP snake_case metadata into
|
|
139
195
|
// SDK camelCase. This does not validate the caller-declared model field types.
|
|
140
|
-
function decodePage(value) {
|
|
196
|
+
function decodePage(value, projectId, model, locale) {
|
|
141
197
|
if (!record(value) || !Array.isArray(value.data) || !record(value.meta) ||
|
|
142
198
|
typeof value.meta.limit !== "number" || !Number.isInteger(value.meta.limit) ||
|
|
143
199
|
value.meta.limit < 1 || value.meta.limit > 100 || value.data.length > value.meta.limit ||
|
|
144
200
|
!(value.meta.next_cursor === null || (typeof value.meta.next_cursor === "string" && cursor(value.meta.next_cursor)))) {
|
|
145
201
|
throw new CMSResponseError("CMS returned an invalid content page.");
|
|
146
202
|
}
|
|
147
|
-
const data = value.data.map(decodeEntry);
|
|
203
|
+
const data = value.data.map(entry => decodeEntry(entry, projectId, model, locale));
|
|
148
204
|
const meta = { limit: value.meta.limit, nextCursor: value.meta.next_cursor };
|
|
149
205
|
if (value.meta.page !== undefined || value.meta.page_size !== undefined) {
|
|
150
206
|
if (typeof value.meta.page !== "number" || !Number.isInteger(value.meta.page) || value.meta.page < 1 || value.meta.page_size !== value.meta.limit || (value.meta.page - 1) * value.meta.limit > 10000)
|
|
@@ -172,11 +228,44 @@ function decodePage(value) {
|
|
|
172
228
|
}
|
|
173
229
|
return { data, meta };
|
|
174
230
|
}
|
|
175
|
-
function decodeEntry(entry) {
|
|
231
|
+
function decodeEntry(entry, projectId, model, locale) {
|
|
176
232
|
if (!record(entry) || typeof entry.id !== "string" || !uuid.test(entry.id) || !record(entry.fields) ||
|
|
177
233
|
!(entry.published_at === null || typeof entry.published_at === "string"))
|
|
178
234
|
throw new CMSResponseError("CMS returned an invalid content entry.");
|
|
179
|
-
const result = {
|
|
235
|
+
const result = {
|
|
236
|
+
id: entry.id, fields: entry.fields, publishedAt: entry.published_at,
|
|
237
|
+
projectId, contentType: { key: model },
|
|
238
|
+
};
|
|
239
|
+
// Old servers omit this metadata. Retain only known request identity until
|
|
240
|
+
// the server supplies the rest; never infer an environment from a credential.
|
|
241
|
+
if (entry.project_id !== undefined &&
|
|
242
|
+
(typeof entry.project_id !== "string" || entry.project_id.toLowerCase() !== projectId.toLowerCase())) {
|
|
243
|
+
throw new CMSResponseError("CMS returned conflicting workspace metadata.");
|
|
244
|
+
}
|
|
245
|
+
if (entry.content_type !== undefined) {
|
|
246
|
+
const type = entry.content_type;
|
|
247
|
+
if (!record(type) || typeof type.id !== "string" || !uuid.test(type.id) ||
|
|
248
|
+
type.key !== model || typeof type.name !== "string" || !type.name.trim()) {
|
|
249
|
+
throw new CMSResponseError("CMS returned invalid content-type metadata.");
|
|
250
|
+
}
|
|
251
|
+
result.contentType = { id: type.id, key: model, name: type.name };
|
|
252
|
+
}
|
|
253
|
+
if (entry.environment !== undefined) {
|
|
254
|
+
const environment = entry.environment;
|
|
255
|
+
if (!record(environment) || typeof environment.id !== "string" || !uuid.test(environment.id) ||
|
|
256
|
+
typeof environment.key !== "string" || !/^[a-z][a-z0-9_-]{0,31}$/.test(environment.key)) {
|
|
257
|
+
throw new CMSResponseError("CMS returned invalid environment metadata.");
|
|
258
|
+
}
|
|
259
|
+
result.environment = { id: environment.id, key: environment.key };
|
|
260
|
+
}
|
|
261
|
+
if (entry.locale !== undefined) {
|
|
262
|
+
if (typeof entry.locale !== "string" || !localeTag.test(entry.locale) || locale !== undefined && entry.locale !== locale) {
|
|
263
|
+
throw new CMSResponseError("CMS returned invalid locale metadata.");
|
|
264
|
+
}
|
|
265
|
+
result.locale = entry.locale;
|
|
266
|
+
}
|
|
267
|
+
else if (locale !== undefined)
|
|
268
|
+
result.locale = locale;
|
|
180
269
|
if (entry.remote !== undefined) {
|
|
181
270
|
const remote = entry.remote;
|
|
182
271
|
if (remote === null)
|
|
@@ -207,7 +296,7 @@ export function createTransport(options, key, mode) {
|
|
|
207
296
|
// credentials to production. Check inherited options too: spreads and plain
|
|
208
297
|
// JavaScript callers must receive the same migration error as TypeScript.
|
|
209
298
|
if (["url", "baseURL", "baseUrl", "host", "endpoint"].some(option => option in options)) {
|
|
210
|
-
throw new CMSConfigurationError("CMS clients connect to https://lemonadecms.com. Remove custom URL configuration.");
|
|
299
|
+
throw new CMSConfigurationError("CMS clients connect to https://api.lemonadecms.com. Remove custom URL configuration.");
|
|
211
300
|
}
|
|
212
301
|
const credential = mode === "content" ? "publicKey" : "secretKey";
|
|
213
302
|
if (Object.keys(options).some(option => !["projectId", credential, "fetch", "timeoutMs"].includes(option)) || "environment" in options) {
|
|
@@ -237,7 +326,8 @@ export function createTransport(options, key, mode) {
|
|
|
237
326
|
if (typeof fetcher !== "function") {
|
|
238
327
|
throw new CMSConfigurationError("This environment needs fetch; supply a compatible fetch implementation.");
|
|
239
328
|
}
|
|
240
|
-
const
|
|
329
|
+
const projectId = options.projectId;
|
|
330
|
+
const endpoint = `${serviceURL}/api/v1/projects/${projectId}/${mode}`;
|
|
241
331
|
return {
|
|
242
332
|
content(model) {
|
|
243
333
|
if (typeof model !== "string" || !identifier.test(model)) {
|
|
@@ -247,6 +337,54 @@ export function createTransport(options, key, mode) {
|
|
|
247
337
|
async list(options = {}) {
|
|
248
338
|
return await request(options);
|
|
249
339
|
},
|
|
340
|
+
async search(text, options = {}) {
|
|
341
|
+
if (!record(options) || "search" in options)
|
|
342
|
+
throw new TypeError("search accepts a query string and list options with optional fields.");
|
|
343
|
+
const { fields, ...list } = options;
|
|
344
|
+
return await request({ ...list, search: { query: text, ...(fields !== undefined ? { fields } : {}) } });
|
|
345
|
+
},
|
|
346
|
+
async findOne(options = {}) {
|
|
347
|
+
return await firstMatch(options);
|
|
348
|
+
},
|
|
349
|
+
async findOneOrThrow(options = {}) {
|
|
350
|
+
const entry = await firstMatch(options);
|
|
351
|
+
if (entry === null)
|
|
352
|
+
throw new CMSNotFoundError(model);
|
|
353
|
+
return entry;
|
|
354
|
+
},
|
|
355
|
+
async count(options = {}) {
|
|
356
|
+
return await statistic("count", options);
|
|
357
|
+
},
|
|
358
|
+
async exists(options = {}) {
|
|
359
|
+
return await statistic("exists", options);
|
|
360
|
+
},
|
|
361
|
+
async getMany(ids, options = {}) {
|
|
362
|
+
readOptions(options, ["select", "locale", "signal", "includeRemote"], "getMany");
|
|
363
|
+
if (!Array.isArray(ids) || ids.length > 100 || !Array.from(ids).every(id => typeof id === "string" && uuid.test(id))) {
|
|
364
|
+
throw new TypeError("getMany ids must contain at most 100 entry UUIDs.");
|
|
365
|
+
}
|
|
366
|
+
query(options);
|
|
367
|
+
options.signal?.throwIfAborted();
|
|
368
|
+
const ordered = ids.map(id => id.toLowerCase());
|
|
369
|
+
if (ordered.length === 0)
|
|
370
|
+
return [];
|
|
371
|
+
const requested = new Set(ordered);
|
|
372
|
+
const requestedLocale = options.locale;
|
|
373
|
+
return await send(options, "/_many", body => {
|
|
374
|
+
if (!record(body) || !Array.isArray(body.data) || body.data.length > requested.size) {
|
|
375
|
+
throw new CMSResponseError("CMS returned an invalid bulk response.");
|
|
376
|
+
}
|
|
377
|
+
const entries = new Map();
|
|
378
|
+
for (const value of body.data) {
|
|
379
|
+
const entry = decodeEntry(value, projectId, model, requestedLocale);
|
|
380
|
+
const id = entry.id.toLowerCase();
|
|
381
|
+
if (!requested.has(id) || entries.has(id))
|
|
382
|
+
throw new CMSResponseError("CMS returned unexpected or duplicate bulk entries.");
|
|
383
|
+
entries.set(id, entry);
|
|
384
|
+
}
|
|
385
|
+
return ordered.map(id => entries.get(id) ?? null);
|
|
386
|
+
}, [...requested]);
|
|
387
|
+
},
|
|
250
388
|
pages(options = {}) {
|
|
251
389
|
return iteratePages(options);
|
|
252
390
|
},
|
|
@@ -267,6 +405,25 @@ export function createTransport(options, key, mode) {
|
|
|
267
405
|
return await request(options, id);
|
|
268
406
|
},
|
|
269
407
|
};
|
|
408
|
+
async function firstMatch(options) {
|
|
409
|
+
readOptions(options, ["select", "locale", "filters", "sort", "search", "signal", "includeRemote"], "findOne/findOneOrThrow");
|
|
410
|
+
const page = await request({ ...options, limit: 1 });
|
|
411
|
+
if (page.meta.limit !== 1)
|
|
412
|
+
throw new CMSResponseError("CMS returned an invalid single-match page.");
|
|
413
|
+
return (page.data[0] ?? null);
|
|
414
|
+
}
|
|
415
|
+
async function statistic(operation, options) {
|
|
416
|
+
readOptions(options, ["locale", "filters", "search", "signal"], operation);
|
|
417
|
+
return await send(options, `/_${operation}`, body => {
|
|
418
|
+
if (!record(body) || !record(body.data))
|
|
419
|
+
throw new CMSResponseError("CMS returned an invalid statistic.");
|
|
420
|
+
const value = body.data[operation];
|
|
421
|
+
if (operation === "count" ? typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 : typeof value !== "boolean") {
|
|
422
|
+
throw new CMSResponseError("CMS returned an invalid statistic.");
|
|
423
|
+
}
|
|
424
|
+
return value;
|
|
425
|
+
});
|
|
426
|
+
}
|
|
270
427
|
// No eager fetch or prefetch: the iterator asks for a page only after the
|
|
271
428
|
// previous yield is consumed. A break/error closes it without more requests.
|
|
272
429
|
async function* iteratePages(options = {}) {
|
|
@@ -298,7 +455,23 @@ export function createTransport(options, key, mode) {
|
|
|
298
455
|
}
|
|
299
456
|
}
|
|
300
457
|
async function request(options, id) {
|
|
458
|
+
const requestedLocale = options.locale;
|
|
459
|
+
return await send(options, id ? `/${id}` : "", body => {
|
|
460
|
+
if (id) {
|
|
461
|
+
if (!record(body))
|
|
462
|
+
throw new CMSResponseError("CMS returned an invalid content item.");
|
|
463
|
+
const entry = decodeEntry(body.data, projectId, model, requestedLocale);
|
|
464
|
+
if (entry.id.toLowerCase() !== id.toLowerCase())
|
|
465
|
+
throw new CMSResponseError("CMS returned a different entry than requested.");
|
|
466
|
+
return entry;
|
|
467
|
+
}
|
|
468
|
+
return decodePage(body, projectId, model, requestedLocale);
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
async function send(options, path, decode, ids) {
|
|
301
472
|
const params = query(options);
|
|
473
|
+
if (ids)
|
|
474
|
+
params.set("ids", ids.join(","));
|
|
302
475
|
const suffix = params.size ? `?${params}` : "";
|
|
303
476
|
return withinDeadline(timeoutMs, options.signal, async (signal) => {
|
|
304
477
|
const checkCancellation = () => {
|
|
@@ -309,7 +482,7 @@ export function createTransport(options, key, mode) {
|
|
|
309
482
|
// Use only bearer authorization: omit browser cookies, refuse redirects that
|
|
310
483
|
// could change the destination, and request fresh data. Abort and network errors
|
|
311
484
|
// propagate to the caller; this client does not silently retry a request.
|
|
312
|
-
const response = await fetcher(`${endpoint}/${model}${
|
|
485
|
+
const response = await fetcher(`${endpoint}/${model}${path}${suffix}`, {
|
|
313
486
|
method: "GET",
|
|
314
487
|
headers: { Accept: "application/json", Authorization: `Bearer ${key}` },
|
|
315
488
|
credentials: "omit",
|
|
@@ -338,12 +511,7 @@ export function createTransport(options, key, mode) {
|
|
|
338
511
|
}
|
|
339
512
|
// Validate the envelope at runtime. Model field types are the caller's
|
|
340
513
|
// declaration until schema-generated types/runtime validators exist.
|
|
341
|
-
|
|
342
|
-
if (!record(body))
|
|
343
|
-
throw new CMSResponseError("CMS returned an invalid content item.");
|
|
344
|
-
return decodeEntry(body.data);
|
|
345
|
-
}
|
|
346
|
-
return decodePage(body);
|
|
514
|
+
return decode(body);
|
|
347
515
|
});
|
|
348
516
|
}
|
|
349
517
|
},
|