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/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 };