@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.
@@ -0,0 +1,6 @@
1
+ // Negotiation affects schema tooling only; ordinary delivery/SDK reads retain
2
+ // their existing response contract and zero runtime dependency boundary.
3
+ export const schemaExtensionKeys = Object.freeze([
4
+ 'relationships', 'rich_text', 'unique_identifiers',
5
+ ]);
6
+ export const schemaExtensions = 'relationships.v1,rich-text.v2,unique-identifiers.v1';
@@ -0,0 +1,50 @@
1
+ import {schemaExtensionKeys} from './schema-extensions.mjs';
2
+
3
+ const identifier = /^[a-z][a-z0-9_]{0,47}$/;
4
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
5
+ const exact = (value, keys) => record(value) && Object.keys(value).length === keys.length &&
6
+ keys.every(key => Object.hasOwn(value, key));
7
+ const invalid = () => { throw new Error('Invalid unique-identifier schema extension.'); };
8
+
9
+ export function uniqueIdentifierDefinitions(schema) {
10
+ // Inline declarations belong to management definitions, never portable
11
+ // delivery exports. Requiring the extension prevents silently lost metadata.
12
+ let nodes = 0;
13
+ function fields(values, depth = 0) {
14
+ if (!Array.isArray(values) || values.length > 24 || depth > 3) invalid();
15
+ for (const field of values) {
16
+ if (++nodes > 150000 || !record(field) || Object.hasOwn(field, 'unique')) invalid();
17
+ const components = field.components || [];
18
+ if (!Array.isArray(components) || components.length > 12) invalid();
19
+ for (const component of components) {
20
+ if (!record(component)) invalid();
21
+ fields(component.fields, depth + 1);
22
+ }
23
+ }
24
+ }
25
+ for (const model of schema.models) fields(model.fields);
26
+ if (!Object.hasOwn(schema, 'extensions')) return [];
27
+ if (!record(schema.extensions) || Object.keys(schema.extensions).length === 0 ||
28
+ Object.keys(schema.extensions).some(key => !schemaExtensionKeys.includes(key))) invalid();
29
+ if (!Object.hasOwn(schema.extensions, 'unique_identifiers')) return [];
30
+ const extension = schema.extensions.unique_identifiers;
31
+ if (!exact(extension, ['version', 'definitions']) || extension.version !== 1 ||
32
+ !Array.isArray(extension.definitions) || extension.definitions.length > 6144) invalid();
33
+ const models = new Map(schema.models.map(model => [model.key, model]));
34
+ const seen = new Set();
35
+ for (const definition of extension.definitions) {
36
+ if (!exact(definition, ['model', 'field']) ||
37
+ !['model', 'field'].every(key => typeof definition[key] === 'string' && identifier.test(definition[key]))) invalid();
38
+ const field = models.get(definition.model)?.fields.find(field => field.key === definition.field);
39
+ const identity = `${definition.model}.${definition.field}`;
40
+ if (!field || field.type !== 'text' || field.localized === true || seen.has(identity)) invalid();
41
+ seen.add(identity);
42
+ }
43
+ return extension.definitions;
44
+ }
45
+
46
+ export function modelsWithUniqueIdentifiers(schema) {
47
+ const definitions = new Set(uniqueIdentifierDefinitions(schema).map(value => `${value.model}.${value.field}`));
48
+ return schema.models.map(model => ({...model, fields: model.fields.map(field =>
49
+ definitions.has(`${model.key}.${field.key}`) ? {...field, unique: true} : field)}));
50
+ }
@@ -0,0 +1,27 @@
1
+ /** Optional browser-only Composer bridge. Website code owns rendering.
2
+ * Enable only on an authenticated preview. No credentials or framework imports.
3
+ */
4
+ export interface ComposerUpdate {
5
+ channel: string;
6
+ entry: string;
7
+ locale: string;
8
+ root: string;
9
+ fields: Record<string, unknown>;
10
+ model_version: number;
11
+ entry_version: number;
12
+ renderer_revision: string;
13
+ }
14
+ export interface VisualComposerOptions {
15
+ studioOrigin: string;
16
+ channel: string;
17
+ entry: string;
18
+ locale: string;
19
+ roots: readonly string[];
20
+ components: readonly string[];
21
+ rendererRevision: string;
22
+ root?: HTMLElement;
23
+ /** Return a synchronous commit function; the bridge calls it only for the
24
+ * latest successful render. Honor signal and keep DOM changes in commit. */
25
+ render: (update: ComposerUpdate, signal: AbortSignal) => Promise<() => void>;
26
+ }
27
+ export declare function enableVisualComposer(options: VisualComposerOptions): () => void;
@@ -0,0 +1,240 @@
1
+ export function enableVisualComposer(options) {
2
+ const keyPattern = /^[a-z][a-z0-9_]{0,47}$/;
3
+ const idPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4
+ const { studioOrigin, channel, entry, locale, roots, components, rendererRevision } = options;
5
+ let origin;
6
+ try {
7
+ origin = new URL(studioOrigin);
8
+ }
9
+ catch {
10
+ return () => { };
11
+ }
12
+ if (origin.origin !== studioOrigin || !['http:', 'https:'].includes(origin.protocol) ||
13
+ window.parent === window || !idPattern.test(channel) || !idPattern.test(entry) ||
14
+ !/^[a-zA-Z0-9-]{2,20}$/.test(locale) || !/^[a-f0-9]{64}$/.test(rendererRevision) ||
15
+ roots.length < 1 || roots.length > 24 || components.length < 1 || components.length > 128 ||
16
+ ![...roots, ...components].every(key => keyPattern.test(key)) || typeof options.render !== 'function') {
17
+ return () => { };
18
+ }
19
+ const root = options.root ?? document.body;
20
+ let active = true;
21
+ let enabled = false;
22
+ let selectedRoot = '';
23
+ let generation = '';
24
+ let latest = 0;
25
+ let busy = false;
26
+ let pending;
27
+ let controller;
28
+ const originals = new Map();
29
+ const send = (type, extra = {}) => window.parent.postMessage({ type, version: 1, channel, entry, locale, root: selectedRoot, ...extra }, studioOrigin);
30
+ const announce = (request) => send('lemonade:composer-ready', {
31
+ roots: [...roots], components: [...components], renderer_revision: rendererRevision,
32
+ ...(request ? { request } : {}),
33
+ });
34
+ const restore = () => {
35
+ for (const [element, attrs] of originals)
36
+ for (const [name, value] of attrs) {
37
+ if (value === null)
38
+ element.removeAttribute(name);
39
+ else
40
+ element.setAttribute(name, value);
41
+ }
42
+ originals.clear();
43
+ };
44
+ const enhance = () => {
45
+ restore();
46
+ if (!enabled)
47
+ return;
48
+ for (const element of Array.from(root.querySelectorAll('[data-lemonade-block]')).slice(0, 100)) {
49
+ const id = element.dataset.lemonadeBlock ?? '';
50
+ if (!idPattern.test(id))
51
+ continue;
52
+ const attrs = new Map();
53
+ for (const name of ['tabindex', 'role', 'aria-label', 'draggable', 'style'])
54
+ attrs.set(name, element.getAttribute(name));
55
+ originals.set(element, attrs);
56
+ if (!element.matches('a[href],button,input,textarea,select'))
57
+ element.setAttribute('role', 'button');
58
+ element.tabIndex = 0;
59
+ element.setAttribute('aria-label', `Edit ${element.dataset.lemonadeName ?? 'section'} in Studio`);
60
+ element.draggable = true;
61
+ element.style.outline = '1px dashed currentColor';
62
+ element.style.outlineOffset = '3px';
63
+ }
64
+ };
65
+ const run = async () => {
66
+ if (!active || !enabled || busy || !pending)
67
+ return;
68
+ const work = pending;
69
+ pending = undefined;
70
+ busy = true;
71
+ const abort = new AbortController();
72
+ controller = abort;
73
+ let timeout;
74
+ try {
75
+ const commit = await Promise.race([
76
+ options.render(work.update, abort.signal),
77
+ new Promise((_, reject) => { timeout = setTimeout(() => { abort.abort(); reject(new Error('Composer render timed out')); }, 10000); }),
78
+ ]);
79
+ if (active && enabled && !abort.signal.aborted && work.sequence === latest && work.generation === generation) {
80
+ if (typeof commit !== 'function')
81
+ throw new Error('Composer renderer must return a commit function');
82
+ commit();
83
+ enhance();
84
+ send('lemonade:composer-rendered', { sequence: work.sequence, generation });
85
+ }
86
+ }
87
+ catch {
88
+ if (active && enabled && work.sequence === latest && work.generation === generation) {
89
+ send('lemonade:composer-error', { sequence: work.sequence, generation });
90
+ }
91
+ }
92
+ finally {
93
+ clearTimeout(timeout);
94
+ if (controller === abort)
95
+ controller = undefined;
96
+ busy = false;
97
+ void run();
98
+ }
99
+ };
100
+ const disable = () => {
101
+ enabled = false;
102
+ pending = undefined;
103
+ controller?.abort();
104
+ restore();
105
+ };
106
+ const receive = (event) => {
107
+ if (!active || event.source !== window.parent || event.origin !== studioOrigin || event.data?.channel !== channel)
108
+ return;
109
+ const data = event.data;
110
+ if (data.type === 'lemonade:preview-connect') {
111
+ if (typeof data.request === 'string' && data.request.length <= 64)
112
+ announce(data.request);
113
+ return;
114
+ }
115
+ if (data.version !== 1 || data.entry !== entry || data.locale !== locale)
116
+ return;
117
+ if (data.type === 'lemonade:composer-disable') {
118
+ disable();
119
+ return;
120
+ }
121
+ if (data.type === 'lemonade:composer-enable') {
122
+ if (!roots.includes(data.root) || typeof data.generation !== 'string' || data.generation.length > 64)
123
+ return;
124
+ if (generation !== data.generation) {
125
+ controller?.abort();
126
+ pending = undefined;
127
+ latest = 0;
128
+ }
129
+ enabled = true;
130
+ generation = data.generation;
131
+ selectedRoot = data.root;
132
+ enhance();
133
+ return;
134
+ }
135
+ if (!enabled || data.generation !== generation || data.root !== selectedRoot)
136
+ return;
137
+ if (data.type === 'lemonade:composer-highlight' && idPattern.test(data.id)) {
138
+ for (const element of originals.keys()) {
139
+ element.style.outlineWidth = element.dataset.lemonadeBlock === data.id ? '3px' : '1px';
140
+ }
141
+ return;
142
+ }
143
+ if (data.type === 'lemonade:composer-drop') {
144
+ if (!Number.isFinite(data.x) || !Number.isFinite(data.y) || data.x < 0 || data.y < 0 ||
145
+ data.x >= window.innerWidth || data.y >= window.innerHeight)
146
+ return;
147
+ const element = document.elementFromPoint(data.x, data.y);
148
+ if (element)
149
+ emitDrop(element, data.payload);
150
+ return;
151
+ }
152
+ if (data.type !== 'lemonade:composer-update' || !Number.isSafeInteger(data.sequence) || data.sequence <= latest ||
153
+ !data.fields || typeof data.fields !== 'object' || Array.isArray(data.fields) ||
154
+ data.renderer_revision !== rendererRevision || !Number.isSafeInteger(data.model_version) || !Number.isSafeInteger(data.entry_version))
155
+ return;
156
+ try {
157
+ if (new TextEncoder().encode(JSON.stringify(data.fields)).length > 65536)
158
+ return;
159
+ }
160
+ catch {
161
+ return;
162
+ }
163
+ latest = data.sequence;
164
+ pending = { update: { channel, entry, locale, root: selectedRoot, fields: data.fields,
165
+ model_version: data.model_version, entry_version: data.entry_version, renderer_revision: rendererRevision }, sequence: latest, generation };
166
+ controller?.abort();
167
+ void run();
168
+ };
169
+ const select = (event) => {
170
+ if (!enabled || !(event.target instanceof Element))
171
+ return;
172
+ if (event instanceof KeyboardEvent && !['Enter', ' '].includes(event.key))
173
+ return;
174
+ if (event instanceof MouseEvent && (event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey))
175
+ return;
176
+ const element = event.target.closest('[data-lemonade-block]');
177
+ if (!element || !originals.has(element))
178
+ return;
179
+ event.preventDefault();
180
+ event.stopImmediatePropagation();
181
+ send('lemonade:composer-select', { id: element.dataset.lemonadeBlock, root: selectedRoot, generation });
182
+ };
183
+ const dragstart = (event) => {
184
+ if (!enabled || !(event.target instanceof Element))
185
+ return;
186
+ const element = event.target.closest('[data-lemonade-block]');
187
+ if (!element || !originals.has(element))
188
+ return;
189
+ event.dataTransfer?.setData('application/x-lemonade-composer', JSON.stringify({ id: element.dataset.lemonadeBlock, channel, root: selectedRoot }));
190
+ };
191
+ const dragover = (event) => {
192
+ if (enabled && event.target instanceof Element && event.target.closest('[data-lemonade-slot]'))
193
+ event.preventDefault();
194
+ };
195
+ const emitDrop = (element, data) => {
196
+ const slot = element.closest('[data-lemonade-slot]');
197
+ const path = slot?.dataset.lemonadeSlot;
198
+ if (!slot || !path || path.length > 512 || !root.contains(slot) || !data ||
199
+ !(idPattern.test(data.id ?? '') || keyPattern.test(data.component ?? '')))
200
+ return;
201
+ if (path !== selectedRoot && !path.startsWith(selectedRoot + '.'))
202
+ return;
203
+ const block = element.closest('[data-lemonade-block]');
204
+ send('lemonade:composer-action', { action: data.id ? 'move' : 'add', id: data.id, component: data.component,
205
+ target: path, before: block?.dataset.lemonadeBlock, root: selectedRoot, generation });
206
+ };
207
+ const drop = (event) => {
208
+ if (!enabled || !(event.target instanceof Element))
209
+ return;
210
+ try {
211
+ const raw = event.dataTransfer?.getData('application/x-lemonade-composer') ?? '';
212
+ if (raw.length > 1024)
213
+ return;
214
+ const data = JSON.parse(raw);
215
+ if (data.channel !== channel || data.root !== selectedRoot)
216
+ return;
217
+ event.preventDefault();
218
+ event.stopPropagation();
219
+ emitDrop(event.target, data);
220
+ }
221
+ catch { /* Foreign drag payloads are not composition commands. */ }
222
+ };
223
+ window.addEventListener('message', receive);
224
+ root.addEventListener('click', select, true);
225
+ root.addEventListener('keydown', select, true);
226
+ root.addEventListener('dragstart', dragstart);
227
+ root.addEventListener('dragover', dragover);
228
+ root.addEventListener('drop', drop);
229
+ announce();
230
+ return () => {
231
+ active = false;
232
+ disable();
233
+ window.removeEventListener('message', receive);
234
+ root.removeEventListener('click', select, true);
235
+ root.removeEventListener('keydown', select, true);
236
+ root.removeEventListener('dragstart', dragstart);
237
+ root.removeEventListener('dragover', dragover);
238
+ root.removeEventListener('drop', drop);
239
+ };
240
+ }
package/dist/index.d.ts CHANGED
@@ -1,10 +1,11 @@
1
- import type { CMSClient, CMSClientOptions, ModelMap, UntypedModels } from "./types.js";
1
+ import type { CMSClient, CMSClientOptions, ModelMap, RelationshipMap, UntypedModels } from "./types.js";
2
2
  export * from "./errors.js";
3
3
  export type { RemoteValue } from "./types.js";
4
- export type { CountOptions, GetManyOptions, ContentSearch, SearchOptions, TextField } from "./types.js";
4
+ export type { CMSQueryDefaults, RelationshipMap, CountOptions, GetManyOptions, ContentSearch, SearchOptions, TextField } from "./types.js";
5
5
  export type { CMSClient, CMSClientOptions, ContentCollection, ContentEntry, ContentPage, ContentTypeIdentity, ContentEnvironment, ListOptions, FindOneOptions, PaginationOptions, ContentFilter, ContentSort } from "./types.js";
6
6
  /** Published content only. Framework environment variables must be passed explicitly. */
7
- export declare function createCMSClient<Models extends ModelMap<Models> = UntypedModels>(options: CMSClientOptions): CMSClient<Models>;
7
+ export declare function createCMSClient<Models extends ModelMap<Models> = UntypedModels, Relationships extends RelationshipMap<Models> = {}>(options: CMSClientOptions<Models>): CMSClient<Models, Relationships>;
8
8
  export { imageSrcSet } from "./images.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/index.js CHANGED
@@ -14,4 +14,4 @@ export function createCMSClient(options) {
14
14
  return createTransport(options, options?.publicKey, "content");
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";
@@ -0,0 +1,20 @@
1
+ export type ExternalMediaProvider = 'youtube' | 'vimeo' | 'spotify' | 'soundcloud';
2
+ export interface ExternalMediaDescriptor {
3
+ provider: ExternalMediaProvider;
4
+ url: string;
5
+ embedUrl: string;
6
+ title: string;
7
+ caption: string;
8
+ transcript: string;
9
+ poster: {
10
+ id: string;
11
+ url: string;
12
+ alt: string;
13
+ } | null;
14
+ }
15
+ /** The only relative URL accepted by first-party renderers is the private website asset proxy. */
16
+ export declare function safeRichTextURL(value: unknown, image: boolean): string | null;
17
+ /** Pure player metadata. This performs no fetches, creates no iframe and grants no playback consent. */
18
+ export declare function externalMediaDescriptor(value: unknown): ExternalMediaDescriptor;
19
+ /** Strict v2 validation protects renderers from silently dropping unknown future nodes. */
20
+ export declare function assertRichTextV2(value: unknown): void;
@@ -0,0 +1,242 @@
1
+ import { CMSResponseError } from './errors.js';
2
+ const encoder = new TextEncoder();
3
+ const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
4
+ const identifier = /^[a-z][a-z0-9_]{0,47}$/;
5
+ const imageTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
6
+ const record = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
7
+ const own = (value, keys) => Object.keys(value).every(key => keys.includes(key));
8
+ function invalid() {
9
+ throw new CMSResponseError('Invalid or unsupported rich-text v2 document.');
10
+ }
11
+ const validString = (value, max, min = 0) => typeof value === 'string' && !/[\u0000\ud800-\udfff]/u.test(value) &&
12
+ encoder.encode(value).byteLength >= min && encoder.encode(value).byteLength <= max;
13
+ /** The only relative URL accepted by first-party renderers is the private website asset proxy. */
14
+ export function safeRichTextURL(value, image) {
15
+ if (typeof value !== 'string' || value.length > (image ? 4096 : 2048) ||
16
+ /[\s\\\u0000-\u0020\u007f-\u009f]/u.test(value))
17
+ return null;
18
+ if (image && /^\/lemonade\/preview\/assets\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}(?:\?[^#]+)?$/i.test(value)) {
19
+ const params = new URL(value, 'https://preview.invalid').searchParams;
20
+ const keys = [];
21
+ params.forEach((_value, key) => keys.push(key));
22
+ const recipes = ['hero-640', 'hero-1280', 'card-320', 'card-640', 'social-1200'];
23
+ if (keys.length > 4 || new Set(keys).size !== keys.length ||
24
+ keys.some(key => !['rendition', 'v', 'impact', 'composition'].includes(key)) ||
25
+ (params.has('rendition') && !recipes.includes(params.get('rendition'))) ||
26
+ (params.has('v') && !/^[A-Za-z0-9_-]{43}$/.test(params.get('v'))) ||
27
+ (params.has('impact') && !['before', 'after'].includes(params.get('impact'))) ||
28
+ (params.has('composition') && (params.get('composition').length > 2048 ||
29
+ !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(params.get('composition')))))
30
+ return null;
31
+ return value;
32
+ }
33
+ if (!/^https?:\/\//i.test(value) && (image || !/^mailto:/i.test(value)))
34
+ return null;
35
+ try {
36
+ const url = new URL(value);
37
+ if (url.username || url.password)
38
+ return null;
39
+ if ((url.protocol === 'https:' || url.protocol === 'http:') && url.hostname)
40
+ return value;
41
+ return !image && url.protocol === 'mailto:' && url.pathname ? value : null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ function external(attrs) {
48
+ if (!record(attrs) || !own(attrs, ['provider', 'url', 'title', 'caption', 'transcript', 'poster']) ||
49
+ !validString(attrs.title, 500, 1) || !/[^\p{White_Space}]/u.test(attrs.title) ||
50
+ !validString(attrs.caption, 2000) || !validString(attrs.transcript, 20_000) ||
51
+ !validString(attrs.url, 2048, 1) ||
52
+ (attrs.poster !== undefined && (typeof attrs.poster !== 'string' || !uuid.test(attrs.poster))))
53
+ invalid();
54
+ const { provider, url, title, caption, transcript } = attrs;
55
+ let embedUrl;
56
+ let match;
57
+ if (provider === 'youtube' && (match = url.match(/^https:\/\/www\.youtube\.com\/watch\?v=([A-Za-z0-9_-]{11})$/))) {
58
+ embedUrl = `https://www.youtube-nocookie.com/embed/${match[1]}`;
59
+ }
60
+ else if (provider === 'vimeo' && (match = url.match(/^https:\/\/vimeo\.com\/([1-9][0-9]{0,11})$/))) {
61
+ embedUrl = `https://player.vimeo.com/video/${match[1]}?dnt=1`;
62
+ }
63
+ else if (provider === 'spotify' && (match = url.match(/^https:\/\/open\.spotify\.com\/episode\/([A-Za-z0-9]{22})$/))) {
64
+ embedUrl = `https://open.spotify.com/embed/episode/${match[1]}`;
65
+ }
66
+ else if (provider === 'soundcloud' && (match = url.match(/^https:\/\/soundcloud\.com\/([A-Za-z0-9][A-Za-z0-9_-]{0,199})\/([A-Za-z0-9][A-Za-z0-9_-]{0,199})$/)) &&
67
+ !['sets', 'playlists', 'discover', 'charts', 'search', 'you'].includes(match[1].toLowerCase()) &&
68
+ !['sets', 'tracks', 'albums', 'reposts', 'likes', 'popular-tracks'].includes(match[2].toLowerCase())) {
69
+ embedUrl = `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&auto_play=false`;
70
+ }
71
+ else
72
+ invalid();
73
+ return { provider: provider, url, embedUrl, title, caption, transcript };
74
+ }
75
+ /** Pure player metadata. This performs no fetches, creates no iframe and grants no playback consent. */
76
+ export function externalMediaDescriptor(value) {
77
+ if (!record(value) || value.type !== 'externalMedia' || !own(value, ['type', 'attrs', 'resource']))
78
+ invalid();
79
+ const descriptor = external(value.attrs);
80
+ const attrs = value.attrs;
81
+ const resource = value.resource;
82
+ let poster = null;
83
+ if (typeof attrs.poster === 'string' && record(resource) && typeof resource.id === 'string' &&
84
+ attrs.poster.toLowerCase() === resource.id.toLowerCase() && typeof resource.content_type === 'string' && imageTypes.includes(resource.content_type)) {
85
+ const url = safeRichTextURL(resource.url, true);
86
+ const metadata = record(resource.metadata) ? resource.metadata : {};
87
+ if (url)
88
+ poster = { id: attrs.poster, url, alt: validString(metadata.alt, 2000) ? metadata.alt : '' };
89
+ }
90
+ return { ...descriptor, poster };
91
+ }
92
+ /** Strict v2 validation protects renderers from silently dropping unknown future nodes. */
93
+ export function assertRichTextV2(value) {
94
+ if (!record(value) || value.version !== 2 || !own(value, ['version', 'document']))
95
+ invalid();
96
+ let remaining = 500, embeds = 20, bytes = 65_536;
97
+ const string = (value, max, min = 0) => {
98
+ if (!validString(value, max, min))
99
+ invalid();
100
+ bytes -= encoder.encode(value).byteLength;
101
+ if (bytes < 0)
102
+ invalid();
103
+ return value;
104
+ };
105
+ function node(value, context, depth) {
106
+ if (!record(value) || depth > 8 || --remaining < 0 || typeof value.type !== 'string')
107
+ invalid();
108
+ const children = (next, min = 0) => {
109
+ const list = value.content ?? [];
110
+ if (!Array.isArray(list) || list.length < min || list.length > 200)
111
+ invalid();
112
+ for (const child of list)
113
+ node(child, next, depth + 1);
114
+ return list;
115
+ };
116
+ if (context === 'inline' || context === 'code') {
117
+ if (context === 'inline' && value.type === 'hardBreak' && own(value, ['type']))
118
+ return;
119
+ if (value.type !== 'text' || !own(value, context === 'code' ? ['type', 'text'] : ['type', 'text', 'marks']))
120
+ invalid();
121
+ string(value.text, 20_000, 1);
122
+ if (context === 'code')
123
+ return;
124
+ const marks = value.marks ?? [];
125
+ if (!Array.isArray(marks) || marks.length > 6)
126
+ invalid();
127
+ const seen = new Set();
128
+ for (const mark of marks) {
129
+ if (!record(mark) || typeof mark.type !== 'string' || seen.has(mark.type))
130
+ invalid();
131
+ seen.add(mark.type);
132
+ if (mark.type === 'link') {
133
+ if (!own(mark, ['type', 'attrs']) || !record(mark.attrs) || !own(mark.attrs, ['href']) ||
134
+ !safeRichTextURL(string(mark.attrs.href, 2048, 1), false))
135
+ invalid();
136
+ }
137
+ else if (!['bold', 'italic', 'underline', 'strike', 'code'].includes(mark.type) || !own(mark, ['type']))
138
+ invalid();
139
+ }
140
+ return;
141
+ }
142
+ if (context === 'root') {
143
+ if (value.type !== 'doc' || !own(value, ['type', 'content']))
144
+ invalid();
145
+ children('block');
146
+ return;
147
+ }
148
+ if (context === 'list') {
149
+ if (value.type !== 'listItem' || !own(value, ['type', 'content']) || !Array.isArray(value.content) ||
150
+ !record(value.content[0]) || value.content[0].type !== 'paragraph')
151
+ invalid();
152
+ children('item', 1);
153
+ return;
154
+ }
155
+ if (context === 'row' || context === 'headerRow') {
156
+ if (value.type !== (context === 'headerRow' ? 'tableHeader' : 'tableCell') ||
157
+ !own(value, ['type', 'content']) || !Array.isArray(value.content) || value.content.length !== 1)
158
+ invalid();
159
+ children('cell', 1);
160
+ return;
161
+ }
162
+ if (value.type === 'paragraph' && ['block', 'item', 'quote', 'cell'].includes(context)) {
163
+ if (!own(value, ['type', 'content']))
164
+ invalid();
165
+ children('inline');
166
+ return;
167
+ }
168
+ if (['bulletList', 'orderedList'].includes(value.type) && ['block', 'item'].includes(context)) {
169
+ if (!own(value, ['type', 'content', 'attrs']) ||
170
+ (value.attrs !== undefined && (!record(value.attrs) || !own(value.attrs, ['start']) ||
171
+ Object.keys(value.attrs).length > 0 && (value.type !== 'orderedList' || value.attrs.start !== 1))))
172
+ invalid();
173
+ children('list', 1);
174
+ return;
175
+ }
176
+ if (context !== 'block')
177
+ invalid();
178
+ if (value.type === 'heading') {
179
+ if (!own(value, ['type', 'attrs', 'content']) || !record(value.attrs) ||
180
+ !own(value.attrs, ['level']) || typeof value.attrs.level !== 'number' || ![2, 3, 4].includes(value.attrs.level))
181
+ invalid();
182
+ children('inline');
183
+ return;
184
+ }
185
+ if (value.type === 'blockquote') {
186
+ if (!own(value, ['type', 'content']))
187
+ invalid();
188
+ children('quote', 1);
189
+ return;
190
+ }
191
+ if (value.type === 'codeBlock') {
192
+ if (!own(value, ['type', 'attrs', 'content']) || !record(value.attrs) || !own(value.attrs, ['language']))
193
+ invalid();
194
+ const language = string(value.attrs.language, 32);
195
+ if (language !== '' && !/^[A-Za-z][A-Za-z0-9_+.#-]{0,31}$/.test(language))
196
+ invalid();
197
+ children('code');
198
+ return;
199
+ }
200
+ if (value.type === 'table') {
201
+ if (!own(value, ['type', 'attrs', 'content']) || !record(value.attrs) ||
202
+ !own(value.attrs, ['caption', 'header']) || typeof value.attrs.header !== 'boolean' ||
203
+ !Array.isArray(value.content) || value.content.length < 1 || value.content.length > 20)
204
+ invalid();
205
+ string(value.attrs.caption, 500);
206
+ let columns = 0;
207
+ for (let index = 0; index < value.content.length; index++) {
208
+ const row = value.content[index];
209
+ if (!record(row) || --remaining < 0 || depth + 1 > 8 || row.type !== 'tableRow' ||
210
+ !own(row, ['type', 'content']) || !Array.isArray(row.content) || row.content.length < 1 || row.content.length > 6)
211
+ invalid();
212
+ columns ||= row.content.length;
213
+ if (row.content.length !== columns)
214
+ invalid();
215
+ for (const cell of row.content)
216
+ node(cell, index === 0 && value.attrs.header ? 'headerRow' : 'row', depth + 2);
217
+ }
218
+ return;
219
+ }
220
+ if (!['image', 'entry', 'externalMedia'].includes(value.type) || --embeds < 0)
221
+ invalid();
222
+ if (value.type === 'externalMedia') {
223
+ if (!own(value, ['type', 'attrs', 'resource']))
224
+ invalid();
225
+ external(value.attrs);
226
+ const attrs = value.attrs;
227
+ string(attrs.title, 500, 1);
228
+ string(attrs.caption, 2000);
229
+ string(attrs.transcript, 20_000);
230
+ return;
231
+ }
232
+ if (!own(value, ['type', 'attrs', 'resource']) || !record(value.attrs) ||
233
+ !own(value.attrs, value.type === 'image' ? ['id', 'alt'] : ['id', 'target']) ||
234
+ typeof value.attrs.id !== 'string' || !uuid.test(value.attrs.id))
235
+ invalid();
236
+ if (value.type === 'image')
237
+ string(value.attrs.alt, 2000);
238
+ else if (typeof value.attrs.target !== 'string' || !identifier.test(value.attrs.target))
239
+ invalid();
240
+ }
241
+ node(value.document, 'root', 0);
242
+ }
@@ -1,4 +1,6 @@
1
- /** Escaped HTML for rich-text v1, including safe expanded images/entry captions. */
1
+ export { externalMediaDescriptor } from './rich-text-v2.js';
2
+ export type { ExternalMediaDescriptor, ExternalMediaProvider } from './rich-text-v2.js';
3
+ /** Escaped HTML; v2 rejects malformed/unknown nodes. External media remains a static card. */
2
4
  export declare function richTextToHtml(value: unknown): string;
3
5
  /** Authored text with block/hard-break newlines; excludes embeds and link URLs. */
4
6
  export declare function richTextToPlainText(value: unknown): string;