social-embeds 0.0.0 → 0.1.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/LICENSE.md +21 -0
- package/README.md +309 -0
- package/dist/index-CGSxjvGN.d.ts +27 -0
- package/dist/index.d.ts +155 -0
- package/dist/index.js +938 -0
- package/dist/parser-DVync2rz.js +747 -0
- package/dist/parser.d.ts +2 -0
- package/dist/parser.js +1 -0
- package/package.json +65 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { a as escapeAttribute, i as x$1, n as matchUrl, o as parseHtml, r as platforms, s as rebuild, t as parseEmbed } from "./parser-DVync2rz.js";
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
// src/loader/load-script.ts
|
|
6
|
+
const ATTEMPTS$1 = 3;
|
|
7
|
+
const RETRY_DELAY_MS$1 = 1e3;
|
|
8
|
+
const TIMEOUT_MS = 15e3;
|
|
9
|
+
const POLL_MS = 50;
|
|
10
|
+
const loading = /* @__PURE__ */ new Map();
|
|
11
|
+
function loadScript(script) {
|
|
12
|
+
if (typeof document === "undefined") return Promise.reject(/* @__PURE__ */ new Error("social-embeds: platform scripts load only in the browser"));
|
|
13
|
+
let promise = loading.get(script.key);
|
|
14
|
+
if (!promise) {
|
|
15
|
+
promise = load(script).catch((error) => {
|
|
16
|
+
loading.delete(script.key);
|
|
17
|
+
throw error;
|
|
18
|
+
});
|
|
19
|
+
loading.set(script.key, promise);
|
|
20
|
+
}
|
|
21
|
+
return promise;
|
|
22
|
+
}
|
|
23
|
+
async function load(script) {
|
|
24
|
+
if (!script.isPresent()) await ([...document.querySelectorAll("script[src]")].some((element) => script.isOwn(element.src)) ? poll(script) : add(script));
|
|
25
|
+
await script.whenPresent?.();
|
|
26
|
+
}
|
|
27
|
+
/** The browser reported the script as failed; its tag has been removed. */
|
|
28
|
+
var LoadError = class extends Error {};
|
|
29
|
+
async function add(script) {
|
|
30
|
+
const ready = script.readyCallback?.();
|
|
31
|
+
for (let attempt = 1;; attempt++) try {
|
|
32
|
+
await insert(script);
|
|
33
|
+
break;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (!(error instanceof LoadError) || attempt >= ATTEMPTS$1) throw error;
|
|
36
|
+
await delay(RETRY_DELAY_MS$1 * 2 ** (attempt - 1));
|
|
37
|
+
}
|
|
38
|
+
await (ready ? timeout(ready, `${script.url} did not report ready`) : poll(script));
|
|
39
|
+
}
|
|
40
|
+
async function insert(script) {
|
|
41
|
+
const restore = script.prepare?.();
|
|
42
|
+
const loaded = new Promise((resolve, reject) => {
|
|
43
|
+
const element = document.createElement("script");
|
|
44
|
+
element.src = script.url;
|
|
45
|
+
element.async = true;
|
|
46
|
+
if (script.crossOrigin) element.crossOrigin = script.crossOrigin;
|
|
47
|
+
element.addEventListener("load", () => {
|
|
48
|
+
resolve();
|
|
49
|
+
});
|
|
50
|
+
element.addEventListener("error", () => {
|
|
51
|
+
element.remove();
|
|
52
|
+
reject(new LoadError(`social-embeds: could not load ${script.url}`));
|
|
53
|
+
});
|
|
54
|
+
document.head.appendChild(element);
|
|
55
|
+
});
|
|
56
|
+
try {
|
|
57
|
+
await timeout(loaded, `${script.url} did not load`);
|
|
58
|
+
} finally {
|
|
59
|
+
restore?.();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function poll(script) {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const deadline = Date.now() + TIMEOUT_MS;
|
|
65
|
+
const check = () => {
|
|
66
|
+
if (script.isPresent()) resolve();
|
|
67
|
+
else if (Date.now() >= deadline) reject(/* @__PURE__ */ new Error(`social-embeds: ${script.url} did not initialise`));
|
|
68
|
+
else setTimeout(check, POLL_MS);
|
|
69
|
+
};
|
|
70
|
+
check();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function timeout(promise, message) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const timer = setTimeout(() => {
|
|
76
|
+
reject(/* @__PURE__ */ new Error(`social-embeds: ${message}`));
|
|
77
|
+
}, TIMEOUT_MS);
|
|
78
|
+
promise.then(() => {
|
|
79
|
+
clearTimeout(timer);
|
|
80
|
+
resolve();
|
|
81
|
+
}, (error) => {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function delay(ms) {
|
|
88
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
89
|
+
}
|
|
90
|
+
// src/loader/platform-scripts.ts
|
|
91
|
+
const globals$1 = () => window;
|
|
92
|
+
/**
|
|
93
|
+
* The SDK refuses to parse without a Graph API version. Social plugins render with old and
|
|
94
|
+
* new versions alike; this is the one Meta's Embedded Posts documentation uses.
|
|
95
|
+
*/
|
|
96
|
+
const FACEBOOK_SDK_VERSION = "v26.0";
|
|
97
|
+
const LOCALE = /^[a-z]{2,3}_[A-Z]{2}$/;
|
|
98
|
+
/** Instagram and Threads share `instgrm.Embeds`; each registers its name when loaded. */
|
|
99
|
+
function embedSdkRegistered(name) {
|
|
100
|
+
return () => {
|
|
101
|
+
const embeds = globals$1().instgrm?.Embeds;
|
|
102
|
+
return embeds?.registeredEmbedSDKs?.[name] === true && typeof embeds.process === "function";
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
const x = {
|
|
106
|
+
key: "x",
|
|
107
|
+
url: "https://platform.twitter.com/widgets.js",
|
|
108
|
+
isOwn: (src) => /^https?:\/\/platform\.(?:twitter|x)\.com\/widgets\.js(?:[?#]|$)/.test(src),
|
|
109
|
+
isPresent: () => typeof globals$1().twttr?.widgets?.load === "function",
|
|
110
|
+
whenPresent: () => new Promise((resolve) => {
|
|
111
|
+
const ready = globals$1().twttr?.ready;
|
|
112
|
+
if (ready) ready(() => {
|
|
113
|
+
resolve();
|
|
114
|
+
});
|
|
115
|
+
else resolve();
|
|
116
|
+
})
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Instagram's and Threads' embed.js do nothing when a loaded Facebook SDK is on the page:
|
|
120
|
+
* both start with `(window.FB && !window.FB.__buffer) || …`. The SDK's own stub carries
|
|
121
|
+
* `__buffer` while the SDK loads, so a loaded SDK is marked the same way while the script
|
|
122
|
+
* runs. Neither script reads `FB` anywhere else (docs/decisions.md D38).
|
|
123
|
+
*/
|
|
124
|
+
function besideFacebookSdk() {
|
|
125
|
+
const fb = globals$1().FB;
|
|
126
|
+
if (!fb || fb.__buffer) return () => {};
|
|
127
|
+
fb.__buffer = true;
|
|
128
|
+
return () => {
|
|
129
|
+
delete fb.__buffer;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const instagram = {
|
|
133
|
+
key: "instagram",
|
|
134
|
+
url: "https://www.instagram.com/embed.js",
|
|
135
|
+
isOwn: (src) => /^https?:\/\/www\.instagram\.com\/embed\.js(?:[?#]|$)/.test(src),
|
|
136
|
+
isPresent: embedSdkRegistered("instagram"),
|
|
137
|
+
prepare: besideFacebookSdk
|
|
138
|
+
};
|
|
139
|
+
const threads = {
|
|
140
|
+
key: "threads",
|
|
141
|
+
url: "https://www.threads.com/embed.js",
|
|
142
|
+
isOwn: (src) => /^https?:\/\/www\.threads\.(?:com|net)\/embed\.js(?:[?#]|$)/.test(src),
|
|
143
|
+
isPresent: embedSdkRegistered("threads"),
|
|
144
|
+
prepare: besideFacebookSdk
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* `xfbml=0` stops the SDK from scanning the whole page; embeds are parsed explicitly. One
|
|
148
|
+
* SDK serves the whole page, so the first locale requested wins.
|
|
149
|
+
*/
|
|
150
|
+
function facebook(locale) {
|
|
151
|
+
return {
|
|
152
|
+
key: "facebook",
|
|
153
|
+
url: `https://connect.facebook.net/${locale}/sdk.js#xfbml=0&version=${FACEBOOK_SDK_VERSION}`,
|
|
154
|
+
crossOrigin: "anonymous",
|
|
155
|
+
isOwn: (src) => /^https?:\/\/connect\.facebook\.net\/[a-z]{2,3}_[A-Z]{2}\/(?:sdk(?:\/debug)?|all)\.js(?:[?#]|$)/.test(src),
|
|
156
|
+
isPresent: () => typeof globals$1().FB?.XFBML?.parse === "function",
|
|
157
|
+
readyCallback: () => new Promise((resolve) => {
|
|
158
|
+
const previous = globals$1().fbAsyncInit;
|
|
159
|
+
globals$1().fbAsyncInit = () => {
|
|
160
|
+
previous?.();
|
|
161
|
+
resolve();
|
|
162
|
+
};
|
|
163
|
+
})
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function loadPlatformScript(platform, { locale = "en_US" } = {}) {
|
|
167
|
+
switch (platform) {
|
|
168
|
+
case "x": return loadScript(x);
|
|
169
|
+
case "instagram": return loadScript(instagram);
|
|
170
|
+
case "threads": return loadScript(threads);
|
|
171
|
+
case "facebook": return LOCALE.test(locale) ? loadScript(facebook(locale)) : Promise.reject(/* @__PURE__ */ new Error(`social-embeds: invalid Facebook locale "${locale}"`));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// src/components/drivers.ts
|
|
175
|
+
const globals = () => window;
|
|
176
|
+
/** Calls `done` once `rendered(container)` holds, checking after every DOM change. */
|
|
177
|
+
function watch(container, rendered, done) {
|
|
178
|
+
if (rendered(container)) {
|
|
179
|
+
done();
|
|
180
|
+
return () => {};
|
|
181
|
+
}
|
|
182
|
+
const observer = new MutationObserver(() => {
|
|
183
|
+
if (!rendered(container)) return;
|
|
184
|
+
observer.disconnect();
|
|
185
|
+
done();
|
|
186
|
+
});
|
|
187
|
+
observer.observe(container, {
|
|
188
|
+
subtree: true,
|
|
189
|
+
childList: true,
|
|
190
|
+
attributes: true,
|
|
191
|
+
attributeFilter: [
|
|
192
|
+
"class",
|
|
193
|
+
"style",
|
|
194
|
+
"height"
|
|
195
|
+
]
|
|
196
|
+
});
|
|
197
|
+
return () => {
|
|
198
|
+
observer.disconnect();
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* widgets.js and embed.js render every blockquote with their class anywhere in the page, on
|
|
203
|
+
* load and on each call, so the components write the blockquote without it. The class is
|
|
204
|
+
* added once, when this embed renders; a platform that has since replaced it with its own
|
|
205
|
+
* (Threads' `text-post-media-registered`) keeps that (docs/decisions.md D39).
|
|
206
|
+
*/
|
|
207
|
+
function reveal(container, className) {
|
|
208
|
+
const blockquote = container.querySelector("blockquote");
|
|
209
|
+
if (blockquote && !blockquote.hasAttribute("class")) blockquote.classList.add(className);
|
|
210
|
+
}
|
|
211
|
+
/** An iframe in `container` that the platform has given a height. */
|
|
212
|
+
function hasSizedIframe(container) {
|
|
213
|
+
return [...container.querySelectorAll("iframe")].some((iframe) => Number.parseFloat(iframe.style.height) > 0);
|
|
214
|
+
}
|
|
215
|
+
const xDriver = {
|
|
216
|
+
load: () => loadPlatformScript("x"),
|
|
217
|
+
render(container, done) {
|
|
218
|
+
const twttr = globals().twttr;
|
|
219
|
+
const onRendered = (event) => {
|
|
220
|
+
if (event.target instanceof Node && container.contains(event.target)) done();
|
|
221
|
+
};
|
|
222
|
+
twttr?.events?.bind?.("rendered", onRendered);
|
|
223
|
+
const stop = watch(container, hasSizedIframe, done);
|
|
224
|
+
reveal(container, "twitter-tweet");
|
|
225
|
+
twttr?.widgets?.load?.(container);
|
|
226
|
+
return () => {
|
|
227
|
+
twttr?.events?.unbind?.("rendered", onRendered);
|
|
228
|
+
stop();
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
/** Instagram and Threads share `instgrm.Embeds.process()`, which renders both. */
|
|
233
|
+
function processEmbeds() {
|
|
234
|
+
const process = globals().instgrm?.Embeds?.process;
|
|
235
|
+
if (!process) throw new Error("social-embeds: instgrm.Embeds.process is missing");
|
|
236
|
+
process();
|
|
237
|
+
}
|
|
238
|
+
const instagramDriver = {
|
|
239
|
+
load: () => loadPlatformScript("instagram"),
|
|
240
|
+
render(container, done) {
|
|
241
|
+
const stop = watch(container, (element) => element.querySelector("iframe.instagram-media-rendered") !== null, done);
|
|
242
|
+
reveal(container, "instagram-media");
|
|
243
|
+
processEmbeds();
|
|
244
|
+
return stop;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
const threadsDriver = {
|
|
248
|
+
load: () => loadPlatformScript("threads"),
|
|
249
|
+
render(container, done) {
|
|
250
|
+
const stop = watch(container, (element) => element.querySelector("iframe") !== null && element.querySelector("blockquote") === null, done);
|
|
251
|
+
reveal(container, "text-post-media");
|
|
252
|
+
processEmbeds();
|
|
253
|
+
return stop;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
const facebookDrivers = /* @__PURE__ */ new Map();
|
|
257
|
+
function facebookDriver(locale) {
|
|
258
|
+
let driver = facebookDrivers.get(locale);
|
|
259
|
+
if (!driver) {
|
|
260
|
+
driver = {
|
|
261
|
+
load: () => loadPlatformScript("facebook", { locale }),
|
|
262
|
+
render(container, done) {
|
|
263
|
+
const fb = globals().FB;
|
|
264
|
+
const parse = fb?.XFBML?.parse;
|
|
265
|
+
if (!fb || !parse) throw new Error("social-embeds: FB.XFBML.parse is missing");
|
|
266
|
+
const run = () => {
|
|
267
|
+
parse(container, done);
|
|
268
|
+
};
|
|
269
|
+
try {
|
|
270
|
+
run();
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (!(error instanceof Error) || !/init not called/.test(error.message)) throw error;
|
|
273
|
+
fb.init?.({
|
|
274
|
+
xfbml: false,
|
|
275
|
+
version: FACEBOOK_SDK_VERSION
|
|
276
|
+
});
|
|
277
|
+
run();
|
|
278
|
+
}
|
|
279
|
+
return () => {};
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
facebookDrivers.set(locale, driver);
|
|
283
|
+
}
|
|
284
|
+
return driver;
|
|
285
|
+
}
|
|
286
|
+
// src/components/hooks.ts
|
|
287
|
+
const ROOT_MARGIN = "200px 0px";
|
|
288
|
+
/**
|
|
289
|
+
* Whether the element has come near the viewport. Always false on the server and in the
|
|
290
|
+
* first client render, so server and client markup match; true right after mount when
|
|
291
|
+
* `lazy` is off or IntersectionObserver is unavailable.
|
|
292
|
+
*/
|
|
293
|
+
function useNearViewport(ref, lazy) {
|
|
294
|
+
const [near, setNear] = useState(false);
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
const element = ref.current;
|
|
297
|
+
if (!lazy || !element || typeof IntersectionObserver === "undefined") {
|
|
298
|
+
setNear(true);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const observer = new IntersectionObserver((entries) => {
|
|
302
|
+
if (entries.some((entry) => entry.isIntersecting)) {
|
|
303
|
+
setNear(true);
|
|
304
|
+
observer.disconnect();
|
|
305
|
+
}
|
|
306
|
+
}, { rootMargin: ROOT_MARGIN });
|
|
307
|
+
observer.observe(element);
|
|
308
|
+
return () => {
|
|
309
|
+
observer.disconnect();
|
|
310
|
+
};
|
|
311
|
+
}, [ref, lazy]);
|
|
312
|
+
return near;
|
|
313
|
+
}
|
|
314
|
+
/** Calls `onError` from an effect, once per distinct message. */
|
|
315
|
+
function useReportError(message, onError) {
|
|
316
|
+
const latest = useRef(onError);
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
latest.current = onError;
|
|
319
|
+
});
|
|
320
|
+
useEffect(() => {
|
|
321
|
+
if (message !== void 0) latest.current?.(new Error(message));
|
|
322
|
+
}, [message]);
|
|
323
|
+
}
|
|
324
|
+
// src/components/iframe-embed.tsx
|
|
325
|
+
const FILL = {
|
|
326
|
+
position: "absolute",
|
|
327
|
+
inset: 0
|
|
328
|
+
};
|
|
329
|
+
/**
|
|
330
|
+
* Group B: a responsive wrapper whose aspect ratio comes from the input, with the
|
|
331
|
+
* iframe filling it. The iframe is created on the client only, so its load event is
|
|
332
|
+
* never missed before hydration; the placeholder covers the box until then.
|
|
333
|
+
*/
|
|
334
|
+
function IframeEmbed({ src, title, allow, referrerPolicy, scrolling, ratio, size, width = "100%", maxWidth, height, placeholder, lazy = true, onLoad, style, ...rest }) {
|
|
335
|
+
const frame = useRef(null);
|
|
336
|
+
const near = useNearViewport(frame, lazy);
|
|
337
|
+
const [loaded, setLoaded] = useState(false);
|
|
338
|
+
const reported = useRef(false);
|
|
339
|
+
const aspectWidth = size?.width ?? ratio.width;
|
|
340
|
+
const aspectHeight = size?.height ?? ratio.height;
|
|
341
|
+
const wrapper = {
|
|
342
|
+
position: "relative",
|
|
343
|
+
width,
|
|
344
|
+
maxWidth,
|
|
345
|
+
...height === void 0 ? { aspectRatio: `${String(aspectWidth)} / ${String(aspectHeight)}` } : { height },
|
|
346
|
+
...style
|
|
347
|
+
};
|
|
348
|
+
return /* @__PURE__ */ jsx("div", {
|
|
349
|
+
...rest,
|
|
350
|
+
style: wrapper,
|
|
351
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
352
|
+
ref: frame,
|
|
353
|
+
style: FILL,
|
|
354
|
+
children: [near && /* @__PURE__ */ jsx("iframe", {
|
|
355
|
+
src,
|
|
356
|
+
title,
|
|
357
|
+
allow,
|
|
358
|
+
allowFullScreen: true,
|
|
359
|
+
referrerPolicy,
|
|
360
|
+
scrolling,
|
|
361
|
+
style: {
|
|
362
|
+
display: "block",
|
|
363
|
+
width: "100%",
|
|
364
|
+
height: "100%",
|
|
365
|
+
border: 0
|
|
366
|
+
},
|
|
367
|
+
onLoad: () => {
|
|
368
|
+
setLoaded(true);
|
|
369
|
+
if (reported.current) return;
|
|
370
|
+
reported.current = true;
|
|
371
|
+
onLoad?.();
|
|
372
|
+
}
|
|
373
|
+
}), !loaded && /* @__PURE__ */ jsx("div", {
|
|
374
|
+
style: FILL,
|
|
375
|
+
children: placeholder ?? /* @__PURE__ */ jsx(Skeleton, {})
|
|
376
|
+
})]
|
|
377
|
+
})
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
/** The default placeholder: a neutral box, readable on light and dark pages. */
|
|
381
|
+
function Skeleton({ minHeight }) {
|
|
382
|
+
return /* @__PURE__ */ jsx("div", {
|
|
383
|
+
"aria-hidden": "true",
|
|
384
|
+
style: {
|
|
385
|
+
width: "100%",
|
|
386
|
+
height: "100%",
|
|
387
|
+
minHeight,
|
|
388
|
+
background: "rgba(128, 128, 128, 0.12)"
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
// src/parser/verify.ts
|
|
393
|
+
/** The kind and variant pairs the parser produces for each platform. */
|
|
394
|
+
const SHAPES = {
|
|
395
|
+
x: ["post sdk"],
|
|
396
|
+
instagram: ["post sdk"],
|
|
397
|
+
threads: ["post sdk"],
|
|
398
|
+
facebook: [
|
|
399
|
+
"post sdk",
|
|
400
|
+
"video sdk",
|
|
401
|
+
"video iframe"
|
|
402
|
+
],
|
|
403
|
+
tiktok: ["video iframe"],
|
|
404
|
+
youtube: ["video iframe"]
|
|
405
|
+
};
|
|
406
|
+
/**
|
|
407
|
+
* Checks an embed handed to a component. It may not come from this parser: a host can build
|
|
408
|
+
* one, or read one back from storage.
|
|
409
|
+
*
|
|
410
|
+
* - Its URL must be one `matchUrl` recognises for the same platform and id, and its kind
|
|
411
|
+
* and variant a pair the parser produces, because components put the URL and id into
|
|
412
|
+
* iframe sources and platform markup.
|
|
413
|
+
* - Its `fallbackHtml` must be exactly what the platform's whitelist rebuilds, because
|
|
414
|
+
* components write it into the page as HTML. For X it must also link to the same post,
|
|
415
|
+
* because widgets.js renders the post the blockquote links to. Otherwise the fallback is
|
|
416
|
+
* dropped.
|
|
417
|
+
*/
|
|
418
|
+
function verifyEmbed(embed) {
|
|
419
|
+
const matched = matchUrl(embed.url);
|
|
420
|
+
if (matched?.platform !== embed.platform || matched.id !== embed.id) return null;
|
|
421
|
+
if (!SHAPES[embed.platform].includes(`${embed.kind} ${embed.variant}`)) return null;
|
|
422
|
+
if (embed.fallbackHtml === void 0) return embed;
|
|
423
|
+
if (trustedFallback(embed, embed.fallbackHtml)) return embed;
|
|
424
|
+
const withoutFallback = { ...embed };
|
|
425
|
+
delete withoutFallback.fallbackHtml;
|
|
426
|
+
return withoutFallback;
|
|
427
|
+
}
|
|
428
|
+
function trustedFallback(embed, fallbackHtml) {
|
|
429
|
+
const whitelist = platforms.find((platform) => platform.platform === embed.platform)?.fallback;
|
|
430
|
+
const nodes = parseHtml(fallbackHtml);
|
|
431
|
+
if (!whitelist || !nodes || rebuild(nodes, whitelist) !== fallbackHtml) return false;
|
|
432
|
+
if (embed.platform !== "x") return true;
|
|
433
|
+
const [root] = parseHtml(`<blockquote class="twitter-tweet">${fallbackHtml}</blockquote>`) ?? [];
|
|
434
|
+
return root?.type === "element" && x$1.parse(root)?.id === embed.id;
|
|
435
|
+
}
|
|
436
|
+
// src/components/shared.ts
|
|
437
|
+
const NAMES = {
|
|
438
|
+
x: "X",
|
|
439
|
+
instagram: "Instagram",
|
|
440
|
+
facebook: "Facebook",
|
|
441
|
+
threads: "Threads",
|
|
442
|
+
tiktok: "TikTok",
|
|
443
|
+
youtube: "YouTube"
|
|
444
|
+
};
|
|
445
|
+
/** The embed for `platform`, or a message saying why the input is not one. */
|
|
446
|
+
function resolveEmbed(url, given, platform) {
|
|
447
|
+
const embed = given ?? (url === void 0 ? null : matchUrl(url));
|
|
448
|
+
if (!embed) return { error: `social-embeds: not a ${NAMES[platform]} post URL: ${String(url)}` };
|
|
449
|
+
if (embed.platform !== platform) return { error: `social-embeds: expected a ${NAMES[platform]} embed, got ${NAMES[embed.platform]}` };
|
|
450
|
+
const verified = verifyEmbed(embed);
|
|
451
|
+
if (!verified) return { error: `social-embeds: the ${NAMES[platform]} embed's url and id do not match` };
|
|
452
|
+
return { embed: verified };
|
|
453
|
+
}
|
|
454
|
+
/** Serialises query parameters; later entries replace earlier ones with the same name. */
|
|
455
|
+
function query(...sources) {
|
|
456
|
+
const params = /* @__PURE__ */ new Map();
|
|
457
|
+
for (const source of sources) for (const [key, value] of source ?? []) params.set(key, String(value));
|
|
458
|
+
const pairs = [...params].map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
|
459
|
+
return pairs.length > 0 ? `?${pairs.join("&")}` : "";
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Maps option props to the platform's parameter names. Player parameters take 0 and 1,
|
|
463
|
+
* so booleans become numbers; undefined props are left out.
|
|
464
|
+
*/
|
|
465
|
+
function playerParams(props, names) {
|
|
466
|
+
const params = [];
|
|
467
|
+
for (const [prop, name] of Object.entries(names)) {
|
|
468
|
+
const value = props[prop];
|
|
469
|
+
if (typeof value === "boolean") params.push([name, value ? 1 : 0]);
|
|
470
|
+
else if (typeof value === "string" || typeof value === "number") params.push([name, value]);
|
|
471
|
+
}
|
|
472
|
+
return params;
|
|
473
|
+
}
|
|
474
|
+
// src/components/facebook-video-iframe.tsx
|
|
475
|
+
const ALLOW$2 = "autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share";
|
|
476
|
+
/**
|
|
477
|
+
* Facebook video from embed code: the iframe that came in (brief §5.4), rebuilt from the
|
|
478
|
+
* parsed href and parameters. Its content fills the iframe at any width, whatever its
|
|
479
|
+
* `width` parameter says (docs/decisions.md D29). `FacebookEmbed` routes here.
|
|
480
|
+
*/
|
|
481
|
+
function FacebookVideoIframe({ embed, ...frame }) {
|
|
482
|
+
const params = Object.entries(embed.options).filter(([key]) => key !== "href");
|
|
483
|
+
return /* @__PURE__ */ jsx(IframeEmbed, {
|
|
484
|
+
...frame,
|
|
485
|
+
src: `https://www.facebook.com/plugins/video.php${query([["href", embed.url]], params)}`,
|
|
486
|
+
title: "Facebook video player",
|
|
487
|
+
allow: ALLOW$2,
|
|
488
|
+
scrolling: "no",
|
|
489
|
+
ratio: {
|
|
490
|
+
width: 16,
|
|
491
|
+
height: 9
|
|
492
|
+
},
|
|
493
|
+
size: embed.size
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
// src/components/markup.ts
|
|
497
|
+
/** `name="value"` pairs for defined values; `true` becomes a valueless attribute. */
|
|
498
|
+
function attributes(entries) {
|
|
499
|
+
let html = "";
|
|
500
|
+
for (const [name, value] of Object.entries(entries)) {
|
|
501
|
+
if (value === void 0) continue;
|
|
502
|
+
html += value === true ? ` ${name}` : ` ${name}="${escapeAttribute(String(value))}"`;
|
|
503
|
+
}
|
|
504
|
+
return html;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Options written as the platform's `data-*` attributes, in the given order. Booleans are
|
|
508
|
+
* written as `"true"`/`"false"`, as platforms document them.
|
|
509
|
+
*/
|
|
510
|
+
function dataAttributes(names, options) {
|
|
511
|
+
const entries = {};
|
|
512
|
+
for (const [key, name] of Object.entries(names)) {
|
|
513
|
+
const value = options[key];
|
|
514
|
+
if (value !== void 0) entries[name] = String(value);
|
|
515
|
+
}
|
|
516
|
+
return attributes(entries);
|
|
517
|
+
}
|
|
518
|
+
/** Embed code options first; props the host passed replace them. */
|
|
519
|
+
function withProps(options, props) {
|
|
520
|
+
const merged = { ...options };
|
|
521
|
+
for (const [key, value] of Object.entries(props)) if (value !== void 0) merged[key] = value;
|
|
522
|
+
return merged;
|
|
523
|
+
}
|
|
524
|
+
function hasFallback(fallbackHtml) {
|
|
525
|
+
return fallbackHtml !== void 0 && fallbackHtml.trim() !== "";
|
|
526
|
+
}
|
|
527
|
+
// src/components/script-embed.tsx
|
|
528
|
+
const ATTEMPTS = 3;
|
|
529
|
+
const RENDER_TIMEOUT_MS = 15e3;
|
|
530
|
+
const RETRY_DELAY_MS = 1e3;
|
|
531
|
+
function toError(error) {
|
|
532
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Group A: the platform markup is written once into a container React never updates
|
|
536
|
+
* again, so the platform script can replace it in place (brief §6.3). Callers key this
|
|
537
|
+
* component by `markup`, so different markup gets a fresh container.
|
|
538
|
+
*/
|
|
539
|
+
function ScriptEmbed({ driver, markup, hasFallback, minHeight, label, width = "100%", maxWidth, placeholder, lazy = true, onLoad, onError, style, ...rest }) {
|
|
540
|
+
const container = useRef(null);
|
|
541
|
+
const near = useNearViewport(container, lazy);
|
|
542
|
+
const [rendered, setRendered] = useState(false);
|
|
543
|
+
const callbacks = useRef({
|
|
544
|
+
onLoad,
|
|
545
|
+
onError
|
|
546
|
+
});
|
|
547
|
+
useEffect(() => {
|
|
548
|
+
callbacks.current = {
|
|
549
|
+
onLoad,
|
|
550
|
+
onError
|
|
551
|
+
};
|
|
552
|
+
});
|
|
553
|
+
useEffect(() => {
|
|
554
|
+
const element = container.current;
|
|
555
|
+
if (!near || !element) return;
|
|
556
|
+
let cancelled = false;
|
|
557
|
+
let done = false;
|
|
558
|
+
let timer;
|
|
559
|
+
let stop = () => {};
|
|
560
|
+
const finish = () => {
|
|
561
|
+
if (cancelled || done) return;
|
|
562
|
+
done = true;
|
|
563
|
+
clearTimeout(timer);
|
|
564
|
+
stop();
|
|
565
|
+
setRendered(true);
|
|
566
|
+
callbacks.current.onLoad?.();
|
|
567
|
+
};
|
|
568
|
+
const retryOrFail = (number, error) => {
|
|
569
|
+
if (cancelled || done) return;
|
|
570
|
+
stop();
|
|
571
|
+
stop = () => {};
|
|
572
|
+
if (number >= ATTEMPTS) {
|
|
573
|
+
callbacks.current.onError?.(error);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
timer = setTimeout(() => {
|
|
577
|
+
attempt(number + 1);
|
|
578
|
+
}, RETRY_DELAY_MS * 2 ** (number - 1));
|
|
579
|
+
};
|
|
580
|
+
const attempt = (number) => {
|
|
581
|
+
if (cancelled || done) return;
|
|
582
|
+
try {
|
|
583
|
+
stop = driver.render(element, finish);
|
|
584
|
+
} catch (error) {
|
|
585
|
+
retryOrFail(number, toError(error));
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
timer = setTimeout(() => {
|
|
589
|
+
retryOrFail(number, /* @__PURE__ */ new Error(`social-embeds: the ${label} embed did not render`));
|
|
590
|
+
}, RENDER_TIMEOUT_MS);
|
|
591
|
+
};
|
|
592
|
+
driver.load().then(() => {
|
|
593
|
+
attempt(1);
|
|
594
|
+
}, (error) => {
|
|
595
|
+
if (!cancelled) callbacks.current.onError?.(toError(error));
|
|
596
|
+
});
|
|
597
|
+
return () => {
|
|
598
|
+
cancelled = true;
|
|
599
|
+
clearTimeout(timer);
|
|
600
|
+
stop();
|
|
601
|
+
};
|
|
602
|
+
}, [
|
|
603
|
+
near,
|
|
604
|
+
driver,
|
|
605
|
+
label
|
|
606
|
+
]);
|
|
607
|
+
const wrapper = {
|
|
608
|
+
width,
|
|
609
|
+
maxWidth,
|
|
610
|
+
...style
|
|
611
|
+
};
|
|
612
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
613
|
+
...rest,
|
|
614
|
+
style: wrapper,
|
|
615
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
616
|
+
ref: container,
|
|
617
|
+
suppressHydrationWarning: true,
|
|
618
|
+
dangerouslySetInnerHTML: { __html: markup }
|
|
619
|
+
}), !hasFallback && !rendered && (placeholder ?? /* @__PURE__ */ jsx(Skeleton, { minHeight }))]
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
// src/components/facebook-embed.tsx
|
|
623
|
+
const ATTRIBUTES$1 = {
|
|
624
|
+
width: "data-width",
|
|
625
|
+
showText: "data-show-text",
|
|
626
|
+
lazy: "data-lazy",
|
|
627
|
+
allowfullscreen: "data-allowfullscreen",
|
|
628
|
+
autoplay: "data-autoplay",
|
|
629
|
+
showCaptions: "data-show-captions"
|
|
630
|
+
};
|
|
631
|
+
/** Embedded Posts: "Max. 750 pixel"; videos have no upper limit (docs/decisions.md D33). */
|
|
632
|
+
const POST_MAX_WIDTH = 750;
|
|
633
|
+
/** docs/decisions.md D34. */
|
|
634
|
+
const MIN_HEIGHT$3 = {
|
|
635
|
+
post: 300,
|
|
636
|
+
video: 200
|
|
637
|
+
};
|
|
638
|
+
/**
|
|
639
|
+
* Facebook rendering rules (brief §5.4): posts always through the SDK; video embed code as
|
|
640
|
+
* the iframe that came in; video URLs through the SDK. The parser has set `variant`.
|
|
641
|
+
*/
|
|
642
|
+
function FacebookEmbed(props) {
|
|
643
|
+
const { url, embed: given, onError, locale, showText, allowfullscreen, autoplay, showCaptions, height, ...shared } = props;
|
|
644
|
+
const { embed, error } = resolveEmbed(url, given, "facebook");
|
|
645
|
+
useReportError(error, onError);
|
|
646
|
+
if (!embed) return null;
|
|
647
|
+
if (embed.variant === "iframe") return /* @__PURE__ */ jsx(FacebookVideoIframe, {
|
|
648
|
+
...shared,
|
|
649
|
+
height,
|
|
650
|
+
embed
|
|
651
|
+
});
|
|
652
|
+
const options = withProps(embed.options, {
|
|
653
|
+
showText,
|
|
654
|
+
allowfullscreen,
|
|
655
|
+
autoplay,
|
|
656
|
+
showCaptions
|
|
657
|
+
});
|
|
658
|
+
const sdkLocale = locale ?? (typeof options.locale === "string" ? options.locale : "en_US");
|
|
659
|
+
const markup = `<div class="${embed.kind === "video" ? "fb-video" : "fb-post"}" data-href="${escapeAttribute(embed.url)}"${dataAttributes(ATTRIBUTES$1, options)}>${embed.fallbackHtml ?? ""}</div>`;
|
|
660
|
+
return /* @__PURE__ */ jsx(ScriptEmbed, {
|
|
661
|
+
maxWidth: embed.kind === "post" ? POST_MAX_WIDTH : void 0,
|
|
662
|
+
...shared,
|
|
663
|
+
onError,
|
|
664
|
+
driver: facebookDriver(sdkLocale),
|
|
665
|
+
markup,
|
|
666
|
+
hasFallback: hasFallback(embed.fallbackHtml),
|
|
667
|
+
minHeight: MIN_HEIGHT$3[embed.kind],
|
|
668
|
+
label: "Facebook"
|
|
669
|
+
}, `${sdkLocale} ${markup}`);
|
|
670
|
+
}
|
|
671
|
+
// src/components/instagram-embed.tsx
|
|
672
|
+
/** A photo post without caption at 375px, the smallest measured (docs/decisions.md D34). */
|
|
673
|
+
const MIN_HEIGHT$2 = 680;
|
|
674
|
+
/**
|
|
675
|
+
* embed.js copies the blockquote's inline style onto its iframe and gives the iframe no
|
|
676
|
+
* width of its own, so without this the post stays 326px wide. The value is the one in
|
|
677
|
+
* Instagram's own embed code (docs/decisions.md D33).
|
|
678
|
+
*/
|
|
679
|
+
const FLUID_WIDTH = "width:calc(100% - 2px)";
|
|
680
|
+
function InstagramEmbed(props) {
|
|
681
|
+
const { url, embed: given, onError, maxWidth = 540, captioned, ...shared } = props;
|
|
682
|
+
const { embed, error } = resolveEmbed(url, given, "instagram");
|
|
683
|
+
useReportError(error, onError);
|
|
684
|
+
if (!embed) return null;
|
|
685
|
+
const options = withProps(embed.options, { captioned });
|
|
686
|
+
const markup = `<blockquote${attributes({
|
|
687
|
+
"data-instgrm-permalink": embed.url,
|
|
688
|
+
"data-instgrm-version": options.version,
|
|
689
|
+
"data-instgrm-captioned": options.captioned === true ? true : void 0,
|
|
690
|
+
style: FLUID_WIDTH
|
|
691
|
+
})}>${embed.fallbackHtml ?? ""}</blockquote>`;
|
|
692
|
+
return /* @__PURE__ */ jsx(ScriptEmbed, {
|
|
693
|
+
...shared,
|
|
694
|
+
onError,
|
|
695
|
+
maxWidth,
|
|
696
|
+
driver: instagramDriver,
|
|
697
|
+
markup,
|
|
698
|
+
hasFallback: hasFallback(embed.fallbackHtml),
|
|
699
|
+
minHeight: MIN_HEIGHT$2,
|
|
700
|
+
label: "Instagram"
|
|
701
|
+
}, markup);
|
|
702
|
+
}
|
|
703
|
+
// src/components/threads-embed.tsx
|
|
704
|
+
/** A text-only post (docs/decisions.md D34). */
|
|
705
|
+
const MIN_HEIGHT$1 = 240;
|
|
706
|
+
function ThreadsEmbed(props) {
|
|
707
|
+
const { url, embed: given, onError, maxWidth = 658, theme, ...shared } = props;
|
|
708
|
+
const { embed, error } = resolveEmbed(url, given, "threads");
|
|
709
|
+
useReportError(error, onError);
|
|
710
|
+
if (!embed) return null;
|
|
711
|
+
const options = withProps(embed.options, { theme });
|
|
712
|
+
const markup = `<blockquote${attributes({
|
|
713
|
+
"data-text-post-permalink": embed.url,
|
|
714
|
+
"data-text-post-version": options.version ?? 0,
|
|
715
|
+
id: `ig-tp-${embed.id}`,
|
|
716
|
+
"data-theme": options.theme
|
|
717
|
+
})}>${embed.fallbackHtml ?? ""}</blockquote>`;
|
|
718
|
+
return /* @__PURE__ */ jsx(ScriptEmbed, {
|
|
719
|
+
...shared,
|
|
720
|
+
onError,
|
|
721
|
+
maxWidth,
|
|
722
|
+
driver: threadsDriver,
|
|
723
|
+
markup,
|
|
724
|
+
hasFallback: hasFallback(embed.fallbackHtml),
|
|
725
|
+
minHeight: MIN_HEIGHT$1,
|
|
726
|
+
label: "Threads"
|
|
727
|
+
}, markup);
|
|
728
|
+
}
|
|
729
|
+
// src/components/tiktok-embed.tsx
|
|
730
|
+
const PARAMS$1 = {
|
|
731
|
+
controls: "controls",
|
|
732
|
+
progressBar: "progress_bar",
|
|
733
|
+
playButton: "play_button",
|
|
734
|
+
volumeControl: "volume_control",
|
|
735
|
+
fullscreenButton: "fullscreen_button",
|
|
736
|
+
timestamp: "timestamp",
|
|
737
|
+
loop: "loop",
|
|
738
|
+
autoplay: "autoplay",
|
|
739
|
+
musicInfo: "music_info",
|
|
740
|
+
description: "description",
|
|
741
|
+
rel: "rel",
|
|
742
|
+
nativeContextMenu: "native_context_menu",
|
|
743
|
+
closedCaption: "closed_caption",
|
|
744
|
+
forceMuted: "force_muted"
|
|
745
|
+
};
|
|
746
|
+
const ALLOW$1 = "accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; gyroscope; picture-in-picture; web-share";
|
|
747
|
+
/**
|
|
748
|
+
* Every TikTok input renders the Embed Player iframe (docs/decisions.md D11). It is at
|
|
749
|
+
* most 325px wide, the size of TikTok's own embed code (D40).
|
|
750
|
+
*/
|
|
751
|
+
function TikTokEmbed(props) {
|
|
752
|
+
const { url, embed: given, onError, maxWidth = 325, controls, progressBar, playButton, volumeControl, fullscreenButton, timestamp, loop, autoplay, musicInfo, description, rel, nativeContextMenu, closedCaption, forceMuted, ...frame } = props;
|
|
753
|
+
const { embed, error } = resolveEmbed(url, given, "tiktok");
|
|
754
|
+
useReportError(error, onError);
|
|
755
|
+
if (!embed) return null;
|
|
756
|
+
const params = playerParams({
|
|
757
|
+
controls,
|
|
758
|
+
progressBar,
|
|
759
|
+
playButton,
|
|
760
|
+
volumeControl,
|
|
761
|
+
fullscreenButton,
|
|
762
|
+
timestamp,
|
|
763
|
+
loop,
|
|
764
|
+
autoplay,
|
|
765
|
+
musicInfo,
|
|
766
|
+
description,
|
|
767
|
+
rel,
|
|
768
|
+
nativeContextMenu,
|
|
769
|
+
closedCaption,
|
|
770
|
+
forceMuted
|
|
771
|
+
}, PARAMS$1);
|
|
772
|
+
return /* @__PURE__ */ jsx(IframeEmbed, {
|
|
773
|
+
...frame,
|
|
774
|
+
maxWidth,
|
|
775
|
+
src: `https://www.tiktok.com/player/v1/${embed.id}${query(Object.entries(embed.options), params)}`,
|
|
776
|
+
title: "TikTok video player",
|
|
777
|
+
allow: ALLOW$1,
|
|
778
|
+
ratio: {
|
|
779
|
+
width: 9,
|
|
780
|
+
height: 16
|
|
781
|
+
},
|
|
782
|
+
size: embed.size
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
// src/components/x-embed.tsx
|
|
786
|
+
const ATTRIBUTES = {
|
|
787
|
+
theme: "data-theme",
|
|
788
|
+
conversation: "data-conversation",
|
|
789
|
+
cards: "data-cards",
|
|
790
|
+
align: "data-align",
|
|
791
|
+
lang: "data-lang",
|
|
792
|
+
dnt: "data-dnt",
|
|
793
|
+
width: "data-width"
|
|
794
|
+
};
|
|
795
|
+
/** A plain text post, the smallest X renders (docs/decisions.md D34). */
|
|
796
|
+
const MIN_HEIGHT = 250;
|
|
797
|
+
function XEmbed(props) {
|
|
798
|
+
const { url, embed: given, onError, maxWidth = 550, theme, conversation, cards, align, lang, dnt, ...shared } = props;
|
|
799
|
+
const { embed, error } = resolveEmbed(url, given, "x");
|
|
800
|
+
useReportError(error, onError);
|
|
801
|
+
if (!embed) return null;
|
|
802
|
+
const options = withProps(embed.options, {
|
|
803
|
+
theme,
|
|
804
|
+
conversation,
|
|
805
|
+
cards,
|
|
806
|
+
align,
|
|
807
|
+
lang,
|
|
808
|
+
dnt
|
|
809
|
+
});
|
|
810
|
+
const content = embed.fallbackHtml ?? `<a href="${escapeAttribute(embed.url)}"></a>`;
|
|
811
|
+
const markup = `<blockquote${dataAttributes(ATTRIBUTES, options)}>${content}</blockquote>`;
|
|
812
|
+
return /* @__PURE__ */ jsx(ScriptEmbed, {
|
|
813
|
+
...shared,
|
|
814
|
+
onError,
|
|
815
|
+
maxWidth,
|
|
816
|
+
driver: xDriver,
|
|
817
|
+
markup,
|
|
818
|
+
hasFallback: hasFallback(embed.fallbackHtml),
|
|
819
|
+
minHeight: MIN_HEIGHT,
|
|
820
|
+
label: "X"
|
|
821
|
+
}, markup);
|
|
822
|
+
}
|
|
823
|
+
// src/components/youtube-embed.tsx
|
|
824
|
+
const PARAMS = {
|
|
825
|
+
autoplay: "autoplay",
|
|
826
|
+
ccLangPref: "cc_lang_pref",
|
|
827
|
+
ccLoadPolicy: "cc_load_policy",
|
|
828
|
+
color: "color",
|
|
829
|
+
controls: "controls",
|
|
830
|
+
disablekb: "disablekb",
|
|
831
|
+
enablejsapi: "enablejsapi",
|
|
832
|
+
end: "end",
|
|
833
|
+
fs: "fs",
|
|
834
|
+
hl: "hl",
|
|
835
|
+
ivLoadPolicy: "iv_load_policy",
|
|
836
|
+
list: "list",
|
|
837
|
+
listType: "listType",
|
|
838
|
+
loop: "loop",
|
|
839
|
+
origin: "origin",
|
|
840
|
+
playlist: "playlist",
|
|
841
|
+
playsinline: "playsinline",
|
|
842
|
+
rel: "rel",
|
|
843
|
+
start: "start",
|
|
844
|
+
widgetReferrer: "widget_referrer"
|
|
845
|
+
};
|
|
846
|
+
const ALLOW = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share";
|
|
847
|
+
function YouTubeEmbed(props) {
|
|
848
|
+
const { url, embed: given, onError, autoplay, ccLangPref, ccLoadPolicy, color, controls, disablekb, enablejsapi, end, fs, hl, ivLoadPolicy, list, listType, loop, origin, playlist, playsinline, rel, start, widgetReferrer, nocookie, ...frame } = props;
|
|
849
|
+
const { embed, error } = resolveEmbed(url, given, "youtube");
|
|
850
|
+
useReportError(error, onError);
|
|
851
|
+
if (!embed) return null;
|
|
852
|
+
const { nocookie: parsedNocookie, ...parsed } = embed.options;
|
|
853
|
+
const host = nocookie ?? parsedNocookie === true ? "https://www.youtube-nocookie.com" : "https://www.youtube.com";
|
|
854
|
+
const params = playerParams({
|
|
855
|
+
autoplay,
|
|
856
|
+
ccLangPref,
|
|
857
|
+
ccLoadPolicy,
|
|
858
|
+
color,
|
|
859
|
+
controls,
|
|
860
|
+
disablekb,
|
|
861
|
+
enablejsapi,
|
|
862
|
+
end,
|
|
863
|
+
fs,
|
|
864
|
+
hl,
|
|
865
|
+
ivLoadPolicy,
|
|
866
|
+
list,
|
|
867
|
+
listType,
|
|
868
|
+
loop,
|
|
869
|
+
origin,
|
|
870
|
+
playlist,
|
|
871
|
+
playsinline,
|
|
872
|
+
rel,
|
|
873
|
+
start,
|
|
874
|
+
widgetReferrer
|
|
875
|
+
}, PARAMS);
|
|
876
|
+
return /* @__PURE__ */ jsx(IframeEmbed, {
|
|
877
|
+
...frame,
|
|
878
|
+
src: `${host}/embed/${embed.id}${query(Object.entries(parsed), params)}`,
|
|
879
|
+
title: "YouTube video player",
|
|
880
|
+
allow: ALLOW,
|
|
881
|
+
referrerPolicy: "strict-origin-when-cross-origin",
|
|
882
|
+
ratio: embed.url.includes("/shorts/") ? {
|
|
883
|
+
width: 9,
|
|
884
|
+
height: 16
|
|
885
|
+
} : {
|
|
886
|
+
width: 16,
|
|
887
|
+
height: 9
|
|
888
|
+
},
|
|
889
|
+
size: embed.size
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
// src/components/social-embed.tsx
|
|
893
|
+
/**
|
|
894
|
+
* Recognises embed code or a post URL and renders the platform's component. `height`
|
|
895
|
+
* reaches only the iframe-based embeds; the others size themselves (brief §6.1).
|
|
896
|
+
*/
|
|
897
|
+
function SocialEmbed(props) {
|
|
898
|
+
const { html, url, fallback, height, onError, ...shared } = props;
|
|
899
|
+
const embed = useMemo(() => html !== void 0 ? parseEmbed(html) : matchUrl(url), [html, url]);
|
|
900
|
+
useReportError(embed === null && !(html !== void 0 && fallback) ? html !== void 0 ? "social-embeds: unrecognised embed code" : `social-embeds: not a supported post URL: ${url}` : void 0, onError);
|
|
901
|
+
if (!embed) return html !== void 0 && fallback ? fallback(html) : null;
|
|
902
|
+
switch (embed.platform) {
|
|
903
|
+
case "x": return /* @__PURE__ */ jsx(XEmbed, {
|
|
904
|
+
...shared,
|
|
905
|
+
onError,
|
|
906
|
+
embed
|
|
907
|
+
});
|
|
908
|
+
case "instagram": return /* @__PURE__ */ jsx(InstagramEmbed, {
|
|
909
|
+
...shared,
|
|
910
|
+
onError,
|
|
911
|
+
embed
|
|
912
|
+
});
|
|
913
|
+
case "threads": return /* @__PURE__ */ jsx(ThreadsEmbed, {
|
|
914
|
+
...shared,
|
|
915
|
+
onError,
|
|
916
|
+
embed
|
|
917
|
+
});
|
|
918
|
+
case "facebook": return /* @__PURE__ */ jsx(FacebookEmbed, {
|
|
919
|
+
...shared,
|
|
920
|
+
height,
|
|
921
|
+
onError,
|
|
922
|
+
embed
|
|
923
|
+
});
|
|
924
|
+
case "tiktok": return /* @__PURE__ */ jsx(TikTokEmbed, {
|
|
925
|
+
...shared,
|
|
926
|
+
height,
|
|
927
|
+
onError,
|
|
928
|
+
embed
|
|
929
|
+
});
|
|
930
|
+
case "youtube": return /* @__PURE__ */ jsx(YouTubeEmbed, {
|
|
931
|
+
...shared,
|
|
932
|
+
height,
|
|
933
|
+
onError,
|
|
934
|
+
embed
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
export { FacebookEmbed, InstagramEmbed, SocialEmbed, ThreadsEmbed, TikTokEmbed, XEmbed, YouTubeEmbed };
|