@bettercms-ai/richtext 0.4.0 → 0.6.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/package.json +1 -1
- package/src/html.ts +219 -18
- package/src/index.ts +1 -0
- package/src/ingest.ts +120 -1
- package/src/schema.ts +33 -0
- package/src/types.ts +53 -0
- package/src/video.ts +74 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bettercms-ai/richtext",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "The one Portable Text contract for BetterCMS: schema, converters, and rendering. Shared by the backend, the dashboard and every delivery SDK.",
|
package/src/html.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { toHTML, type PortableTextHtmlComponents } from "@portabletext/to-html";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
BUILTIN_EMBED_KEY,
|
|
4
|
+
BUILTIN_IMAGE_KEY,
|
|
5
|
+
PT_TYPES,
|
|
6
|
+
type PortableText,
|
|
7
|
+
type PortableTextTable,
|
|
8
|
+
} from "./types";
|
|
9
|
+
import { resolveVideoSource } from "./video";
|
|
3
10
|
import { builtInTableToTable } from "./table";
|
|
4
11
|
import { safeHref } from "./href";
|
|
5
12
|
import { isStyledTag, styledAttrEntries } from "./styled";
|
|
@@ -84,12 +91,137 @@ function tableHtml(table: PortableTextTable): string {
|
|
|
84
91
|
}</table>`;
|
|
85
92
|
}
|
|
86
93
|
|
|
94
|
+
/**
|
|
95
|
+
* The tags a caller may dress with the SITE'S OWN classes.
|
|
96
|
+
*
|
|
97
|
+
* Every tag this serialiser emits that a stylesheet is likely to target, and no attribute but
|
|
98
|
+
* `class`: an imported page's prose is `<p class="mt-4 text-slate-600">`, and a value that comes
|
|
99
|
+
* back as a bare `<p>` is the design deleted on the author's first save. The list is closed
|
|
100
|
+
* because each entry has to be a tag this file actually writes.
|
|
101
|
+
*/
|
|
102
|
+
export type ClassTag =
|
|
103
|
+
| "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
|
|
104
|
+
| "ul" | "ol" | "li" | "a" | "img" | "figure" | "blockquote";
|
|
105
|
+
|
|
106
|
+
export type HtmlClasses = Partial<Record<ClassTag, string>>;
|
|
107
|
+
|
|
108
|
+
/** ` class="…"`, or nothing. Values come from a derived page, so they are escaped like content. */
|
|
109
|
+
const cls = (classes: HtmlClasses, tag: ClassTag): string => {
|
|
110
|
+
const value = classes[tag];
|
|
111
|
+
return value ? ` class="${esc(value)}"` : "";
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Portable Text style → the tag it is written as. `normal` is a paragraph. */
|
|
115
|
+
const STYLE_TAGS: Record<string, ClassTag> = {
|
|
116
|
+
normal: "p", blockquote: "blockquote",
|
|
117
|
+
h1: "h1", h2: "h2", h3: "h3", h4: "h4", h5: "h5", h6: "h6",
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A MEDIA url: `safeHref`, plus a plain relative path.
|
|
124
|
+
*
|
|
125
|
+
* An imported page is full of `images/hero.png` and `../media/clip.mp4`, and `safeHref` refuses
|
|
126
|
+
* a path with no leading slash — so every one of those images would render as nothing, which is
|
|
127
|
+
* the loss this whole pass exists to stop. Widened only for `<img>`, `<video>` and a poster:
|
|
128
|
+
* they load a picture, while an `<iframe>` frames a whole page and keeps the strict check.
|
|
129
|
+
* A colon or whitespace is refused, so no scheme (`javascript:`) can ride this path, and a
|
|
130
|
+
* PROTOCOL-RELATIVE `//host/clip.mp4` is refused with it — it is off-origin, not relative, and
|
|
131
|
+
* `safeHref` rejects the same shape for the same reason (href.ts:20).
|
|
132
|
+
*/
|
|
133
|
+
const mediaSrc = (raw: string): string | null =>
|
|
134
|
+
safeHref(raw) ?? (raw !== "" && !raw.startsWith("//") && !/[:\s]/.test(raw) ? raw : null);
|
|
135
|
+
|
|
136
|
+
const fieldsOf = (value: unknown): Record<string, unknown> => {
|
|
137
|
+
const f = isRecord(value) ? value.fields : undefined;
|
|
138
|
+
return isRecord(f) ? f : {};
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* An image, as the markup a page was imported FROM.
|
|
143
|
+
*
|
|
144
|
+
* It used to render as `<div class="bcms-block">` — an empty marker — so an article's
|
|
145
|
+
* illustrations vanished from every delivered rendition while the stored value still held them.
|
|
146
|
+
* The payload is a URL and an alt string, which is everything an `<img>` needs; the marker was
|
|
147
|
+
* only ever right for the blocks that carry an unresolvable REFERENCE.
|
|
148
|
+
*/
|
|
149
|
+
function imageHtml(value: unknown, classes: HtmlClasses): string {
|
|
150
|
+
const f = fieldsOf(value);
|
|
151
|
+
const src = mediaSrc(str(f.src));
|
|
152
|
+
if (!src) return "";
|
|
153
|
+
const caption = str(f.caption);
|
|
154
|
+
const img = `<img${cls(classes, "img")} src="${esc(src)}" alt="${esc(str(f.alt))}">`;
|
|
155
|
+
return `<figure${cls(classes, "figure")}>${img}${
|
|
156
|
+
caption ? `<figcaption>${esc(caption)}</figcaption>` : ""
|
|
157
|
+
}</figure>`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The attributes a framed embed needs to be safe: no scripts of ours, no payment APIs. */
|
|
161
|
+
const IFRAME_ATTRS = ' loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen';
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* An embed — a provider player, a map, a form. `<iframe>` and nothing else.
|
|
165
|
+
*
|
|
166
|
+
* `safeHref` is the whole gate: it admits http(s) and site-relative URLs only, so a
|
|
167
|
+
* `javascript:` src cannot reach the attribute. An embed whose URL it refuses renders nothing
|
|
168
|
+
* rather than a broken frame.
|
|
169
|
+
*/
|
|
170
|
+
function embedHtml(value: unknown, _classes: HtmlClasses): string {
|
|
171
|
+
const f = fieldsOf(value);
|
|
172
|
+
const src = safeHref(str(f.url) || str(f.src));
|
|
173
|
+
if (!src) return "";
|
|
174
|
+
const title = str(f.title);
|
|
175
|
+
return `<iframe src="${esc(src)}"${title ? ` title="${esc(title)}"` : ""}${IFRAME_ATTRS}></iframe>`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A video: the file itself where the URL is one, the provider's player where it is a provider's.
|
|
180
|
+
*
|
|
181
|
+
* `resolveVideoSource` is the single answer to "what is this URL", shared with the editor that
|
|
182
|
+
* drew the same block — see video.ts. A URL it does not recognise still plays as a `<video>`:
|
|
183
|
+
* an uploaded asset is served from a PATH, which is not parseable as a URL at all, and refusing
|
|
184
|
+
* it would make every uploaded video render as nothing.
|
|
185
|
+
*/
|
|
186
|
+
function videoHtml(value: unknown, _classes: HtmlClasses): string {
|
|
187
|
+
const v = isRecord(value) ? value : {};
|
|
188
|
+
const raw = str(v.src);
|
|
189
|
+
const src = mediaSrc(raw);
|
|
190
|
+
if (!src) return "";
|
|
191
|
+
const title = str(v.title);
|
|
192
|
+
const titleAttr = title ? ` title="${esc(title)}"` : "";
|
|
193
|
+
const resolved = resolveVideoSource(raw);
|
|
194
|
+
if (resolved?.kind === "iframe") {
|
|
195
|
+
return `<iframe src="${esc(resolved.src)}"${titleAttr}${IFRAME_ATTRS}></iframe>`;
|
|
196
|
+
}
|
|
197
|
+
const poster = mediaSrc(str(v.poster));
|
|
198
|
+
return `<video controls src="${esc(src)}"${poster ? ` poster="${esc(poster)}"` : ""}${titleAttr}></video>`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** A downloadable document. The NAME is the link text — a file with no name has nothing to click. */
|
|
202
|
+
function fileHtml(value: unknown, classes: HtmlClasses): string {
|
|
203
|
+
const v = isRecord(value) ? value : {};
|
|
204
|
+
const src = safeHref(str(v.src));
|
|
205
|
+
if (!src) return "";
|
|
206
|
+
const name = str(v.name) || src.split("/").pop() || "Download";
|
|
207
|
+
return `<a${cls(classes, "a")} href="${esc(src)}" download>${esc(name)}</a>`;
|
|
208
|
+
}
|
|
209
|
+
|
|
87
210
|
export interface RenderOptions {
|
|
88
211
|
/**
|
|
89
212
|
* Render a single value with no wrapping `<p>`, for a field whose consumer supplies its own
|
|
90
213
|
* heading or button. Mirrors `allowMultipleParagraphs: false` and the old `toInlineHtml`.
|
|
91
214
|
*/
|
|
92
215
|
inline?: boolean;
|
|
216
|
+
/**
|
|
217
|
+
* The site's own class per tag, for a value derived FROM that site.
|
|
218
|
+
*
|
|
219
|
+
* A derived `document` field records what the imported page's prose wore
|
|
220
|
+
* (`config.htmlClasses`), and every rendition of that value — the envelope's html mirror, the
|
|
221
|
+
* published page, the canvas — has to put it back on or the page restyles itself the first
|
|
222
|
+
* time anyone saves.
|
|
223
|
+
*/
|
|
224
|
+
classes?: HtmlClasses;
|
|
93
225
|
/**
|
|
94
226
|
* `<blockId>__<prop>`. Present ⇒ wrap each top-level node in an element carrying
|
|
95
227
|
* `data-bcms-field="<base>#<_key>"`, which is what makes ONE PARAGRAPH inside a rich-text
|
|
@@ -98,11 +230,53 @@ export interface RenderOptions {
|
|
|
98
230
|
* Wrapping at the top level rather than inside the block components is deliberate: a block
|
|
99
231
|
* component only ever sees TEXT blocks, so a divider or an embedded image would get no
|
|
100
232
|
* address — and those are exactly the nodes an editor most wants to click.
|
|
233
|
+
*
|
|
234
|
+
* List items are the one exception: a run of them renders as ONE `<ul>`/`<ol>` and the address
|
|
235
|
+
* sits on each `<li>`, because a wrapper per item would split the list into one-item lists.
|
|
101
236
|
*/
|
|
102
237
|
bindingBase?: string;
|
|
103
238
|
}
|
|
104
239
|
|
|
105
|
-
|
|
240
|
+
/**
|
|
241
|
+
* A tag override per style/list, for the tags the caller gave a class — and NOTHING else.
|
|
242
|
+
*
|
|
243
|
+
* Only the dressed tags are overridden, so a call with no classes renders through the library's
|
|
244
|
+
* own components byte for byte. That is what keeps every already-published rendition stable:
|
|
245
|
+
* the html on an envelope is compared against stored markup by the parity gate.
|
|
246
|
+
*/
|
|
247
|
+
const tagOverrides = <T extends string>(
|
|
248
|
+
classes: HtmlClasses,
|
|
249
|
+
tags: Record<T, ClassTag>,
|
|
250
|
+
): Record<string, (p: { children?: string }) => string> => {
|
|
251
|
+
const out: Record<string, (p: { children?: string }) => string> = {};
|
|
252
|
+
for (const [name, tag] of Object.entries(tags) as [string, ClassTag][]) {
|
|
253
|
+
if (!classes[tag]) continue;
|
|
254
|
+
out[name] = ({ children }) => `<${tag}${cls(classes, tag)}>${children ?? ""}</${tag}>`;
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** The per-node address `portableTextToHtml({ bindingBase })` stamps. "" for a node with no key. */
|
|
260
|
+
const bindingAttrs = (base: string, key: unknown): string =>
|
|
261
|
+
typeof key === "string" && key
|
|
262
|
+
? ` data-bcms-field="${esc(base)}#${esc(key)}" data-bcms-kind="richtext"`
|
|
263
|
+
: "";
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* `keys` is the set of `_key`s the value actually carries: to-html invents one (`li-0-1`) for an
|
|
267
|
+
* item that has none, and an invented key is an address that resolves to nothing.
|
|
268
|
+
*/
|
|
269
|
+
const buildComponents = (
|
|
270
|
+
classes: HtmlClasses = {},
|
|
271
|
+
binding?: { base: string; keys: ReadonlySet<unknown> },
|
|
272
|
+
): Partial<PortableTextHtmlComponents> => ({
|
|
273
|
+
block: tagOverrides(classes, STYLE_TAGS),
|
|
274
|
+
list: tagOverrides(classes, { bullet: "ul", number: "ol", check: "ul" }),
|
|
275
|
+
// With a base, every item carries its own address ON the `<li>` — see `bindingBase`.
|
|
276
|
+
listItem: binding
|
|
277
|
+
? ({ value, children }: { value: { _key?: unknown }; children?: string }) =>
|
|
278
|
+
`<li${cls(classes, "li")}${binding.keys.has(value._key) ? bindingAttrs(binding.base, value._key) : ""}>${children ?? ""}</li>`
|
|
279
|
+
: tagOverrides(classes, { bullet: "li", number: "li", check: "li" }),
|
|
106
280
|
types: {
|
|
107
281
|
[PT_TYPES.divider]: () => "<hr>",
|
|
108
282
|
[PT_TYPES.table]: ({ value }) => tableHtml(value as PortableTextTable),
|
|
@@ -113,14 +287,22 @@ const components: Partial<PortableTextHtmlComponents> = {
|
|
|
113
287
|
const cls = lang ? ` class="language-${lang}"` : "";
|
|
114
288
|
return `<pre><code${cls}>${esc(code)}</code></pre>`;
|
|
115
289
|
},
|
|
290
|
+
[PT_TYPES.video]: ({ value }) => videoHtml(value, classes),
|
|
291
|
+
[PT_TYPES.file]: ({ value }) => fileHtml(value, classes),
|
|
116
292
|
/**
|
|
117
|
-
* A generic embedded block.
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
293
|
+
* A generic embedded block. A block whose payload this serialiser can READ renders as
|
|
294
|
+
* content; one carrying a reference it would have to resolve against a project renders as a
|
|
295
|
+
* marker, which keeps the node addressable and visible instead of drawing nothing.
|
|
296
|
+
*
|
|
297
|
+
* 🔴 AN IMAGE AND AN EMBED ARE READABLE, and rendering them as markers was silent loss: an
|
|
298
|
+
* imported article's illustrations and its YouTube embed disappeared from every delivered
|
|
299
|
+
* rendition while the stored value still held them. Their payload is a URL — there is
|
|
300
|
+
* nothing to resolve.
|
|
121
301
|
*/
|
|
122
302
|
[PT_TYPES.block]: ({ value }) => {
|
|
123
303
|
const v = value as { schemaKey?: unknown };
|
|
304
|
+
if (v.schemaKey === BUILTIN_IMAGE_KEY) return imageHtml(value, classes);
|
|
305
|
+
if (v.schemaKey === BUILTIN_EMBED_KEY) return embedHtml(value, classes);
|
|
124
306
|
/**
|
|
125
307
|
* A pre-P1c TABLE is the exception to the marker, and it has to be: it resolves against
|
|
126
308
|
* nothing. Its rows are text, held right here, so a marker would DELETE visible content.
|
|
@@ -164,7 +346,7 @@ const components: Partial<PortableTextHtmlComponents> = {
|
|
|
164
346
|
rel.add("noreferrer");
|
|
165
347
|
}
|
|
166
348
|
const relAttr = rel.size ? ` rel="${esc([...rel].join(" "))}"` : "";
|
|
167
|
-
return `<a href="${esc(href)}"${target}${relAttr}>${children}</a>`;
|
|
349
|
+
return `<a${cls(classes, "a")} href="${esc(href)}"${target}${relAttr}>${children}</a>`;
|
|
168
350
|
},
|
|
169
351
|
/**
|
|
170
352
|
* The site's own inline markup, written back out as itself.
|
|
@@ -195,25 +377,44 @@ const components: Partial<PortableTextHtmlComponents> = {
|
|
|
195
377
|
*/
|
|
196
378
|
unknownMark: ({ children }) => children,
|
|
197
379
|
unknownType: ({ children }) => children ?? "",
|
|
198
|
-
};
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
/** The undressed components, built once: the overwhelming majority of calls pass no classes. */
|
|
383
|
+
const defaultComponents = buildComponents();
|
|
384
|
+
|
|
385
|
+
const isListItem = (node: unknown): boolean =>
|
|
386
|
+
isRecord(node) && node._type === "block" && typeof node.listItem === "string" && !!node.listItem;
|
|
199
387
|
|
|
200
388
|
export function portableTextToHtml(
|
|
201
389
|
value: PortableText | null | undefined,
|
|
202
390
|
opts: RenderOptions = {},
|
|
203
391
|
): string {
|
|
204
392
|
if (!Array.isArray(value) || value.length === 0) return "";
|
|
393
|
+
const components = opts.classes ? buildComponents(opts.classes) : defaultComponents;
|
|
205
394
|
|
|
206
395
|
if (opts.bindingBase) {
|
|
207
|
-
const base =
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
396
|
+
const base = opts.bindingBase;
|
|
397
|
+
const bound = buildComponents(opts.classes, { base, keys: new Set(value.map((n) => n._key)) });
|
|
398
|
+
const out: string[] = [];
|
|
399
|
+
for (let i = 0; i < value.length; ) {
|
|
400
|
+
const node = value[i];
|
|
401
|
+
// A RUN of list items renders as ONE list, each `<li>` carrying its own address. Wrapping
|
|
402
|
+
// items one by one gave every item its own `<ol>`, so numbering restarted at 1 on every line
|
|
403
|
+
// and bullets sat a list-margin apart — the canvas no longer looked like the site.
|
|
404
|
+
if (isListItem(node)) {
|
|
405
|
+
let end = i + 1;
|
|
406
|
+
while (end < value.length && isListItem(value[end])) end++;
|
|
407
|
+
out.push(toHTML(value.slice(i, end) as never, { components: bound }));
|
|
408
|
+
i = end;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
const inner = toHTML([node] as never, { components });
|
|
412
|
+
// A node with no key cannot be addressed. Render it rather than dropping it — an
|
|
413
|
+
// unaddressable paragraph is a small loss, a missing one is content disappearing.
|
|
414
|
+
out.push(node._key ? `<div${bindingAttrs(base, node._key)}>${inner}</div>` : inner);
|
|
415
|
+
i++;
|
|
416
|
+
}
|
|
417
|
+
return out.join("");
|
|
217
418
|
}
|
|
218
419
|
|
|
219
420
|
const html = toHTML(value as never, { components });
|
package/src/index.ts
CHANGED
package/src/ingest.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { htmlToPortableText as ptHtmlToPortableText, type DeserializerRule } from "@portabletext/html";
|
|
2
2
|
import { compileSchema } from "@portabletext/schema";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
BUILTIN_EMBED_KEY,
|
|
5
|
+
BUILTIN_IMAGE_KEY,
|
|
6
|
+
PT_TYPES,
|
|
7
|
+
type PortableText,
|
|
8
|
+
type PortableTextBlock,
|
|
9
|
+
} from "./types";
|
|
4
10
|
import { randomKey, isValidKey, type KeyGenerator } from "./key";
|
|
5
11
|
import { safeHref } from "./href";
|
|
6
12
|
import { isStyledTag, styledAttrsOf, type StyledMarkDef } from "./styled";
|
|
@@ -222,6 +228,118 @@ function styledRule(key: KeyGenerator): DeserializerRule {
|
|
|
222
228
|
};
|
|
223
229
|
}
|
|
224
230
|
|
|
231
|
+
/**
|
|
232
|
+
* ── MEDIA, WHICH USED TO PRODUCE NO NODE AT ALL ──────────────────────────────────────────
|
|
233
|
+
*
|
|
234
|
+
* `<img>`, `<video>`, `<iframe>` and a PDF link have no place in the compiled schema, so a
|
|
235
|
+
* prose container ingested as one document lost every one of them: the deserialiser emits
|
|
236
|
+
* nothing for a tag it cannot map, and an article came back as its paragraphs only. These four
|
|
237
|
+
* rules are what make importing a page's body lossless.
|
|
238
|
+
*
|
|
239
|
+
* Gated the same way the table rule is, and for the same reason — a custom rule runs BEFORE the
|
|
240
|
+
* compiled schema and its output is taken as given, so an ungated rule is a way past a field's
|
|
241
|
+
* Accept config.
|
|
242
|
+
*/
|
|
243
|
+
|
|
244
|
+
/** The `<img>`, `<video>` or `<iframe>` a `<figure>` is wrapped around — the figure's subject. */
|
|
245
|
+
const mediaIn = (el: Element): Element | null =>
|
|
246
|
+
el.querySelector("img,video,iframe");
|
|
247
|
+
|
|
248
|
+
const captionOf = (el: Element): string =>
|
|
249
|
+
el.nodeName.toLowerCase() === "figure"
|
|
250
|
+
? (el.querySelector("figcaption")?.textContent ?? "").trim()
|
|
251
|
+
: "";
|
|
252
|
+
|
|
253
|
+
/** `<video src>` or `<video><source src></video>` — both spellings are ordinary markup. */
|
|
254
|
+
const videoSrc = (el: Element): string =>
|
|
255
|
+
(el.getAttribute("src") ?? el.querySelector("source")?.getAttribute("src") ?? "").trim();
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* An `<a>` that IS its line — nothing else visible beside it in its parent.
|
|
259
|
+
*
|
|
260
|
+
* A PDF link in the middle of a sentence is a link, and turning it into a block would tear the
|
|
261
|
+
* sentence in half. A link on a line of its own is a download button, which is what the `file`
|
|
262
|
+
* block draws.
|
|
263
|
+
*/
|
|
264
|
+
const wholeLine = (el: Element): boolean => {
|
|
265
|
+
const parent = el.parentElement;
|
|
266
|
+
if (!parent) return false;
|
|
267
|
+
return [...parent.childNodes].every(
|
|
268
|
+
(n) => n === el || (n.nodeType === 3 && (n as Text).data.trim() === ""),
|
|
269
|
+
);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const PDF_HREF = /\.pdf(?:[?#]|$)/i;
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* `<figure>`/`<img>`/`<video>`/`<iframe>`/`<a href=*.pdf>` → the block object each one is.
|
|
276
|
+
*
|
|
277
|
+
* ONE rule rather than four, because a `<figure>` has to decide which of them it is wrapping
|
|
278
|
+
* and four rules would each have to ask that question. `undefined` hands the node back to the
|
|
279
|
+
* deserialiser untouched, which is what keeps a mid-sentence PDF link a link.
|
|
280
|
+
*/
|
|
281
|
+
function mediaRule(key: KeyGenerator, schema: PtSchema | undefined): DeserializerRule {
|
|
282
|
+
const allows = (name: string): boolean =>
|
|
283
|
+
!schema || schema.blockObjects.some((o) => o.name === name);
|
|
284
|
+
return {
|
|
285
|
+
deserialize(node: Node, _next, createBlock) {
|
|
286
|
+
if (!isElement(node)) return undefined;
|
|
287
|
+
const tag = node.nodeName.toLowerCase();
|
|
288
|
+
if (tag === "a") {
|
|
289
|
+
if (!allows(PT_TYPES.file)) return undefined;
|
|
290
|
+
const href = (node.getAttribute("href") ?? "").trim();
|
|
291
|
+
if (!PDF_HREF.test(href) || !safeHref(href) || !wholeLine(node)) return undefined;
|
|
292
|
+
const name = (node.textContent ?? "").trim() || href.split("/").pop() || "Download";
|
|
293
|
+
return createBlock({ _type: PT_TYPES.file, _key: key(), src: href, name });
|
|
294
|
+
}
|
|
295
|
+
const el = tag === "figure" ? mediaIn(node) : node;
|
|
296
|
+
if (!el) return undefined;
|
|
297
|
+
const subject = el.nodeName.toLowerCase();
|
|
298
|
+
if (subject !== "img" && subject !== "video" && subject !== "iframe") return undefined;
|
|
299
|
+
const caption = captionOf(node);
|
|
300
|
+
|
|
301
|
+
if (subject === "video") {
|
|
302
|
+
if (!allows(PT_TYPES.video)) return undefined;
|
|
303
|
+
const src = videoSrc(el);
|
|
304
|
+
if (!src) return undefined;
|
|
305
|
+
const poster = (el.getAttribute("poster") ?? "").trim();
|
|
306
|
+
return createBlock({
|
|
307
|
+
_type: PT_TYPES.video,
|
|
308
|
+
_key: key(),
|
|
309
|
+
src,
|
|
310
|
+
...(poster ? { poster } : {}),
|
|
311
|
+
...(caption ? { title: caption } : {}),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
// Image and embed keep the GENERIC block object under their existing `builtin:` keys —
|
|
315
|
+
// thousands of stored values already spell them that way, and nothing is gained by a
|
|
316
|
+
// migration when the renderer reads both shapes from the same fields.
|
|
317
|
+
if (!allows(PT_TYPES.block)) return undefined;
|
|
318
|
+
const src = (el.getAttribute("src") ?? "").trim();
|
|
319
|
+
if (!src) return undefined;
|
|
320
|
+
if (subject === "iframe") {
|
|
321
|
+
const title = (el.getAttribute("title") ?? "").trim() || caption;
|
|
322
|
+
return createBlock({
|
|
323
|
+
_type: PT_TYPES.block,
|
|
324
|
+
_key: key(),
|
|
325
|
+
schemaKey: BUILTIN_EMBED_KEY,
|
|
326
|
+
fields: { url: src, ...(title ? { title } : {}) },
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return createBlock({
|
|
330
|
+
_type: PT_TYPES.block,
|
|
331
|
+
_key: key(),
|
|
332
|
+
schemaKey: BUILTIN_IMAGE_KEY,
|
|
333
|
+
fields: {
|
|
334
|
+
src,
|
|
335
|
+
alt: (el.getAttribute("alt") ?? "").trim(),
|
|
336
|
+
...(caption ? { caption } : {}),
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
225
343
|
/**
|
|
226
344
|
* Does this field's schema carry the `styled` annotation?
|
|
227
345
|
*
|
|
@@ -263,6 +381,7 @@ export function htmlToPortableText(html: string, opts: IngestOptions = {}): Port
|
|
|
263
381
|
*/
|
|
264
382
|
rules: [
|
|
265
383
|
...(allowsTables(opts.schema) ? [tableRule(key, (cell) => htmlToPortableText(cell, opts))] : []),
|
|
384
|
+
mediaRule(key, opts.schema),
|
|
266
385
|
...(allowsStyled(opts.schema) ? [styledRule(key)] : []),
|
|
267
386
|
],
|
|
268
387
|
keyGenerator: key,
|
package/src/schema.ts
CHANGED
|
@@ -146,6 +146,39 @@ export function ptSchemaFor(accept: readonly AcceptElement[]): PtSchema {
|
|
|
146
146
|
],
|
|
147
147
|
});
|
|
148
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Video and file, the two media kinds with a type of their own.
|
|
151
|
+
*
|
|
152
|
+
* They ride the Accept elements that already gate the same capability — `embed` is "Video
|
|
153
|
+
* embed" and a downloadable document is a link — so no stored field config has to change for
|
|
154
|
+
* an author to get them. Image, embed and columns keep the generic block above: their
|
|
155
|
+
* rendition is a straight read of their own fields, while a video's depends on what its URL
|
|
156
|
+
* turns out to be, and a file's is a link the author must be able to name.
|
|
157
|
+
*/
|
|
158
|
+
if (has("embed")) {
|
|
159
|
+
blockObjects.push({
|
|
160
|
+
name: PT_TYPES.video,
|
|
161
|
+
title: "Video",
|
|
162
|
+
fields: [
|
|
163
|
+
{ name: "src", type: "string" },
|
|
164
|
+
{ name: "poster", type: "string" },
|
|
165
|
+
{ name: "provider", type: "string" },
|
|
166
|
+
{ name: "title", type: "string" },
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (has("link")) {
|
|
171
|
+
blockObjects.push({
|
|
172
|
+
name: PT_TYPES.file,
|
|
173
|
+
title: "File",
|
|
174
|
+
fields: [
|
|
175
|
+
{ name: "src", type: "string" },
|
|
176
|
+
{ name: "name", type: "string" },
|
|
177
|
+
{ name: "size", type: "number" },
|
|
178
|
+
{ name: "mime", type: "string" },
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
}
|
|
149
182
|
/**
|
|
150
183
|
* The table, and it is a REAL block object now rather than the capability marker it used to be.
|
|
151
184
|
*
|
package/src/types.ts
CHANGED
|
@@ -131,6 +131,32 @@ export interface PortableTextTable extends PortableTextObject {
|
|
|
131
131
|
legacy?: boolean;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* A video block. `src` is an uploaded asset's path or a provider URL; `provider` and `poster`
|
|
136
|
+
* are what an author set, never something this package infers — the renderer asks
|
|
137
|
+
* `resolveVideoSource` about `src` and draws what it answers.
|
|
138
|
+
*
|
|
139
|
+
* 🔴 THE SHAPE IS A CONTRACT WITH THE EDITOR. The dashboard writes these fields (`ptNodes.tsx`,
|
|
140
|
+
* `formNodes.tsx`) and this package renders them; spelling it once here is what stops the two
|
|
141
|
+
* sides disagreeing about a key and rendering an empty box.
|
|
142
|
+
*/
|
|
143
|
+
export interface PortableTextVideo extends PortableTextObject {
|
|
144
|
+
_type: "video";
|
|
145
|
+
src: string;
|
|
146
|
+
poster?: string;
|
|
147
|
+
provider?: string;
|
|
148
|
+
title?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A downloadable file. `name` is the link's text — a file with no name has nothing to click. */
|
|
152
|
+
export interface PortableTextFile extends PortableTextObject {
|
|
153
|
+
_type: "file";
|
|
154
|
+
src: string;
|
|
155
|
+
name: string;
|
|
156
|
+
size?: number;
|
|
157
|
+
mime?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
134
160
|
export const isPortableTextBlock = (n: unknown): n is PortableTextBlock =>
|
|
135
161
|
!!n && typeof n === "object" && (n as PortableTextNode)._type === "block";
|
|
136
162
|
|
|
@@ -168,6 +194,16 @@ export const PT_TYPES = {
|
|
|
168
194
|
mention: "bcmsMention",
|
|
169
195
|
divider: "bcmsDivider",
|
|
170
196
|
code: "bcmsCode",
|
|
197
|
+
/**
|
|
198
|
+
* A video — an uploaded file or a provider URL. DEDICATED rather than another `schemaKey` on
|
|
199
|
+
* the generic block, because a video is the one media node whose rendition depends on its own
|
|
200
|
+
* payload: the same field is a `<video>` for an mp4 and a provider `<iframe>` for a YouTube
|
|
201
|
+
* link (@see resolveVideoSource). The generic block carries a reference this package cannot
|
|
202
|
+
* resolve; a video carries a URL it can.
|
|
203
|
+
*/
|
|
204
|
+
video: "video",
|
|
205
|
+
/** A downloadable document — a PDF, a spec sheet. `<a href download>`, nothing more. */
|
|
206
|
+
file: "file",
|
|
171
207
|
/**
|
|
172
208
|
* A table. Spelled `table`, unprefixed, because that is what every other Portable Text
|
|
173
209
|
* producer calls one — the Sanity-shaped `{_type:"table", rows}` this platform used to refuse
|
|
@@ -221,8 +257,25 @@ export const STORABLE_BLOCK_TYPES: ReadonlySet<string> = new Set<string>([
|
|
|
221
257
|
PT_TYPES.code,
|
|
222
258
|
PT_TYPES.divider,
|
|
223
259
|
PT_TYPES.mention,
|
|
260
|
+
PT_TYPES.video,
|
|
261
|
+
PT_TYPES.file,
|
|
224
262
|
]);
|
|
225
263
|
|
|
264
|
+
/**
|
|
265
|
+
* The `schemaKey`s the GENERIC block object carries for the two media kinds that keep it.
|
|
266
|
+
*
|
|
267
|
+
* Image and embed stay on `bcmsBlock` because thousands of stored values already spell them
|
|
268
|
+
* that way and a migration buys nothing: unlike a video, their rendition is a straight read of
|
|
269
|
+
* their own fields. Named here because this package now RENDERS them — it used to emit a marker
|
|
270
|
+
* — and a renderer keyed on a string literal typed at the call site is the drift this file
|
|
271
|
+
* exists to prevent.
|
|
272
|
+
*
|
|
273
|
+
* The embed's key is historically `builtin:video`: it is the "Video embed" block, and it
|
|
274
|
+
* predates the dedicated `video` type by two years of content.
|
|
275
|
+
*/
|
|
276
|
+
export const BUILTIN_IMAGE_KEY = "builtin:image" as const;
|
|
277
|
+
export const BUILTIN_EMBED_KEY = "builtin:video" as const;
|
|
278
|
+
|
|
226
279
|
/**
|
|
227
280
|
* Top-level nodes whose `_type` this platform cannot store or render, in document order.
|
|
228
281
|
*
|
package/src/video.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a video URL actually IS — a provider embed, a playable file, or nothing we will render.
|
|
3
|
+
*
|
|
4
|
+
* Moved here from the dashboard (`src/lib/richtext/builtInBlocks.ts`) because the same question
|
|
5
|
+
* is asked on both sides of one value: the server serialises a `video` block into the published
|
|
6
|
+
* page, and the editor draws the same block in the canvas. Two implementations disagreeing about
|
|
7
|
+
* whether a link is a YouTube embed means the author sees one thing and the visitor another.
|
|
8
|
+
*
|
|
9
|
+
* 🔴 ALLOWLIST, NOT A GUESS. An unknown URL returns null rather than becoming an `<iframe src>`:
|
|
10
|
+
* this value reaches a published page, and a frame pointing at arbitrary author input on the
|
|
11
|
+
* tenant's own origin is the one thing a renderer must not mint by accident.
|
|
12
|
+
*/
|
|
13
|
+
export type ResolvedVideoSource = {
|
|
14
|
+
kind: "iframe" | "video";
|
|
15
|
+
src: string;
|
|
16
|
+
provider: "YouTube" | "Vimeo" | "Video file";
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const videoId = (value: string | null | undefined): string | null =>
|
|
20
|
+
value && /^[a-zA-Z0-9_-]{6,32}$/.test(value) ? value : null;
|
|
21
|
+
|
|
22
|
+
/** Only known embed providers or direct video files become executable media elements. */
|
|
23
|
+
export function resolveVideoSource(value: unknown): ResolvedVideoSource | null {
|
|
24
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
25
|
+
let parsed: URL;
|
|
26
|
+
try {
|
|
27
|
+
parsed = new URL(value.trim());
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
32
|
+
|
|
33
|
+
const host = parsed.hostname.toLowerCase().replace(/^www\./, "");
|
|
34
|
+
let id: string | null = null;
|
|
35
|
+
if (host === "youtu.be") id = videoId(parsed.pathname.split("/").filter(Boolean)[0]);
|
|
36
|
+
if (host === "youtube.com" || host === "m.youtube.com" || host === "youtube-nocookie.com") {
|
|
37
|
+
id = videoId(
|
|
38
|
+
parsed.searchParams.get("v")
|
|
39
|
+
?? parsed.pathname.match(/^\/(?:embed|shorts)\/([^/?#]+)/)?.[1],
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
if (id) {
|
|
43
|
+
return {
|
|
44
|
+
kind: "iframe",
|
|
45
|
+
src: `https://www.youtube-nocookie.com/embed/${id}`,
|
|
46
|
+
provider: "YouTube",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (host === "vimeo.com" || host === "player.vimeo.com") {
|
|
51
|
+
// Hand-rolled reverse scan, not `findLast`: this package's consumers compile against
|
|
52
|
+
// libs older than ES2023, where `Array.prototype.findLast` is not declared.
|
|
53
|
+
const segments: string[] = parsed.pathname.split("/").filter(Boolean);
|
|
54
|
+
let vimeoId: string | null = null;
|
|
55
|
+
for (let i = segments.length - 1; i >= 0; i -= 1) {
|
|
56
|
+
if (/^\d+$/.test(segments[i]!)) {
|
|
57
|
+
vimeoId = segments[i]!;
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (vimeoId) {
|
|
62
|
+
return {
|
|
63
|
+
kind: "iframe",
|
|
64
|
+
src: `https://player.vimeo.com/video/${vimeoId}`,
|
|
65
|
+
provider: "Vimeo",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (/\.(?:m4v|mp4|og[gv]|webm)$/i.test(parsed.pathname)) {
|
|
71
|
+
return { kind: "video", src: parsed.toString(), provider: "Video file" };
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|