@bettercms-ai/richtext 0.3.0 → 0.5.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 +2 -1
- package/src/from-lexical.ts +3 -0
- package/src/heading.ts +132 -0
- package/src/href.ts +6 -0
- package/src/html.ts +186 -10
- 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.5.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.",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"exports": {
|
|
16
16
|
".": "./src/index.ts",
|
|
17
17
|
"./html": "./src/html.ts",
|
|
18
|
+
"./heading": "./src/heading.ts",
|
|
18
19
|
"./ingest": "./src/ingest.ts"
|
|
19
20
|
},
|
|
20
21
|
"scripts": {
|
package/src/from-lexical.ts
CHANGED
|
@@ -49,6 +49,7 @@ interface LexNode {
|
|
|
49
49
|
checked?: boolean;
|
|
50
50
|
url?: string;
|
|
51
51
|
target?: string | null;
|
|
52
|
+
rel?: string | null;
|
|
52
53
|
mentionId?: string;
|
|
53
54
|
children?: unknown[];
|
|
54
55
|
blockAttrs?: Record<string, unknown>;
|
|
@@ -102,6 +103,8 @@ function spansFrom(
|
|
|
102
103
|
if (href) {
|
|
103
104
|
const def: PortableTextMarkDef = { _type: PT_TYPES.link, _key: ctx.key(), href };
|
|
104
105
|
if (typeof node.target === "string" && node.target) def.target = node.target;
|
|
106
|
+
// `rel` rides next to `target` — the importer keeps `prev`/`next` on pagination links.
|
|
107
|
+
if (typeof node.rel === "string" && node.rel) (def as PortableTextMarkDef & { rel?: string }).rel = node.rel;
|
|
105
108
|
markDefs.push(def);
|
|
106
109
|
marks = [...inherited, def._key];
|
|
107
110
|
}
|
package/src/heading.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `richtext` block rendered AS a heading — `props.level` (1–6).
|
|
3
|
+
*
|
|
4
|
+
* Closes the gap between the two blocks that each held half of a title: `heading` has a level
|
|
5
|
+
* but a PLAIN `text` (the renderer escapes it, so `Equip Your Projects with <span
|
|
6
|
+
* class="text-yellow-500">ScrewFast</span>` loses its brand span), and `richtext` keeps the
|
|
7
|
+
* markup but had no level. With `level` set, the renderer emits `<hN>` around the rich copy
|
|
8
|
+
* instead of `<div class="bcms-text">`, so the marks survive AND the element is a real heading —
|
|
9
|
+
* the one site CSS sizes (`.bcms-page h1{font-size:…}` keys on the tag, not a class).
|
|
10
|
+
*
|
|
11
|
+
* WHY A LEVEL ON `richtext` RATHER THAN MARKS ON `heading.text`: `props.html` is already the one
|
|
12
|
+
* MARKUP slot every writer, override and coercion rule knows about (`MARKUP_PATH` in
|
|
13
|
+
* prop-value.ts, block-pass.ts and @bettercms-ai/ui/resolve). Making `heading.props.text` a
|
|
14
|
+
* Portable Text value would add a second markup slot, and every reader that does
|
|
15
|
+
* `String(p.text)` would print `[object Object]`. A number beside `html` touches nothing that
|
|
16
|
+
* reads `html`.
|
|
17
|
+
*
|
|
18
|
+
* ── The one rule both the validator and the renderer apply ──────────────────────────────────
|
|
19
|
+
* A heading holds PHRASING content only. `<h1><p>…</p><p>…</p></h1>` is not a heading, it is
|
|
20
|
+
* two paragraphs in a heading-shaped box, and it is what a naïve wrapper would emit for every
|
|
21
|
+
* editor that stores a lone line as `<p>…</p>`. So the value must reduce to ONE line of inline
|
|
22
|
+
* copy: a lone Portable Text text block, or html with at most a bare `<p>`/`<hN>` wrapper around
|
|
23
|
+
* inline markup. The write schema refuses anything else with a message naming the fix; the
|
|
24
|
+
* renderer, which also sees values no block schema checked (component overrides), falls back to
|
|
25
|
+
* the ordinary `<div class="bcms-text">` rather than emit invalid markup or drop copy.
|
|
26
|
+
*/
|
|
27
|
+
import { isPortableTextBlock, type PortableTextBlock } from "./types";
|
|
28
|
+
import { readStructuredContent } from "./read";
|
|
29
|
+
import { portableTextToHtml } from "./html";
|
|
30
|
+
|
|
31
|
+
export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The level a `richtext` block renders at, or null for "not a heading" — which is every block
|
|
35
|
+
* stored before this existed. No clamping: `heading` clamps because its level is always a
|
|
36
|
+
* heading, but here an absent/invalid level must mean the ordinary paragraph render, so a
|
|
37
|
+
* stray `0`, `null` or `""` cannot quietly turn body copy into an `<h1>`.
|
|
38
|
+
*/
|
|
39
|
+
export function richtextHeadingLevel(value: unknown): HeadingLevel | null {
|
|
40
|
+
if (value === null || value === undefined || value === "" || typeof value === "boolean") return null;
|
|
41
|
+
const n = Number(value);
|
|
42
|
+
return Number.isInteger(n) && n >= 1 && n <= 6 ? (n as HeadingLevel) : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Flow-content tags a heading may not contain. Phrasing tags (`span`, `a`, `strong`, `br`) pass. */
|
|
46
|
+
const BLOCK_TAG =
|
|
47
|
+
/<\/?(?:address|article|aside|blockquote|details|dialog|div|dl|dd|dt|fieldset|figcaption|figure|footer|form|h[1-6]|header|hr|li|main|nav|ol|p|pre|section|summary|table|tbody|td|tfoot|th|thead|tr|ul)\b/i;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* An ATTRIBUTE-LESS lone `<p>` or `<hN>`. With attributes it is not unwrapped: dropping a
|
|
51
|
+
* `<p class="lead">` would silently change the design, so that value is refused instead.
|
|
52
|
+
*/
|
|
53
|
+
const LONE_WRAPPER = /^<(p|h[1-6])>([\s\S]*)<\/\1>$/i;
|
|
54
|
+
|
|
55
|
+
/** `html` as the inner content of a heading, or null when it is not one line of inline copy. */
|
|
56
|
+
export function inlineHeadingHtml(html: string): string | null {
|
|
57
|
+
const m = LONE_WRAPPER.exec(html.trim());
|
|
58
|
+
const inner = m ? m[2]! : html;
|
|
59
|
+
return BLOCK_TAG.test(inner) ? null : inner;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The ONE Portable Text block a heading can be made of, or null. A list item, a quote, a table
|
|
64
|
+
* or a second block is not a heading; `normal` and an existing `h1`–`h6` style are (the level
|
|
65
|
+
* on the block, not the style in the value, decides the tag).
|
|
66
|
+
*/
|
|
67
|
+
export function loneHeadingBlock(content: unknown[]): PortableTextBlock | null {
|
|
68
|
+
if (content.length !== 1) return null;
|
|
69
|
+
const node = content[0];
|
|
70
|
+
if (!isPortableTextBlock(node) || node.listItem !== undefined) return null;
|
|
71
|
+
const style = node.style ?? "normal";
|
|
72
|
+
return style === "normal" || /^h[1-6]$/.test(style) ? node : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** True when these richtext props can render as a heading. What the write schema enforces. */
|
|
76
|
+
export function canRenderAsHeading(props: Record<string, unknown>): boolean {
|
|
77
|
+
const structured = readStructuredContent(props);
|
|
78
|
+
if (structured) return loneHeadingBlock(structured) !== null;
|
|
79
|
+
return inlineHeadingHtml(typeof props.html === "string" ? props.html : "") !== null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The level the VALUE spells for itself — a lone `<h3>` wrapper, or a lone `h3`-styled Portable
|
|
84
|
+
* Text block — or null when it names none (a `<p>`, bare inline copy, `normal`).
|
|
85
|
+
*
|
|
86
|
+
* `props.level` decides the tag, so a value that names a DIFFERENT one is a contradiction the
|
|
87
|
+
* write schema refuses rather than silently overriding: `{ html: "<h3>T</h3>", level: 1 }` would
|
|
88
|
+
* store as an h3 and render as an h1, and the editor's block-style control reads the VALUE.
|
|
89
|
+
* Same class of defect as `headingLevelMismatch` in publish-static.ts, one layer up.
|
|
90
|
+
*/
|
|
91
|
+
export function valueHeadingLevel(props: Record<string, unknown>): HeadingLevel | null {
|
|
92
|
+
const structured = readStructuredContent(props);
|
|
93
|
+
const tag = structured
|
|
94
|
+
? loneHeadingBlock(structured)?.style
|
|
95
|
+
: LONE_WRAPPER.exec(String(props.html ?? "").trim())?.[1];
|
|
96
|
+
return tag && /^h[1-6]$/i.test(tag) ? (Number(tag[1]) as HeadingLevel) : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The inner markup + binding ADDRESS of a levelled richtext value, or null when the value is
|
|
101
|
+
* not one line of inline copy (the caller then renders it as ordinary rich text).
|
|
102
|
+
*
|
|
103
|
+
* Shared by all three renderers — render-page.ts, @bettercms-ai/next and @bettercms-ai/astro —
|
|
104
|
+
* because a heading assembled once per lane is exactly the divergence `parity.test.ts` exists to
|
|
105
|
+
* catch: three green suites and one lane shipping a `<div>` where the others ship an `<hN>`.
|
|
106
|
+
* The lanes differ only in how they SPELL attributes (a string, a React prop, an Astro spread),
|
|
107
|
+
* so the address comes back raw and each lane formats it the way it already does.
|
|
108
|
+
*
|
|
109
|
+
* The binding is the one each lane already emits, moved from the wrapper onto the heading so no
|
|
110
|
+
* `<div>` lands inside an `<hN>`: the html lane binds the whole field (`<blockId>__html`); the
|
|
111
|
+
* Portable Text lane binds the lone block by its `_key` (`<base>#<_key>`), exactly as
|
|
112
|
+
* `portableTextToHtml({ bindingBase })` addresses it — so the editor's writer is unchanged.
|
|
113
|
+
*/
|
|
114
|
+
export function headingParts(
|
|
115
|
+
/** Any block's props — `{ html?, content? }`, as loose as the three lanes' own types are. */
|
|
116
|
+
props: { html?: unknown },
|
|
117
|
+
base: string | null,
|
|
118
|
+
): { inner: string; address: string | null } | null {
|
|
119
|
+
const structured = readStructuredContent(props);
|
|
120
|
+
if (structured) {
|
|
121
|
+
const node = loneHeadingBlock(structured);
|
|
122
|
+
if (!node) return null;
|
|
123
|
+
// Style normalised to `normal` so the serialiser emits a lone <p> that `inline` unwraps; the
|
|
124
|
+
// block's LEVEL, not any h1–h6 style inside the value, decides the tag.
|
|
125
|
+
return {
|
|
126
|
+
inner: portableTextToHtml([{ ...node, style: "normal" }], { inline: true }),
|
|
127
|
+
address: base && node._key ? `${base}#${node._key}` : null,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const inner = inlineHeadingHtml(String(props.html ?? ""));
|
|
131
|
+
return inner === null ? null : { inner, address: base };
|
|
132
|
+
}
|
package/src/href.ts
CHANGED
|
@@ -11,6 +11,12 @@ const SAFE_HREF_SCHEMES = ["http:", "https:", "mailto:", "tel:"];
|
|
|
11
11
|
/** The href if it is safe to emit, otherwise null. Callers render the label as plain text. */
|
|
12
12
|
export function safeHref(raw: string): string | null {
|
|
13
13
|
const trimmed = raw.trim();
|
|
14
|
+
// An in-page FRAGMENT (`#where-it-comes-from`) can only move within the page, so it is as safe
|
|
15
|
+
// as a site-relative path. Refusing it cost every imported Contents list its links: the
|
|
16
|
+
// importer's Lexical anchors were dropped on conversion, on ingest and on render alike
|
|
17
|
+
// (astro-sumi, 2026-09-14). No whitespace or markup characters, so it cannot break out of the
|
|
18
|
+
// attribute it is written into.
|
|
19
|
+
if (trimmed.startsWith("#")) return /^#[^\s<>"'`]*$/.test(trimmed) ? trimmed : null;
|
|
14
20
|
// A site-relative path has no scheme to check and cannot navigate off-origin.
|
|
15
21
|
if (trimmed.startsWith("/") && !trimmed.startsWith("//")) return trimmed;
|
|
16
22
|
try {
|
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
|
|
@@ -102,7 +234,29 @@ export interface RenderOptions {
|
|
|
102
234
|
bindingBase?: string;
|
|
103
235
|
}
|
|
104
236
|
|
|
105
|
-
|
|
237
|
+
/**
|
|
238
|
+
* A tag override per style/list, for the tags the caller gave a class — and NOTHING else.
|
|
239
|
+
*
|
|
240
|
+
* Only the dressed tags are overridden, so a call with no classes renders through the library's
|
|
241
|
+
* own components byte for byte. That is what keeps every already-published rendition stable:
|
|
242
|
+
* the html on an envelope is compared against stored markup by the parity gate.
|
|
243
|
+
*/
|
|
244
|
+
const tagOverrides = <T extends string>(
|
|
245
|
+
classes: HtmlClasses,
|
|
246
|
+
tags: Record<T, ClassTag>,
|
|
247
|
+
): Record<string, (p: { children?: string }) => string> => {
|
|
248
|
+
const out: Record<string, (p: { children?: string }) => string> = {};
|
|
249
|
+
for (const [name, tag] of Object.entries(tags) as [string, ClassTag][]) {
|
|
250
|
+
if (!classes[tag]) continue;
|
|
251
|
+
out[name] = ({ children }) => `<${tag}${cls(classes, tag)}>${children ?? ""}</${tag}>`;
|
|
252
|
+
}
|
|
253
|
+
return out;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const buildComponents = (classes: HtmlClasses = {}): Partial<PortableTextHtmlComponents> => ({
|
|
257
|
+
block: tagOverrides(classes, STYLE_TAGS),
|
|
258
|
+
list: tagOverrides(classes, { bullet: "ul", number: "ol", check: "ul" }),
|
|
259
|
+
listItem: tagOverrides(classes, { bullet: "li", number: "li", check: "li" }),
|
|
106
260
|
types: {
|
|
107
261
|
[PT_TYPES.divider]: () => "<hr>",
|
|
108
262
|
[PT_TYPES.table]: ({ value }) => tableHtml(value as PortableTextTable),
|
|
@@ -113,14 +267,22 @@ const components: Partial<PortableTextHtmlComponents> = {
|
|
|
113
267
|
const cls = lang ? ` class="language-${lang}"` : "";
|
|
114
268
|
return `<pre><code${cls}>${esc(code)}</code></pre>`;
|
|
115
269
|
},
|
|
270
|
+
[PT_TYPES.video]: ({ value }) => videoHtml(value, classes),
|
|
271
|
+
[PT_TYPES.file]: ({ value }) => fileHtml(value, classes),
|
|
116
272
|
/**
|
|
117
|
-
* A generic embedded block.
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
273
|
+
* A generic embedded block. A block whose payload this serialiser can READ renders as
|
|
274
|
+
* content; one carrying a reference it would have to resolve against a project renders as a
|
|
275
|
+
* marker, which keeps the node addressable and visible instead of drawing nothing.
|
|
276
|
+
*
|
|
277
|
+
* 🔴 AN IMAGE AND AN EMBED ARE READABLE, and rendering them as markers was silent loss: an
|
|
278
|
+
* imported article's illustrations and its YouTube embed disappeared from every delivered
|
|
279
|
+
* rendition while the stored value still held them. Their payload is a URL — there is
|
|
280
|
+
* nothing to resolve.
|
|
121
281
|
*/
|
|
122
282
|
[PT_TYPES.block]: ({ value }) => {
|
|
123
283
|
const v = value as { schemaKey?: unknown };
|
|
284
|
+
if (v.schemaKey === BUILTIN_IMAGE_KEY) return imageHtml(value, classes);
|
|
285
|
+
if (v.schemaKey === BUILTIN_EMBED_KEY) return embedHtml(value, classes);
|
|
124
286
|
/**
|
|
125
287
|
* A pre-P1c TABLE is the exception to the marker, and it has to be: it resolves against
|
|
126
288
|
* nothing. Its rows are text, held right here, so a marker would DELETE visible content.
|
|
@@ -150,11 +312,21 @@ const components: Partial<PortableTextHtmlComponents> = {
|
|
|
150
312
|
* renders the LABEL rather than deleting it — a blocked link must not lose the words.
|
|
151
313
|
*/
|
|
152
314
|
[PT_TYPES.link]: ({ value, children }) => {
|
|
153
|
-
const v = (value ?? {}) as { href?: unknown; target?: unknown };
|
|
315
|
+
const v = (value ?? {}) as { href?: unknown; target?: unknown; rel?: unknown };
|
|
154
316
|
const href = typeof v.href === "string" ? safeHref(v.href) : null;
|
|
155
317
|
if (!href) return children;
|
|
156
|
-
|
|
157
|
-
|
|
318
|
+
// `rel` is author data too (the importer keeps `prev`/`next` on pagination links), so only
|
|
319
|
+
// plain link-type tokens pass; a new tab always adds noopener + noreferrer.
|
|
320
|
+
const rel = new Set(
|
|
321
|
+
typeof v.rel === "string" ? v.rel.toLowerCase().split(/\s+/).filter((t) => /^[a-z][a-z-]*$/.test(t)) : [],
|
|
322
|
+
);
|
|
323
|
+
const target = v.target === "_blank" ? ' target="_blank"' : "";
|
|
324
|
+
if (target) {
|
|
325
|
+
rel.add("noopener");
|
|
326
|
+
rel.add("noreferrer");
|
|
327
|
+
}
|
|
328
|
+
const relAttr = rel.size ? ` rel="${esc([...rel].join(" "))}"` : "";
|
|
329
|
+
return `<a${cls(classes, "a")} href="${esc(href)}"${target}${relAttr}>${children}</a>`;
|
|
158
330
|
},
|
|
159
331
|
/**
|
|
160
332
|
* The site's own inline markup, written back out as itself.
|
|
@@ -185,13 +357,17 @@ const components: Partial<PortableTextHtmlComponents> = {
|
|
|
185
357
|
*/
|
|
186
358
|
unknownMark: ({ children }) => children,
|
|
187
359
|
unknownType: ({ children }) => children ?? "",
|
|
188
|
-
};
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
/** The undressed components, built once: the overwhelming majority of calls pass no classes. */
|
|
363
|
+
const defaultComponents = buildComponents();
|
|
189
364
|
|
|
190
365
|
export function portableTextToHtml(
|
|
191
366
|
value: PortableText | null | undefined,
|
|
192
367
|
opts: RenderOptions = {},
|
|
193
368
|
): string {
|
|
194
369
|
if (!Array.isArray(value) || value.length === 0) return "";
|
|
370
|
+
const components = opts.classes ? buildComponents(opts.classes) : defaultComponents;
|
|
195
371
|
|
|
196
372
|
if (opts.bindingBase) {
|
|
197
373
|
const base = esc(opts.bindingBase);
|
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
|
+
}
|