@lemonadecms/client 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +100 -8
- package/bin/cli/create-migration.mjs +10 -3
- package/bin/cli/network.mjs +2 -1
- package/bin/cli/schema.mjs +18 -1
- package/bin/codegen/csharp.mjs +6 -1
- package/bin/codegen/python.mjs +6 -1
- package/bin/generate-types.mjs +17 -3
- 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/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 +97 -9
- package/dist/types.d.ts +56 -7
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/rich-text.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
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",
|