@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bettercms-ai/richtext",
3
- "version": "0.4.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 { PT_TYPES, type PortableText, type PortableTextTable } from "./types";
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
- const components: Partial<PortableTextHtmlComponents> = {
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. It renders as a marker rather than as content, because the
118
- * payload it carries (a component reference, a media id) has to be RESOLVED against the
119
- * project before it means anything, and this serialiser has no project. Emitting the
120
- * marker keeps the node addressable and visible instead of silently rendering nothing.
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 = esc(opts.bindingBase);
208
- return value
209
- .map((node) => {
210
- const inner = toHTML([node] as never, { components });
211
- // A node with no key cannot be addressed. Render it rather than dropping it — an
212
- // unaddressable paragraph is a small loss, a missing one is content disappearing.
213
- if (!node._key) return inner;
214
- return `<div data-bcms-field="${base}#${esc(node._key)}" data-bcms-kind="richtext">${inner}</div>`;
215
- })
216
- .join("");
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
@@ -9,3 +9,4 @@ export * from "./styled";
9
9
  export * from "./read";
10
10
  export * from "./accept";
11
11
  export * from "./features";
12
+ export * from "./video";
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 { PT_TYPES, type PortableText, type PortableTextBlock } from "./types";
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
+ }