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
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
// src/parser/html.ts
|
|
2
|
+
const VOID_TAGS = /* @__PURE__ */ new Set([
|
|
3
|
+
"area",
|
|
4
|
+
"base",
|
|
5
|
+
"br",
|
|
6
|
+
"col",
|
|
7
|
+
"embed",
|
|
8
|
+
"hr",
|
|
9
|
+
"img",
|
|
10
|
+
"input",
|
|
11
|
+
"link",
|
|
12
|
+
"meta",
|
|
13
|
+
"source",
|
|
14
|
+
"track",
|
|
15
|
+
"wbr"
|
|
16
|
+
]);
|
|
17
|
+
const RAW_TEXT_END = /* @__PURE__ */ new Map([["script", /<\/script\s*>/giu], ["style", /<\/style\s*>/giu]]);
|
|
18
|
+
const MAX_DEPTH = 100;
|
|
19
|
+
const START_TAG = /<([a-zA-Z][a-zA-Z0-9-]*)/y;
|
|
20
|
+
const ATTRIBUTE = /\s+([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/y;
|
|
21
|
+
const START_TAG_END = /\s*(\/?)>/y;
|
|
22
|
+
const END_TAG = /<\/([a-zA-Z][a-zA-Z0-9-]*)\s*>/y;
|
|
23
|
+
const NAMED_ENTITIES = /* @__PURE__ */ new Map([
|
|
24
|
+
["amp", "&"],
|
|
25
|
+
["lt", "<"],
|
|
26
|
+
["gt", ">"],
|
|
27
|
+
["quot", "\""],
|
|
28
|
+
["apos", "'"],
|
|
29
|
+
["nbsp", "\xA0"]
|
|
30
|
+
]);
|
|
31
|
+
/** Parses an HTML fragment into a node list, or `null` if it is not well-formed. */
|
|
32
|
+
function parseHtml(input) {
|
|
33
|
+
const root = {
|
|
34
|
+
type: "element",
|
|
35
|
+
tag: "",
|
|
36
|
+
attrs: /* @__PURE__ */ new Map(),
|
|
37
|
+
children: []
|
|
38
|
+
};
|
|
39
|
+
const open = [root];
|
|
40
|
+
let pos = 0;
|
|
41
|
+
while (pos < input.length) {
|
|
42
|
+
const parent = open[open.length - 1] ?? root;
|
|
43
|
+
const lt = input.indexOf("<", pos);
|
|
44
|
+
const textEnd = lt === -1 ? input.length : lt;
|
|
45
|
+
if (textEnd > pos) parent.children.push({
|
|
46
|
+
type: "text",
|
|
47
|
+
raw: input.slice(pos, textEnd)
|
|
48
|
+
});
|
|
49
|
+
if (lt === -1) break;
|
|
50
|
+
if (input.startsWith("<!--", lt)) {
|
|
51
|
+
const close = input.indexOf("-->", lt + 4);
|
|
52
|
+
if (close === -1) return null;
|
|
53
|
+
parent.children.push({ type: "comment" });
|
|
54
|
+
pos = close + 3;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
END_TAG.lastIndex = lt;
|
|
58
|
+
const end = END_TAG.exec(input);
|
|
59
|
+
if (end) {
|
|
60
|
+
if (parent === root || parent.tag !== end[1]?.toLowerCase()) return null;
|
|
61
|
+
open.pop();
|
|
62
|
+
pos = END_TAG.lastIndex;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const start = readStartTag(input, lt);
|
|
66
|
+
if (!start) return null;
|
|
67
|
+
const { element } = start;
|
|
68
|
+
parent.children.push(element);
|
|
69
|
+
pos = start.end;
|
|
70
|
+
const rawTextEnd = start.selfClosing ? void 0 : RAW_TEXT_END.get(element.tag);
|
|
71
|
+
if (rawTextEnd) {
|
|
72
|
+
rawTextEnd.lastIndex = pos;
|
|
73
|
+
const close = rawTextEnd.exec(input);
|
|
74
|
+
if (!close) return null;
|
|
75
|
+
if (close.index > pos) element.children.push({
|
|
76
|
+
type: "text",
|
|
77
|
+
raw: input.slice(pos, close.index)
|
|
78
|
+
});
|
|
79
|
+
pos = rawTextEnd.lastIndex;
|
|
80
|
+
} else if (!start.selfClosing && !VOID_TAGS.has(element.tag)) {
|
|
81
|
+
if (open.length > MAX_DEPTH) return null;
|
|
82
|
+
open.push(element);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return open.length === 1 ? root.children : null;
|
|
86
|
+
}
|
|
87
|
+
function readStartTag(input, at) {
|
|
88
|
+
START_TAG.lastIndex = at;
|
|
89
|
+
const name = START_TAG.exec(input)?.[1];
|
|
90
|
+
if (name === void 0) return null;
|
|
91
|
+
const attrs = /* @__PURE__ */ new Map();
|
|
92
|
+
let pos = START_TAG.lastIndex;
|
|
93
|
+
for (;;) {
|
|
94
|
+
START_TAG_END.lastIndex = pos;
|
|
95
|
+
const close = START_TAG_END.exec(input);
|
|
96
|
+
if (close) return {
|
|
97
|
+
element: {
|
|
98
|
+
type: "element",
|
|
99
|
+
tag: name.toLowerCase(),
|
|
100
|
+
attrs,
|
|
101
|
+
children: []
|
|
102
|
+
},
|
|
103
|
+
selfClosing: close[1] === "/",
|
|
104
|
+
end: START_TAG_END.lastIndex
|
|
105
|
+
};
|
|
106
|
+
ATTRIBUTE.lastIndex = pos;
|
|
107
|
+
const attribute = ATTRIBUTE.exec(input);
|
|
108
|
+
if (!attribute) return null;
|
|
109
|
+
const [, attributeName = "", doubleQuoted, singleQuoted, unquoted] = attribute;
|
|
110
|
+
const key = attributeName.toLowerCase();
|
|
111
|
+
if (!attrs.has(key)) attrs.set(key, decodeEntities(doubleQuoted ?? singleQuoted ?? unquoted ?? ""));
|
|
112
|
+
pos = ATTRIBUTE.lastIndex;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Decodes numeric references and the few named entities that appear in embed attributes. */
|
|
116
|
+
function decodeEntities(value) {
|
|
117
|
+
return value.replace(/&(#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z]+);/g, (reference, body) => {
|
|
118
|
+
if (!body.startsWith("#")) return NAMED_ENTITIES.get(body) ?? reference;
|
|
119
|
+
const hex = body[1] === "x" || body[1] === "X";
|
|
120
|
+
const codePoint = Number.parseInt(body.slice(hex ? 2 : 1), hex ? 16 : 10);
|
|
121
|
+
return codePoint > 0 && codePoint <= 1114111 && (codePoint < 55296 || codePoint > 57343) ? String.fromCodePoint(codePoint) : reference;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function hasClass(element, name) {
|
|
125
|
+
return (element.attrs.get("class") ?? "").split(/\s+/).includes(name);
|
|
126
|
+
}
|
|
127
|
+
/** All descendant elements in document order. */
|
|
128
|
+
function descendants(nodes) {
|
|
129
|
+
const found = [];
|
|
130
|
+
for (const node of nodes) {
|
|
131
|
+
if (node.type !== "element") continue;
|
|
132
|
+
found.push(node, ...descendants(node.children));
|
|
133
|
+
}
|
|
134
|
+
return found;
|
|
135
|
+
}
|
|
136
|
+
const URL_ATTRIBUTES = /* @__PURE__ */ new Set(["href", "cite"]);
|
|
137
|
+
const HTTP_URL = /^https?:\/\//i;
|
|
138
|
+
/**
|
|
139
|
+
* Rebuilds markup from whitelisted elements and attributes only. Elements not on the list
|
|
140
|
+
* are dropped together with their content; `href` and `cite` must be http(s) URLs. Text is
|
|
141
|
+
* copied as written: the tokenizer guarantees it contains no `<`, so it cannot open a tag.
|
|
142
|
+
*/
|
|
143
|
+
function rebuild(nodes, whitelist) {
|
|
144
|
+
let html = "";
|
|
145
|
+
for (const node of nodes) {
|
|
146
|
+
if (node.type === "text") {
|
|
147
|
+
html += node.raw;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (node.type !== "element") continue;
|
|
151
|
+
const allowed = whitelist.get(node.tag);
|
|
152
|
+
if (!allowed) continue;
|
|
153
|
+
html += `<${node.tag}`;
|
|
154
|
+
for (const rule of allowed) {
|
|
155
|
+
const name = typeof rule === "string" ? rule : rule.name;
|
|
156
|
+
const value = node.attrs.get(name);
|
|
157
|
+
if (value === void 0) continue;
|
|
158
|
+
if (typeof rule !== "string" && !rule.value.test(value)) continue;
|
|
159
|
+
if (URL_ATTRIBUTES.has(name) && !HTTP_URL.test(value)) continue;
|
|
160
|
+
html += ` ${name}="${escapeAttribute(value)}"`;
|
|
161
|
+
}
|
|
162
|
+
html += ">";
|
|
163
|
+
if (!VOID_TAGS.has(node.tag)) html += `${rebuild(node.children, whitelist)}</${node.tag}>`;
|
|
164
|
+
}
|
|
165
|
+
return html;
|
|
166
|
+
}
|
|
167
|
+
function escapeAttribute(value) {
|
|
168
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
169
|
+
}
|
|
170
|
+
// src/parser/url.ts
|
|
171
|
+
const URL_PATTERN = /^(?:https?:)?\/\/([^/?#\s@]+)((?:\/[^?#\s]*)?)(?:\?([^#\s]*))?(?:#\S*)?$/i;
|
|
172
|
+
/** Splits an absolute or protocol-relative http(s) URL, or returns `null`. */
|
|
173
|
+
function parseUrl(input) {
|
|
174
|
+
const match = URL_PATTERN.exec(input.trim());
|
|
175
|
+
if (!match) return null;
|
|
176
|
+
const [, host = "", path = "", search = ""] = match;
|
|
177
|
+
const query = /* @__PURE__ */ new Map();
|
|
178
|
+
for (const pair of search.split("&")) {
|
|
179
|
+
if (pair === "") continue;
|
|
180
|
+
const eq = pair.indexOf("=");
|
|
181
|
+
const key = decodeComponent(eq === -1 ? pair : pair.slice(0, eq));
|
|
182
|
+
const value = decodeComponent(eq === -1 ? "" : pair.slice(eq + 1));
|
|
183
|
+
if (key === null || value === null) return null;
|
|
184
|
+
if (!query.has(key)) query.set(key, value);
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
host: host.toLowerCase(),
|
|
188
|
+
path,
|
|
189
|
+
query
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function decodeComponent(component) {
|
|
193
|
+
try {
|
|
194
|
+
return decodeURIComponent(component.replace(/\+/g, " "));
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Types an option value: booleans, canonical integers, otherwise the string as given. */
|
|
200
|
+
function coerce(value) {
|
|
201
|
+
if (value === "true") return true;
|
|
202
|
+
if (value === "false") return false;
|
|
203
|
+
if (/^(?:0|[1-9]\d{0,14})$/.test(value)) return Number(value);
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
/** Positive integer pixel size from an attribute or query value. */
|
|
207
|
+
function pixels(value) {
|
|
208
|
+
return value !== void 0 && /^[1-9]\d{0,5}$/.test(value) ? Number(value) : void 0;
|
|
209
|
+
}
|
|
210
|
+
// src/parser/platform.ts
|
|
211
|
+
/** Reads options from attributes; the first attribute present for a key wins. */
|
|
212
|
+
function attributeOptions(element, mapping, coerceValue) {
|
|
213
|
+
const options = {};
|
|
214
|
+
for (const [attribute, key] of mapping) {
|
|
215
|
+
const value = element.attrs.get(attribute);
|
|
216
|
+
if (value !== void 0 && !(key in options)) options[key] = coerceValue(value);
|
|
217
|
+
}
|
|
218
|
+
return options;
|
|
219
|
+
}
|
|
220
|
+
function sizeOf(element) {
|
|
221
|
+
const width = pixels(element.attrs.get("width"));
|
|
222
|
+
const height = pixels(element.attrs.get("height"));
|
|
223
|
+
if (width === void 0 && height === void 0) return void 0;
|
|
224
|
+
return {
|
|
225
|
+
...width === void 0 ? {} : { width },
|
|
226
|
+
...height === void 0 ? {} : { height }
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/** Adds `size` only when known, so results stay free of `undefined` properties. */
|
|
230
|
+
function withSize(embed, size) {
|
|
231
|
+
return size ? {
|
|
232
|
+
...embed,
|
|
233
|
+
size
|
|
234
|
+
} : embed;
|
|
235
|
+
}
|
|
236
|
+
// src/parser/platforms/facebook.ts
|
|
237
|
+
const HOSTS$4 = /* @__PURE__ */ new Set([
|
|
238
|
+
"facebook.com",
|
|
239
|
+
"www.facebook.com",
|
|
240
|
+
"m.facebook.com"
|
|
241
|
+
]);
|
|
242
|
+
const PLUGIN_HOSTS = /* @__PURE__ */ new Set(["facebook.com", "www.facebook.com"]);
|
|
243
|
+
const BASE = "https://www.facebook.com";
|
|
244
|
+
const NAME = "[A-Za-z0-9.\\-]{1,80}";
|
|
245
|
+
const POST_ID = "(?:\\d{1,25}|pfbid[A-Za-z0-9]{10,120})";
|
|
246
|
+
const POSTS_PATH = new RegExp(`^/(${NAME})/posts/(${POST_ID})/?$`);
|
|
247
|
+
const PHOTOS_PATH = new RegExp(`^/(${NAME})/photos/([A-Za-z0-9.]{1,40})/(\\d{1,25})/?$`);
|
|
248
|
+
const VIDEOS_PATH = new RegExp(`^/(${NAME})/videos/(?:[^/]{1,200}/)?(\\d{1,25})/?$`);
|
|
249
|
+
const REEL_PATH = /^\/reel\/(\d{1,25})\/?$/;
|
|
250
|
+
const STORY_PATHS = /* @__PURE__ */ new Set(["/permalink.php", "/story.php"]);
|
|
251
|
+
const PHOTO_PATHS = /* @__PURE__ */ new Set([
|
|
252
|
+
"/photo.php",
|
|
253
|
+
"/photo/",
|
|
254
|
+
"/photo"
|
|
255
|
+
]);
|
|
256
|
+
const STORY_ID = new RegExp(`^${POST_ID}$`);
|
|
257
|
+
const NUMERIC_ID = /^\d{1,25}$/;
|
|
258
|
+
const SCRIPT$4 = /^(?:https?:)?\/\/connect\.facebook\.net\/([a-z]{2,3}_[A-Z]{2})\/sdk\.js(?:[?#].*)?$/;
|
|
259
|
+
const OPTION_ATTRIBUTES$2 = [
|
|
260
|
+
["data-width", "width"],
|
|
261
|
+
["data-show-text", "showText"],
|
|
262
|
+
["data-lazy", "lazy"],
|
|
263
|
+
["data-allowfullscreen", "allowfullscreen"],
|
|
264
|
+
["data-autoplay", "autoplay"],
|
|
265
|
+
["data-show-captions", "showCaptions"]
|
|
266
|
+
];
|
|
267
|
+
const PARSE_IGNORE = {
|
|
268
|
+
name: "class",
|
|
269
|
+
value: /^fb-xfbml-parse-ignore$/
|
|
270
|
+
};
|
|
271
|
+
const FALLBACK$3 = /* @__PURE__ */ new Map([
|
|
272
|
+
["div", [PARSE_IGNORE]],
|
|
273
|
+
["blockquote", ["cite", PARSE_IGNORE]],
|
|
274
|
+
["a", ["href"]],
|
|
275
|
+
["p", []]
|
|
276
|
+
]);
|
|
277
|
+
function target(url) {
|
|
278
|
+
if (!url || !HOSTS$4.has(url.host)) return null;
|
|
279
|
+
const { path, query } = url;
|
|
280
|
+
const [, name, postId] = POSTS_PATH.exec(path) ?? [];
|
|
281
|
+
if (name !== void 0 && postId !== void 0) return {
|
|
282
|
+
kind: "post",
|
|
283
|
+
id: postId,
|
|
284
|
+
url: `${BASE}/${name}/posts/${postId}`
|
|
285
|
+
};
|
|
286
|
+
if (STORY_PATHS.has(path)) {
|
|
287
|
+
const storyId = query.get("story_fbid");
|
|
288
|
+
if (storyId === void 0 || !STORY_ID.test(storyId)) return null;
|
|
289
|
+
const owner = query.get("id");
|
|
290
|
+
const ownerQuery = owner !== void 0 && NUMERIC_ID.test(owner) ? `&id=${owner}` : "";
|
|
291
|
+
return {
|
|
292
|
+
kind: "post",
|
|
293
|
+
id: storyId,
|
|
294
|
+
url: `${BASE}${path}?story_fbid=${storyId}${ownerQuery}`
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
if (PHOTO_PATHS.has(path)) {
|
|
298
|
+
const fbid = query.get("fbid");
|
|
299
|
+
if (fbid === void 0 || !NUMERIC_ID.test(fbid)) return null;
|
|
300
|
+
return {
|
|
301
|
+
kind: "post",
|
|
302
|
+
id: fbid,
|
|
303
|
+
url: `${BASE}${path}?fbid=${fbid}`
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const [, owner, album, photoId] = PHOTOS_PATH.exec(path) ?? [];
|
|
307
|
+
if (owner !== void 0 && album !== void 0 && photoId !== void 0) return {
|
|
308
|
+
kind: "post",
|
|
309
|
+
id: photoId,
|
|
310
|
+
url: `${BASE}/${owner}/photos/${album}/${photoId}/`
|
|
311
|
+
};
|
|
312
|
+
const [, page, videoId] = VIDEOS_PATH.exec(path) ?? [];
|
|
313
|
+
if (page !== void 0 && videoId !== void 0) return {
|
|
314
|
+
kind: "video",
|
|
315
|
+
id: videoId,
|
|
316
|
+
url: `${BASE}/${page}/videos/${videoId}/`
|
|
317
|
+
};
|
|
318
|
+
if (path === "/watch" || path === "/watch/" || path === "/video.php") {
|
|
319
|
+
const v = query.get("v");
|
|
320
|
+
if (v === void 0 || !NUMERIC_ID.test(v)) return null;
|
|
321
|
+
return {
|
|
322
|
+
kind: "video",
|
|
323
|
+
id: v,
|
|
324
|
+
url: `${BASE}${path === "/video.php" ? path : "/watch/"}?v=${v}`
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
const [, reelId] = REEL_PATH.exec(path) ?? [];
|
|
328
|
+
if (reelId !== void 0) return {
|
|
329
|
+
kind: "video",
|
|
330
|
+
id: reelId,
|
|
331
|
+
url: `${BASE}/reel/${reelId}`
|
|
332
|
+
};
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
function embed(found, kind, variant, options) {
|
|
336
|
+
return {
|
|
337
|
+
platform: "facebook",
|
|
338
|
+
kind,
|
|
339
|
+
url: found.url,
|
|
340
|
+
id: found.id,
|
|
341
|
+
variant,
|
|
342
|
+
options
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
const facebook = {
|
|
346
|
+
platform: "facebook",
|
|
347
|
+
fallback: FALLBACK$3,
|
|
348
|
+
parse(root) {
|
|
349
|
+
if (root.tag === "div") {
|
|
350
|
+
const kind = hasClass(root, "fb-post") ? "post" : hasClass(root, "fb-video") ? "video" : null;
|
|
351
|
+
const found = target(parseUrl(root.attrs.get("data-href") ?? ""));
|
|
352
|
+
if (!kind || !found) return null;
|
|
353
|
+
return {
|
|
354
|
+
...embed(found, kind, "sdk", attributeOptions(root, OPTION_ATTRIBUTES$2, coerce)),
|
|
355
|
+
fallbackHtml: rebuild(root.children, FALLBACK$3)
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
if (root.tag !== "iframe") return null;
|
|
359
|
+
const src = parseUrl(root.attrs.get("src") ?? "");
|
|
360
|
+
if (!src || !PLUGIN_HOSTS.has(src.host)) return null;
|
|
361
|
+
const found = target(parseUrl(src.query.get("href") ?? ""));
|
|
362
|
+
if (!found) return null;
|
|
363
|
+
if (src.path === "/plugins/post.php") {
|
|
364
|
+
const options = {};
|
|
365
|
+
const showText = src.query.get("show_text");
|
|
366
|
+
const width = src.query.get("width");
|
|
367
|
+
if (showText !== void 0) options.showText = coerce(showText);
|
|
368
|
+
if (width !== void 0) options.width = coerce(width);
|
|
369
|
+
return embed(found, "post", "sdk", options);
|
|
370
|
+
}
|
|
371
|
+
if (src.path === "/plugins/video.php") return withSize(embed(found, "video", "iframe", Object.fromEntries([...src.query].filter(([key]) => key !== "href").map(([key, value]) => [key, coerce(value)]))), sizeOf(root));
|
|
372
|
+
return null;
|
|
373
|
+
},
|
|
374
|
+
isOwnScript: (src) => SCRIPT$4.test(src),
|
|
375
|
+
scriptOptions(src) {
|
|
376
|
+
const [, locale] = SCRIPT$4.exec(src) ?? [];
|
|
377
|
+
return locale === void 0 ? {} : { locale };
|
|
378
|
+
},
|
|
379
|
+
allowsFbRoot: true,
|
|
380
|
+
match(url) {
|
|
381
|
+
const found = target(url);
|
|
382
|
+
return found && embed(found, found.kind, "sdk", {});
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
// src/parser/platforms/instagram.ts
|
|
386
|
+
const HOSTS$3 = /* @__PURE__ */ new Set(["instagram.com", "www.instagram.com"]);
|
|
387
|
+
const POST_PATH$1 = /^\/(?:([A-Za-z0-9._]{1,30})\/)?(p|reel|reels|tv)\/([A-Za-z0-9_-]{1,64})\/?$/;
|
|
388
|
+
const TYPES = /* @__PURE__ */ new Set([
|
|
389
|
+
"p",
|
|
390
|
+
"reel",
|
|
391
|
+
"reels",
|
|
392
|
+
"tv"
|
|
393
|
+
]);
|
|
394
|
+
const USER_POST_TYPES = /* @__PURE__ */ new Set(["p", "reel"]);
|
|
395
|
+
const SCRIPT$3 = /^(?:https?:)?\/\/www\.instagram\.com\/embed\.js$/;
|
|
396
|
+
const FALLBACK$2 = /* @__PURE__ */ new Map([
|
|
397
|
+
["div", []],
|
|
398
|
+
["a", ["href"]],
|
|
399
|
+
["p", []]
|
|
400
|
+
]);
|
|
401
|
+
/**
|
|
402
|
+
* embed.js serves only /p/, /reel/ and /tv/ permalinks; /reels/ and /{user}/… return 404
|
|
403
|
+
* (docs/decisions.md D15), so the canonical URL drops the username and maps reels → reel.
|
|
404
|
+
*/
|
|
405
|
+
function post$2(url) {
|
|
406
|
+
if (!url || !HOSTS$3.has(url.host)) return null;
|
|
407
|
+
const [, user, type, code] = POST_PATH$1.exec(url.path) ?? [];
|
|
408
|
+
if (type === void 0 || code === void 0) return null;
|
|
409
|
+
if (user !== void 0 && (TYPES.has(user) || !USER_POST_TYPES.has(type))) return null;
|
|
410
|
+
return {
|
|
411
|
+
platform: "instagram",
|
|
412
|
+
kind: "post",
|
|
413
|
+
url: `https://www.instagram.com/${type === "reels" ? "reel" : type}/${code}/`,
|
|
414
|
+
id: code,
|
|
415
|
+
variant: "sdk"
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
const instagram = {
|
|
419
|
+
platform: "instagram",
|
|
420
|
+
fallback: FALLBACK$2,
|
|
421
|
+
parse(root) {
|
|
422
|
+
if (root.tag !== "blockquote" || !hasClass(root, "instagram-media")) return null;
|
|
423
|
+
const permalink = root.attrs.get("data-instgrm-permalink");
|
|
424
|
+
const found = permalink === void 0 ? descendants(root.children).reverse().map((element) => element.tag === "a" ? post$2(parseUrl(element.attrs.get("href") ?? "")) : null).find((candidate) => candidate !== null) : post$2(parseUrl(permalink));
|
|
425
|
+
if (!found) return null;
|
|
426
|
+
const options = {};
|
|
427
|
+
if (root.attrs.has("data-instgrm-captioned")) options.captioned = true;
|
|
428
|
+
const version = root.attrs.get("data-instgrm-version");
|
|
429
|
+
if (version !== void 0) options.version = coerce(version);
|
|
430
|
+
return {
|
|
431
|
+
...found,
|
|
432
|
+
options,
|
|
433
|
+
fallbackHtml: rebuild(root.children, FALLBACK$2)
|
|
434
|
+
};
|
|
435
|
+
},
|
|
436
|
+
isOwnScript: (src) => SCRIPT$3.test(src),
|
|
437
|
+
match(url) {
|
|
438
|
+
const found = post$2(url);
|
|
439
|
+
return found && {
|
|
440
|
+
...found,
|
|
441
|
+
options: {}
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
// src/parser/platforms/threads.ts
|
|
446
|
+
const HOSTS$2 = /* @__PURE__ */ new Set([
|
|
447
|
+
"threads.com",
|
|
448
|
+
"www.threads.com",
|
|
449
|
+
"threads.net",
|
|
450
|
+
"www.threads.net"
|
|
451
|
+
]);
|
|
452
|
+
const POST_PATH = /^\/(?:@[A-Za-z0-9._]{1,30}\/post|t)\/([A-Za-z0-9_-]{1,64})\/?$/;
|
|
453
|
+
const SCRIPT$2 = /^(?:https?:)?\/\/www\.threads\.(?:com|net)\/embed\.js$/;
|
|
454
|
+
const OPTION_ATTRIBUTES$1 = [["data-theme", "theme"], ["data-text-post-version", "version"]];
|
|
455
|
+
const FALLBACK$1 = /* @__PURE__ */ new Map([["a", ["href"]], ["div", []]]);
|
|
456
|
+
function post$1(url) {
|
|
457
|
+
if (!url || !HOSTS$2.has(url.host)) return null;
|
|
458
|
+
const [, code] = POST_PATH.exec(url.path) ?? [];
|
|
459
|
+
if (code === void 0) return null;
|
|
460
|
+
return {
|
|
461
|
+
platform: "threads",
|
|
462
|
+
kind: "post",
|
|
463
|
+
url: `https://www.threads.com/t/${code}`,
|
|
464
|
+
id: code,
|
|
465
|
+
variant: "sdk"
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
const threads = {
|
|
469
|
+
platform: "threads",
|
|
470
|
+
fallback: FALLBACK$1,
|
|
471
|
+
parse(root) {
|
|
472
|
+
if (root.tag !== "blockquote" || !hasClass(root, "text-post-media")) return null;
|
|
473
|
+
const found = post$1(parseUrl(root.attrs.get("data-text-post-permalink") ?? ""));
|
|
474
|
+
if (!found) return null;
|
|
475
|
+
const elementId = root.attrs.get("id");
|
|
476
|
+
if (elementId !== void 0 && elementId !== `ig-tp-${found.id}`) return null;
|
|
477
|
+
return {
|
|
478
|
+
...found,
|
|
479
|
+
options: attributeOptions(root, OPTION_ATTRIBUTES$1, coerce),
|
|
480
|
+
fallbackHtml: rebuild(root.children, FALLBACK$1)
|
|
481
|
+
};
|
|
482
|
+
},
|
|
483
|
+
isOwnScript: (src) => SCRIPT$2.test(src),
|
|
484
|
+
match(url) {
|
|
485
|
+
const found = post$1(url);
|
|
486
|
+
return found && {
|
|
487
|
+
...found,
|
|
488
|
+
options: {}
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
// src/parser/platforms/tiktok.ts
|
|
493
|
+
const HOSTS$1 = /* @__PURE__ */ new Set(["tiktok.com", "www.tiktok.com"]);
|
|
494
|
+
const VIDEO_PATH = /^\/@([A-Za-z0-9._]{0,64})\/video\/(\d{1,25})\/?$/;
|
|
495
|
+
const PLAYER_PATH = /^\/player\/v1\/(\d{1,25})\/?$/;
|
|
496
|
+
const VIDEO_ID$1 = /^\d{1,25}$/;
|
|
497
|
+
const SCRIPT$1 = /^(?:https?:)?\/\/www\.tiktok\.com\/embed\.js$/;
|
|
498
|
+
/** `user` is unknown for player iframe input; TikTok serves `/@/video/{id}` for that case. */
|
|
499
|
+
function video$1(id, user = "", options = {}) {
|
|
500
|
+
return {
|
|
501
|
+
platform: "tiktok",
|
|
502
|
+
kind: "video",
|
|
503
|
+
url: `https://www.tiktok.com/@${user}/video/${id}`,
|
|
504
|
+
id,
|
|
505
|
+
variant: "iframe",
|
|
506
|
+
options
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
const tiktok = {
|
|
510
|
+
platform: "tiktok",
|
|
511
|
+
parse(root) {
|
|
512
|
+
if (root.tag === "blockquote" && hasClass(root, "tiktok-embed")) {
|
|
513
|
+
const cite = parseUrl(root.attrs.get("cite") ?? "");
|
|
514
|
+
const [, user, citeId] = cite && HOSTS$1.has(cite.host) ? VIDEO_PATH.exec(cite.path) ?? [] : [];
|
|
515
|
+
const dataId = root.attrs.get("data-video-id");
|
|
516
|
+
if (dataId !== void 0 && !VIDEO_ID$1.test(dataId)) return null;
|
|
517
|
+
if (dataId !== void 0 && citeId !== void 0 && dataId !== citeId) return null;
|
|
518
|
+
const id = dataId ?? citeId;
|
|
519
|
+
return id === void 0 ? null : video$1(id, user);
|
|
520
|
+
}
|
|
521
|
+
if (root.tag !== "iframe") return null;
|
|
522
|
+
const src = parseUrl(root.attrs.get("src") ?? "");
|
|
523
|
+
if (!src || !HOSTS$1.has(src.host)) return null;
|
|
524
|
+
const [, id] = PLAYER_PATH.exec(src.path) ?? [];
|
|
525
|
+
if (id === void 0) return null;
|
|
526
|
+
return withSize(video$1(id, "", Object.fromEntries([...src.query].map(([key, value]) => [key, coerce(value)]))), sizeOf(root));
|
|
527
|
+
},
|
|
528
|
+
isOwnScript: (src) => SCRIPT$1.test(src),
|
|
529
|
+
match(url) {
|
|
530
|
+
if (!HOSTS$1.has(url.host)) return null;
|
|
531
|
+
const [, user, id] = VIDEO_PATH.exec(url.path) ?? [];
|
|
532
|
+
return user === void 0 || id === void 0 ? null : video$1(id, user);
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
// src/parser/platforms/x.ts
|
|
536
|
+
const HOSTS = /* @__PURE__ */ new Set([
|
|
537
|
+
"x.com",
|
|
538
|
+
"www.x.com",
|
|
539
|
+
"mobile.x.com",
|
|
540
|
+
"twitter.com",
|
|
541
|
+
"www.twitter.com",
|
|
542
|
+
"mobile.twitter.com"
|
|
543
|
+
]);
|
|
544
|
+
const STATUS_PATH = /^\/([A-Za-z0-9_]{1,15})\/status\/(\d{1,20})\/?$/;
|
|
545
|
+
const SCRIPT = /^(?:https?:)?\/\/platform\.(?:x|twitter)\.com\/widgets\.js$/;
|
|
546
|
+
const OPTION_ATTRIBUTES = [
|
|
547
|
+
["data-theme", "theme"],
|
|
548
|
+
["data-conversation", "conversation"],
|
|
549
|
+
["data-cards", "cards"],
|
|
550
|
+
["data-align", "align"],
|
|
551
|
+
["align", "align"],
|
|
552
|
+
["data-lang", "lang"],
|
|
553
|
+
["data-dnt", "dnt"],
|
|
554
|
+
["data-width", "width"]
|
|
555
|
+
];
|
|
556
|
+
const FALLBACK = /* @__PURE__ */ new Map([
|
|
557
|
+
["p", ["lang", "dir"]],
|
|
558
|
+
["a", ["href"]],
|
|
559
|
+
["br", []]
|
|
560
|
+
]);
|
|
561
|
+
function post(url) {
|
|
562
|
+
if (!url || !HOSTS.has(url.host)) return null;
|
|
563
|
+
const [, user, id] = STATUS_PATH.exec(url.path) ?? [];
|
|
564
|
+
if (user === void 0 || id === void 0) return null;
|
|
565
|
+
return {
|
|
566
|
+
platform: "x",
|
|
567
|
+
kind: "post",
|
|
568
|
+
url: `https://x.com/${user}/status/${id}`,
|
|
569
|
+
id,
|
|
570
|
+
variant: "sdk"
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
const x = {
|
|
574
|
+
platform: "x",
|
|
575
|
+
fallback: FALLBACK,
|
|
576
|
+
parse(root) {
|
|
577
|
+
if (root.tag !== "blockquote" || !hasClass(root, "twitter-tweet")) return null;
|
|
578
|
+
const status = descendants(root.children).reverse().map((element) => element.tag === "a" ? post(parseUrl(element.attrs.get("href") ?? "")) : null).find((found) => found !== null);
|
|
579
|
+
if (!status) return null;
|
|
580
|
+
return {
|
|
581
|
+
...status,
|
|
582
|
+
options: attributeOptions(root, OPTION_ATTRIBUTES, coerce),
|
|
583
|
+
fallbackHtml: rebuild(root.children, FALLBACK)
|
|
584
|
+
};
|
|
585
|
+
},
|
|
586
|
+
isOwnScript: (src) => SCRIPT.test(src),
|
|
587
|
+
match(url) {
|
|
588
|
+
const status = post(url);
|
|
589
|
+
return status && {
|
|
590
|
+
...status,
|
|
591
|
+
options: {}
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
// src/parser/platforms/youtube.ts
|
|
596
|
+
const YOUTUBE_HOSTS = /* @__PURE__ */ new Set([
|
|
597
|
+
"youtube.com",
|
|
598
|
+
"www.youtube.com",
|
|
599
|
+
"m.youtube.com"
|
|
600
|
+
]);
|
|
601
|
+
const NOCOOKIE_HOSTS = /* @__PURE__ */ new Set(["youtube-nocookie.com", "www.youtube-nocookie.com"]);
|
|
602
|
+
const VIDEO_ID = /^(?!videoseries)[A-Za-z0-9_-]{11}$/;
|
|
603
|
+
const EMBED_PATH = /^\/embed\/((?!videoseries)[A-Za-z0-9_-]{11})\/?$/;
|
|
604
|
+
const URL_PATHS = /^\/(shorts|embed|live)\/((?!videoseries)[A-Za-z0-9_-]{11})\/?$/;
|
|
605
|
+
const SHORT_LINK_PATH = /^\/([A-Za-z0-9_-]{11})\/?$/;
|
|
606
|
+
const DURATION = /^(?:(\d{1,5})h)?(?:(\d{1,6})m)?(?:(\d{1,7})s?)?$/;
|
|
607
|
+
/** `90`, `90s`, `1m30s`, `1h2m3s` → seconds. */
|
|
608
|
+
function seconds(value) {
|
|
609
|
+
if (value === void 0 || value === "") return void 0;
|
|
610
|
+
const match = DURATION.exec(value);
|
|
611
|
+
if (!match) return void 0;
|
|
612
|
+
const [, hours = "0", minutes = "0", secs = "0"] = match;
|
|
613
|
+
return Number(hours) * 3600 + Number(minutes) * 60 + Number(secs);
|
|
614
|
+
}
|
|
615
|
+
/** `start` wins over `t`; `t` is converted, because the embed player only reads `start`. */
|
|
616
|
+
function startOf(query) {
|
|
617
|
+
return seconds(query.get("start")) ?? seconds(query.get("t"));
|
|
618
|
+
}
|
|
619
|
+
function video(id, shorts = false) {
|
|
620
|
+
return {
|
|
621
|
+
platform: "youtube",
|
|
622
|
+
kind: "video",
|
|
623
|
+
url: shorts ? `https://www.youtube.com/shorts/${id}` : `https://www.youtube.com/watch?v=${id}`,
|
|
624
|
+
id,
|
|
625
|
+
variant: "iframe"
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
function urlOptions(url, nocookie) {
|
|
629
|
+
const start = startOf(url.query);
|
|
630
|
+
return {
|
|
631
|
+
...start === void 0 ? {} : { start },
|
|
632
|
+
...nocookie ? { nocookie: true } : {}
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
// src/parser/platforms/index.ts
|
|
636
|
+
const platforms = [
|
|
637
|
+
x,
|
|
638
|
+
instagram,
|
|
639
|
+
facebook,
|
|
640
|
+
threads,
|
|
641
|
+
tiktok,
|
|
642
|
+
{
|
|
643
|
+
platform: "youtube",
|
|
644
|
+
parse(root) {
|
|
645
|
+
if (root.tag !== "iframe") return null;
|
|
646
|
+
const src = parseUrl(root.attrs.get("src") ?? "");
|
|
647
|
+
const nocookie = src !== null && NOCOOKIE_HOSTS.has(src.host);
|
|
648
|
+
if (!src || !(nocookie || YOUTUBE_HOSTS.has(src.host))) return null;
|
|
649
|
+
const [, id] = EMBED_PATH.exec(src.path) ?? [];
|
|
650
|
+
if (id === void 0) return null;
|
|
651
|
+
const entries = [...src.query].filter(([key]) => key !== "t").map(([key, value]) => [key, coerce(value)]);
|
|
652
|
+
const options = Object.fromEntries(entries);
|
|
653
|
+
const start = startOf(src.query);
|
|
654
|
+
if (start !== void 0) options.start = start;
|
|
655
|
+
if (nocookie) options.nocookie = true;
|
|
656
|
+
return withSize({
|
|
657
|
+
...video(id),
|
|
658
|
+
options
|
|
659
|
+
}, sizeOf(root));
|
|
660
|
+
},
|
|
661
|
+
isOwnScript: () => false,
|
|
662
|
+
match(url) {
|
|
663
|
+
if (url.host === "youtu.be") {
|
|
664
|
+
const [, id] = SHORT_LINK_PATH.exec(url.path) ?? [];
|
|
665
|
+
return id === void 0 ? null : {
|
|
666
|
+
...video(id),
|
|
667
|
+
options: urlOptions(url, false)
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
const nocookie = NOCOOKIE_HOSTS.has(url.host);
|
|
671
|
+
if (!nocookie && !YOUTUBE_HOSTS.has(url.host)) return null;
|
|
672
|
+
if (!nocookie && /^\/watch\/?$/.test(url.path)) {
|
|
673
|
+
const id = url.query.get("v");
|
|
674
|
+
return id !== void 0 && VIDEO_ID.test(id) ? {
|
|
675
|
+
...video(id),
|
|
676
|
+
options: urlOptions(url, false)
|
|
677
|
+
} : null;
|
|
678
|
+
}
|
|
679
|
+
const [, kind, id] = URL_PATHS.exec(url.path) ?? [];
|
|
680
|
+
if (kind === void 0 || id === void 0) return null;
|
|
681
|
+
if (nocookie && kind !== "embed") return null;
|
|
682
|
+
return {
|
|
683
|
+
...video(id, kind === "shorts"),
|
|
684
|
+
options: urlOptions(url, nocookie)
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
];
|
|
689
|
+
// src/parser/match-url.ts
|
|
690
|
+
/**
|
|
691
|
+
* Recognises a post URL. The result has no fallback content, and options only for data the
|
|
692
|
+
* URL itself carries (docs/decisions.md D13). Never throws.
|
|
693
|
+
*/
|
|
694
|
+
function matchUrl(url) {
|
|
695
|
+
if (typeof url !== "string") return null;
|
|
696
|
+
const parsed = parseUrl(url);
|
|
697
|
+
if (!parsed) return null;
|
|
698
|
+
for (const platform of platforms) {
|
|
699
|
+
const embed = platform.match(parsed);
|
|
700
|
+
if (embed) return embed;
|
|
701
|
+
}
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
// src/parser/parse-embed.ts
|
|
705
|
+
/**
|
|
706
|
+
* Recognises a platform's own embed code. The input must consist of exactly one embed
|
|
707
|
+
* element plus, optionally, the platform's own `<script src>` tags, Facebook's empty
|
|
708
|
+
* `<div id="fb-root">`, whitespace and comments. Anything else returns `null`
|
|
709
|
+
* (docs/decisions.md D14). Never throws.
|
|
710
|
+
*/
|
|
711
|
+
function parseEmbed(html) {
|
|
712
|
+
if (typeof html !== "string") return null;
|
|
713
|
+
const nodes = parseHtml(html);
|
|
714
|
+
if (!nodes) return null;
|
|
715
|
+
let root;
|
|
716
|
+
const scripts = [];
|
|
717
|
+
let fbRoot = false;
|
|
718
|
+
for (const node of nodes) {
|
|
719
|
+
if (node.type === "comment") continue;
|
|
720
|
+
if (node.type === "text") {
|
|
721
|
+
if (/^\s*$/.test(node.raw)) continue;
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
if (node.tag === "script") scripts.push(node);
|
|
725
|
+
else if (isFbRoot(node)) fbRoot = true;
|
|
726
|
+
else if (root) return null;
|
|
727
|
+
else root = node;
|
|
728
|
+
}
|
|
729
|
+
if (!root) return null;
|
|
730
|
+
for (const platform of platforms) {
|
|
731
|
+
const embed = platform.parse(root);
|
|
732
|
+
if (!embed) continue;
|
|
733
|
+
if (fbRoot && !platform.allowsFbRoot) return null;
|
|
734
|
+
const sources = scripts.map((script) => script.children.length === 0 ? script.attrs.get("src") : void 0);
|
|
735
|
+
if (!sources.every((src) => src !== void 0 && platform.isOwnScript(src))) return null;
|
|
736
|
+
const fromScripts = sources.map((src) => src === void 0 ? {} : platform.scriptOptions?.(src) ?? {});
|
|
737
|
+
return {
|
|
738
|
+
...embed,
|
|
739
|
+
options: Object.assign({}, ...fromScripts, embed.options)
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
function isFbRoot(element) {
|
|
745
|
+
return element.tag === "div" && element.attrs.get("id") === "fb-root" && element.attrs.size === 1 && element.children.length === 0;
|
|
746
|
+
}
|
|
747
|
+
export { escapeAttribute as a, x as i, matchUrl as n, parseHtml as o, platforms as r, rebuild as s, parseEmbed as t };
|