discord-html-transcripts-fix 2.1.0 → 2.3.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.
@@ -0,0 +1,545 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.inlineExternalAssets = inlineExternalAssets;
7
+ exports.clearInlineAssetCache = clearInlineAssetCache;
8
+ const undici_1 = require("undici");
9
+ const utils_1 = require("./utils");
10
+ const debug_1 = __importDefault(require("debug"));
11
+
12
+ const log = (0, debug_1.default)('discord-html-transcripts:selfContained');
13
+
14
+ const JSDELIVR = 'https://cdn.jsdelivr.net';
15
+ // Every specifier inside the jsDelivr "+esm" graph is an absolute /npm/… path,
16
+ // which is what makes the blob rewrite below safe. Matching the quoted path
17
+ // directly rather than trying to parse import/export syntax is deliberate: a
18
+ // syntax-shaped pattern silently skipped side-effect imports (`import"/npm/x";`)
19
+ // because its optional `from` group ran ahead to the next `from` in the file.
20
+ const SPECIFIER = /["'](\/npm\/[^"']+)["']/g;
21
+ // Anything the blob rewrite could not resolve on its own.
22
+ const RELATIVE_SPECIFIER = /(?:from|import)\s*\(?\s*["']\.{1,2}\//;
23
+
24
+ const TWEMOJI = String.raw`https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/twemoji\/[0-9.]+\/svg\/[0-9a-f-]+\.svg`;
25
+ // Only attribute values the renderer itself emits: <img src> for emoji in text
26
+ // and embeds, and emoji="" on reactions, buttons and selects. Message text, the
27
+ // data-text search index and user-posted links are never rewritten — a transcript
28
+ // is an archive of what people actually wrote.
29
+ // `[^<>]*` rather than `[^>]*` inside tags: identical on real output, where React
30
+ // escapes every `<`, but it keeps a malformed document from backtracking for minutes.
31
+ const TWEMOJI_ATTRIBUTE = new RegExp(String.raw`(<img\b[^<>]*?\ssrc="|\semoji=")(` + TWEMOJI + ')(")', 'g');
32
+ // React emits a preload hint per <img>. Once the image is inline, keeping it would
33
+ // only make the page reach cdnjs again.
34
+ const TWEMOJI_PRELOAD = new RegExp(String.raw`<link\b[^<>]*\brel="preload"[^<>]*\bhref="(` + TWEMOJI + String.raw`)"[^<>]*\/?>`, 'g');
35
+ const TWEMOJI_HOST = 'https://cdnjs.cloudflare.com';
36
+ // The gg sans @font-face rules live in a <style> element the generator writes
37
+ // itself. Only url() values inside <style> elements are touched: React escapes
38
+ // every piece of user text, so no user can produce a raw <style> element, while
39
+ // the same URL typed into a message would otherwise be rewritten too.
40
+ const STYLE_BLOCK = /<style\b[^>]*>[\s\S]*?<\/style>/g;
41
+ const FONT_SRC = new RegExp(String.raw`url\((https:\/\/cdn\.jsdelivr\.net\/gh\/Tyrrrz\/DiscordFonts@[\w.-]+\/[\w-]+\.woff2)\)`, 'g');
42
+
43
+ const DEFAULT_TIMEOUT_MS = 30000;
44
+ // setTimeout clamps anything above this to 1 ms, which would abort every request.
45
+ const MAX_TIMEOUT_MS = 2147483647;
46
+ // A failed download is remembered this long. A CDN outage then costs one timeout
47
+ // per window instead of one per transcript, without pinning a single transient
48
+ // failure for the whole lifetime of a long-running bot.
49
+ const FAILURE_TTL_MS = 60000;
50
+ const FETCH_CONCURRENCY = 6;
51
+ // The real runtime is about 36 modules. A graph far beyond that is not the
52
+ // component library any more, and walking it would just burn requests.
53
+ const MAX_MODULES = 200;
54
+ // Failure entries are keyed by URL and only pruned on lookup; this bounds them for
55
+ // bots that live for months.
56
+ const MAX_FAILURE_ENTRIES = 1000;
57
+
58
+ // Module graphs, fonts and emoji SVGs are identical for every transcript in a
59
+ // process, so successful downloads are kept for good. Failures are kept only for
60
+ // FAILURE_TTL_MS, so the first transcript after an outage retries.
61
+ const graphCache = new Map();
62
+ // Concurrent exports share one in-flight graph download instead of each walking
63
+ // all 36 modules at once.
64
+ const graphInFlight = new Map();
65
+ // Same for single fonts and emoji, keyed by URL.
66
+ const fileInFlight = new Map();
67
+ const fontCache = new Map();
68
+ const emojiCache = new Map();
69
+ const failures = new Map();
70
+ const failureReasons = new Map();
71
+ const warnedUnavailable = new Set();
72
+ let warnedBadTimeout = false;
73
+
74
+ function clearInlineAssetCache() {
75
+ graphCache.clear();
76
+ graphInFlight.clear();
77
+ fileInFlight.clear();
78
+ fontCache.clear();
79
+ emojiCache.clear();
80
+ failures.clear();
81
+ failureReasons.clear();
82
+ warnedUnavailable.clear();
83
+ warnedBadTimeout = false;
84
+ }
85
+
86
+ // Environment variables arrive as strings, so "5000" is taken as 5000 ms. Anything else
87
+ // that is not a usable duration falls back to the default — 0 used to mean "no timeout"
88
+ // to undici and hung the export, negative, NaN and Infinity threw — and says so once.
89
+ function resolveTimeout(value) {
90
+ if (value === undefined || value === null) return DEFAULT_TIMEOUT_MS;
91
+ const n = typeof value === 'string' && /^\s*\d+\s*$/.test(value) ? Number(value) : value;
92
+ if (Number.isInteger(n) && n > 0 && n <= MAX_TIMEOUT_MS) return n;
93
+ if (!warnedBadTimeout) {
94
+ warnedBadTimeout = true;
95
+ console.warn(`[discord-html-transcripts-fix] inlineAssetsTimeout must be a positive whole number of milliseconds ` +
96
+ `up to ${MAX_TIMEOUT_MS} (got ${typeof value === 'string' ? JSON.stringify(value) : String(value)}); using ${DEFAULT_TIMEOUT_MS}.`);
97
+ }
98
+ return DEFAULT_TIMEOUT_MS;
99
+ }
100
+
101
+ function recentlyFailed(key) {
102
+ const at = failures.get(key);
103
+ if (at === undefined) return false;
104
+ const age = Date.now() - at;
105
+ // A negative age means the clock was set back; treat the entry as expired
106
+ // rather than pinning the host until the clock catches up again.
107
+ if (age >= 0 && age < FAILURE_TTL_MS) return true;
108
+ failures.delete(key);
109
+ return false;
110
+ }
111
+
112
+ // Joins a download of the same URL that another export already started.
113
+ function sharedDownload(url, load) {
114
+ let pending = fileInFlight.get(url);
115
+ if (!pending) {
116
+ pending = load().finally(() => fileInFlight.delete(url));
117
+ fileInFlight.set(url, pending);
118
+ }
119
+ return pending;
120
+ }
121
+
122
+ function markFailed(key) {
123
+ if (failures.size >= MAX_FAILURE_ENTRIES) {
124
+ const now = Date.now();
125
+ for (const [k, at] of failures) {
126
+ const age = now - at;
127
+ if (age < 0 || age >= FAILURE_TTL_MS) failures.delete(k);
128
+ }
129
+ // Still full of live entries: drop the oldest (Map keeps insertion order).
130
+ while (failures.size >= MAX_FAILURE_ENTRIES) failures.delete(failures.keys().next().value);
131
+ }
132
+ failures.delete(key); // re-insert so the entry counts as newest
133
+ failures.set(key, Date.now());
134
+ }
135
+
136
+ // Everything this module treats as "the remote side is the problem" is an AssetFetchError.
137
+ // Anything else thrown in here is a bug in this module and must never be mistaken for a CDN problem.
138
+ class AssetFetchError extends Error {
139
+ constructor(message, { cause, statusCode, outage = false, unavailable = false } = {}) {
140
+ super(message, { cause });
141
+ this.name = 'AssetFetchError';
142
+ this.statusCode = statusCode;
143
+ this.outage = outage; // the host as a whole looks unusable: stop asking for more files
144
+ this.unavailable = unavailable; // this one file does not exist (404/410)
145
+ }
146
+ }
147
+
148
+ // Nothing answered, or the answer never finished. A single reset socket is NOT in this list:
149
+ // it concerns one request and must not blacklist the host for a minute.
150
+ const HARD_NETWORK_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'ENETUNREACH', 'EHOSTUNREACH',
151
+ 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT']);
152
+ function transportIsOutage(cause) {
153
+ const code = cause && (cause.code || (cause.cause && cause.cause.code));
154
+ return HARD_NETWORK_CODES.has(code) || (cause && cause.name === 'TimeoutError');
155
+ }
156
+
157
+ function isOutage(err) {
158
+ return err instanceof AssetFetchError && err.outage === true;
159
+ }
160
+
161
+ function describeError(err) {
162
+ return err && err.message ? err.message : String(err);
163
+ }
164
+
165
+ // Network problems are expected and tolerated; anything else is a bug in this file. Both keep the export
166
+ // alive (the documented contract), but a bug is reported loudly, with its stack, instead of as "CDN down".
167
+ function reportFailure(what, err) {
168
+ if (err instanceof AssetFetchError) {
169
+ console.warn(`[discord-html-transcripts-fix] inlineAssets: ${what} — ${err.message}`);
170
+ }
171
+ else {
172
+ console.error(`[discord-html-transcripts-fix] inlineAssets: INTERNAL ERROR while inlining ${what}; the CDN references ` +
173
+ 'are kept and the export continues. This is a bug in discord-html-transcripts-fix, please report it.', err);
174
+ }
175
+ }
176
+
177
+ // A rejecting callback stops new work from being scheduled, lets the in-flight items finish (no orphaned
178
+ // requests that outlive the caller) and is rethrown afterwards.
179
+ async function mapWithConcurrency(items, limit, fn) {
180
+ let next = 0;
181
+ let failed = null;
182
+ const worker = async () => {
183
+ while (next < items.length && !failed) {
184
+ const i = next++;
185
+ try {
186
+ await fn(items[i], i);
187
+ }
188
+ catch (err) {
189
+ failed = failed || { err };
190
+ }
191
+ }
192
+ };
193
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
194
+ if (failed) throw failed.err;
195
+ }
196
+
197
+ const MAX_ASSET_BYTES = 4 * 1024 * 1024;
198
+ // A captive portal, WAF page or proxy error that answers 200 with HTML/JSON is not the asset.
199
+ // The component runtime must be a script, not markup.
200
+ const LOOKS_LIKE_MODULE = /^\s*[^<\s]/;
201
+ const NOT_AN_ASSET = /text\/html|application\/json/i;
202
+
203
+ // undici applies an AbortSignal only once the request is bound to a socket, so a black-holed connect would
204
+ // outlast the requested timeout (verified: timeout 1500 ms -> 10.6 s). The deadline makes the *caller* stop
205
+ // waiting on time; the signal still tears the request down as soon as it gets a socket.
206
+ function withDeadline(promise, ms) {
207
+ let timer;
208
+ const deadline = new Promise((_, reject) => {
209
+ timer = setTimeout(() => reject(Object.assign(new Error(`timed out after ${ms} ms`), { name: 'TimeoutError' })), ms);
210
+ });
211
+ promise.catch(() => {}); // the loser of the race must not surface as an unhandled rejection
212
+ return Promise.race([promise, deadline]).finally(() => clearTimeout(timer));
213
+ }
214
+
215
+ async function fetchBody(url, timeoutMs) {
216
+ const signal = AbortSignal.timeout(timeoutMs); // a bad timeout value is a bug, not a network problem
217
+ let res;
218
+ try {
219
+ res = await withDeadline((0, undici_1.request)(url, {
220
+ headersTimeout: timeoutMs,
221
+ bodyTimeout: timeoutMs,
222
+ signal,
223
+ }), timeoutMs);
224
+ }
225
+ catch (cause) {
226
+ throw new AssetFetchError(`GET ${url} failed: ${describeError(cause)}`, { cause, outage: transportIsOutage(cause) });
227
+ }
228
+ if (res.statusCode !== 200) {
229
+ await res.body.dump();
230
+ throw new AssetFetchError(`GET ${url} → HTTP ${res.statusCode}`, {
231
+ statusCode: res.statusCode,
232
+ outage: res.statusCode >= 500 || res.statusCode === 429,
233
+ unavailable: res.statusCode === 404 || res.statusCode === 410,
234
+ });
235
+ }
236
+ const type = String(res.headers['content-type'] || '');
237
+ if (NOT_AN_ASSET.test(type)) {
238
+ await res.body.dump();
239
+ throw new AssetFetchError(`GET ${url} answered with "${type}" instead of the asset`);
240
+ }
241
+ return res.body;
242
+ }
243
+
244
+ async function readBody(url, body, method) {
245
+ let out;
246
+ try {
247
+ out = await body[method]();
248
+ }
249
+ catch (cause) {
250
+ throw new AssetFetchError(`GET ${url} failed while reading the body: ${describeError(cause)}`, { cause, outage: transportIsOutage(cause) });
251
+ }
252
+ if ((out.length || out.byteLength) > MAX_ASSET_BYTES) throw new AssetFetchError(`GET ${url} is larger than ${MAX_ASSET_BYTES} bytes`);
253
+ return out;
254
+ }
255
+
256
+ async function fetchText(url, timeoutMs, mustMatch) {
257
+ const text = await readBody(url, await fetchBody(url, timeoutMs), 'text');
258
+ if (mustMatch && !mustMatch.test(text.slice(0, 512))) throw new AssetFetchError(`GET ${url} does not look like the expected asset`);
259
+ return text;
260
+ }
261
+
262
+ async function fetchBytes(url, timeoutMs, magic) {
263
+ const bytes = Buffer.from(await readBody(url, await fetchBody(url, timeoutMs), 'arrayBuffer'));
264
+ if (magic && bytes.subarray(0, magic.length).toString('latin1') !== magic) throw new AssetFetchError(`GET ${url} does not look like the expected asset`);
265
+ return bytes;
266
+ }
267
+
268
+ async function fetchModuleGraph(entry, timeoutMs) {
269
+ const cached = graphCache.get(entry);
270
+ if (cached) return cached;
271
+ const pending = graphInFlight.get(entry);
272
+ if (pending) return pending;
273
+ if (recentlyFailed(entry) || recentlyFailed(JSDELIVR)) {
274
+ throw new AssetFetchError(`${entry}: an attempt failed less than ${FAILURE_TTL_MS / 1000}s ago (${failureReasons.get(entry) || failureReasons.get(JSDELIVR) || 'see the earlier warning'})`);
275
+ }
276
+ const attempt = (async () => {
277
+ try {
278
+ return await loadModuleGraph(entry, timeoutMs);
279
+ }
280
+ catch (err) {
281
+ if (err instanceof AssetFetchError) {
282
+ markFailed(entry);
283
+ failureReasons.set(entry, describeError(err));
284
+ // The fonts come from the same host; no point in waiting for them too.
285
+ if (isOutage(err)) {
286
+ markFailed(JSDELIVR);
287
+ failureReasons.set(JSDELIVR, describeError(err));
288
+ }
289
+ }
290
+ throw err;
291
+ }
292
+ finally {
293
+ graphInFlight.delete(entry);
294
+ }
295
+ })();
296
+ graphInFlight.set(entry, attempt);
297
+ return attempt;
298
+ }
299
+
300
+ // Walks the module graph breadth-first and returns the sources plus a
301
+ // dependency-first ordering, so each module can be rewritten once all of its
302
+ // dependencies already have a blob URL.
303
+ async function loadModuleGraph(entry, timeoutMs) {
304
+ const sources = new Map();
305
+ const deps = new Map();
306
+ const queue = [entry];
307
+
308
+ while (queue.length) {
309
+ const spec = queue.shift();
310
+ if (sources.has(spec)) continue;
311
+ if (sources.size >= MAX_MODULES) throw new AssetFetchError(`the runtime graph exceeds ${MAX_MODULES} modules`);
312
+ const src = await fetchText(JSDELIVR + spec, timeoutMs, LOOKS_LIKE_MODULE);
313
+ if (RELATIVE_SPECIFIER.test(src)) {
314
+ // A relative specifier would resolve against the blob URL at runtime,
315
+ // which cannot work — bail out rather than ship a broken page.
316
+ throw new AssetFetchError(`relative import in ${spec}`);
317
+ }
318
+ sources.set(spec, src);
319
+ const own = [];
320
+ for (const m of src.matchAll(SPECIFIER)) {
321
+ own.push(m[1]);
322
+ if (!sources.has(m[1])) queue.push(m[1]);
323
+ }
324
+ deps.set(spec, own);
325
+ }
326
+
327
+ const order = [];
328
+ const state = new Map();
329
+ const visit = (node) => {
330
+ const s = state.get(node);
331
+ if (s === 'done') return;
332
+ if (s === 'open') throw new AssetFetchError(`import cycle at ${node}`);
333
+ state.set(node, 'open');
334
+ for (const d of deps.get(node) || []) visit(d);
335
+ state.set(node, 'done');
336
+ order.push(node);
337
+ };
338
+ visit(entry);
339
+
340
+ // Dry-run of the browser-side rewrite. If any /npm/… specifier would survive
341
+ // it, the page would fail at runtime with an unresolvable module — better to
342
+ // find that here and keep the CDN reference instead.
343
+ const built = new Set();
344
+ for (const spec of order) {
345
+ let src = sources.get(spec);
346
+ for (const dep of built) src = src.split(`"${dep}"`).join('""').split(`'${dep}'`).join("''");
347
+ const leftover = src.match(SPECIFIER);
348
+ if (leftover) throw new AssetFetchError(`unresolved specifier ${leftover[0]} in ${spec}`);
349
+ built.add(spec);
350
+ }
351
+
352
+ const graph = { entry, order, sources: Object.fromEntries(sources) };
353
+ log('fetched %d modules for %s', order.length, entry);
354
+ graphCache.set(entry, graph);
355
+ return graph;
356
+ }
357
+
358
+ // Rebuilds the module graph in the browser: each module's /npm/… specifiers are
359
+ // swapped for the blob URL of the dependency built just before it, so no import
360
+ // map and no network access are involved.
361
+ function buildBootstrap(graph) {
362
+ const payload = (0, utils_1.safeJsonForScript)({ entry: graph.entry, order: graph.order, sources: graph.sources });
363
+ return `(function(){try{var G=${payload};var urls={};` +
364
+ `G.order.forEach(function(spec){var src=G.sources[spec];` +
365
+ `Object.keys(urls).forEach(function(dep){` +
366
+ `src=src.split('"'+dep+'"').join('"'+urls[dep]+'"').split("'"+dep+"'").join("'"+urls[dep]+"'");});` +
367
+ `urls[spec]=URL.createObjectURL(new Blob([src],{type:'text/javascript'}));});` +
368
+ `import(urls[G.entry]).catch(function(e){console.error('[discord-html-transcripts-fix] inlined component runtime failed to start:',e);});` +
369
+ `}catch(e){console.error('[discord-html-transcripts-fix] inlined component runtime is malformed:',e);}})();`;
370
+ }
371
+
372
+ // Returns the rewritten markup plus how many font files still point at the CDN,
373
+ // which decides whether the preconnect hint may go.
374
+ async function inlineFonts(html, timeoutMs) {
375
+ const urls = [...new Set((html.match(STYLE_BLOCK) || []).flatMap((block) => [...block.matchAll(FONT_SRC)].map((m) => m[1])))];
376
+ if (urls.length === 0) return { html, remaining: 0 };
377
+
378
+ let outage = recentlyFailed(JSDELIVR);
379
+ const causes = [];
380
+ await mapWithConcurrency(urls, FETCH_CONCURRENCY, async (url) => {
381
+ if (outage || fontCache.has(url) || recentlyFailed(url)) return;
382
+ try {
383
+ const bytes = await sharedDownload(url, () => fetchBytes(url, timeoutMs, 'wOF2'));
384
+ fontCache.set(url, 'data:font/woff2;base64,' + bytes.toString('base64'));
385
+ }
386
+ catch (err) {
387
+ if (!(err instanceof AssetFetchError)) throw err; // a bug: do not mask it, do not blame the host
388
+ markFailed(url);
389
+ causes.push(describeError(err));
390
+ if (isOutage(err)) {
391
+ outage = true;
392
+ markFailed(JSDELIVR);
393
+ }
394
+ log('font %s failed: %s', url, describeError(err));
395
+ }
396
+ });
397
+
398
+ const remaining = urls.filter((url) => !fontCache.has(url)).length;
399
+ if (remaining > 0) {
400
+ // The page still renders — the stack falls back to a system font — but the
401
+ // caller asked for a self-contained file, so say that it is not one, and why.
402
+ const why = causes[0] || (outage ? 'jsDelivr was marked unreachable by an earlier failure' : 'unknown');
403
+ console.warn(`[discord-html-transcripts-fix] inlineAssets: ${remaining} of ${urls.length} font files could not be ` +
404
+ `inlined and still load from jsDelivr (${why}).`);
405
+ }
406
+ log('inlined %d/%d font files', urls.length - remaining, urls.length);
407
+
408
+ return {
409
+ html: html.replace(STYLE_BLOCK, (block) => block.replace(FONT_SRC, (whole, url) => {
410
+ const dataUri = fontCache.get(url);
411
+ return dataUri ? `url(${dataUri})` : whole;
412
+ })),
413
+ remaining,
414
+ };
415
+ }
416
+
417
+ async function inlineTwemoji(html, timeoutMs) {
418
+ const urls = [...new Set([...html.matchAll(TWEMOJI_ATTRIBUTE)].map((m) => m[2]))];
419
+ if (urls.length === 0) return html;
420
+
421
+ // After an outage, stop paying the timeout for every remaining emoji — within
422
+ // this transcript and, via the TTL, for the ones rendered right after it.
423
+ let outage = recentlyFailed(TWEMOJI_HOST);
424
+ const causes = [];
425
+ const unavailable = new Set();
426
+ await mapWithConcurrency(urls, FETCH_CONCURRENCY, async (url) => {
427
+ if (outage || emojiCache.has(url) || recentlyFailed(url)) return;
428
+ try {
429
+ const svg = await sharedDownload(url, () => fetchText(url, timeoutMs, /<svg\b/));
430
+ emojiCache.set(url, 'data:image/svg+xml;base64,' + Buffer.from(svg, 'utf8').toString('base64'));
431
+ }
432
+ catch (err) {
433
+ if (!(err instanceof AssetFetchError)) throw err;
434
+ markFailed(url);
435
+ if (err.unavailable) unavailable.add(url);
436
+ else causes.push(describeError(err));
437
+ if (isOutage(err)) {
438
+ outage = true;
439
+ markFailed(TWEMOJI_HOST);
440
+ }
441
+ log('twemoji %s failed: %s', url, describeError(err));
442
+ }
443
+ });
444
+
445
+ // Emoji newer than Twemoji 14.0.2 do not exist on the CDN at all: they are broken with or without
446
+ // inlining and nothing here can fix it, so say so once per emoji instead of on every export.
447
+ const fresh = [...unavailable].filter((url) => !warnedUnavailable.has(url));
448
+ fresh.forEach((url) => warnedUnavailable.add(url));
449
+ if (fresh.length > 0) {
450
+ console.warn(`[discord-html-transcripts-fix] inlineAssets: ${fresh.length} emoji ${fresh.length === 1 ? "does" : "do"} not exist in Twemoji 14.0.2 and cannot be ` +
451
+ `inlined (they render as broken images either way): ${fresh.map((u) => u.split('/').pop()).join(', ')}`);
452
+ }
453
+ const missing = urls.filter((url) => !emojiCache.has(url) && !unavailable.has(url) && !warnedUnavailable.has(url)).length;
454
+ if (missing > 0) {
455
+ // Not fatal — those images simply keep loading from cdnjs — but the caller
456
+ // asked for a self-contained file, so say that it is not one, and why.
457
+ const why = causes[0] || (outage ? 'cdnjs was marked unreachable by an earlier failure' : 'unknown');
458
+ console.warn(`[discord-html-transcripts-fix] inlineAssets: ${missing} of ${urls.length} emoji could not be ` +
459
+ `inlined and still load from cdnjs (${why}).`);
460
+ }
461
+ log('inlined %d/%d emoji', urls.length - missing - unavailable.size, urls.length);
462
+
463
+ // Replacements are functions on purpose: neither the data URI nor the matched
464
+ // text may be read as a `$&`-style replacement pattern.
465
+ return html
466
+ .replace(TWEMOJI_ATTRIBUTE, (whole, before, url, after) => {
467
+ const dataUri = emojiCache.get(url);
468
+ if (!dataUri) return whole;
469
+ // <discord-reaction emoji=""> only draws an <img> when the value contains
470
+ // "http" or starts with "/" (DiscordReaction.js render()); anything else is
471
+ // printed as text, so a plain data URI showed up as 2 kB of base64. A MIME
472
+ // parameter keeps the URI valid (RFC 2397) and satisfies that check.
473
+ return before + (before.endsWith('emoji="') ? dataUri.replace(';base64,', ';name=http;base64,') : dataUri) + after;
474
+ })
475
+ .replace(TWEMOJI_PRELOAD, (whole, url) => (emojiCache.has(url) ? '' : whole));
476
+ }
477
+
478
+ /**
479
+ * Replaces the third-party CDN references in a rendered transcript with inline
480
+ * copies — the component runtime and the gg sans font from jsDelivr, the emoji
481
+ * from cdnjs — so the file renders without either.
482
+ *
483
+ * Discord's own CDN is deliberately left alone: avatars always load from it, and
484
+ * attachment images do unless `saveImages` is set.
485
+ *
486
+ * Never throws: if anything cannot be fetched the original reference is kept,
487
+ * which still renders correctly as long as the CDNs are reachable.
488
+ */
489
+ async function inlineExternalAssets(html, options) {
490
+ const opts = options || {};
491
+ const timeoutMs = resolveTimeout(opts.timeout);
492
+ let out = html;
493
+ let runtimeOnCdn = false;
494
+ let fontsOnCdn = false;
495
+
496
+ const scriptTag = out.match(/<script[^<>]*type="module"[^<>]*src="https:\/\/cdn\.jsdelivr\.net(\/npm\/[^"]+)"[^<>]*><\/script>/);
497
+ if (scriptTag) {
498
+ try {
499
+ const graph = await fetchModuleGraph(scriptTag[1], timeoutMs);
500
+ const tag = `<script>${buildBootstrap(graph)}</script>`;
501
+ // Replacement MUST be a function: minified module sources contain `$'`
502
+ // and `$&`, which String.replace would expand as match references and
503
+ // splice half the document into the middle of the script.
504
+ out = out.replace(scriptTag[0], () => tag);
505
+ }
506
+ catch (err) {
507
+ runtimeOnCdn = true;
508
+ reportFailure('the component runtime (kept on jsDelivr)', err);
509
+ }
510
+ }
511
+
512
+ try {
513
+ const fonts = await inlineFonts(out, timeoutMs);
514
+ out = fonts.html;
515
+ fontsOnCdn = fonts.remaining > 0;
516
+ }
517
+ catch (err) {
518
+ fontsOnCdn = true;
519
+ reportFailure('the fonts (kept on jsDelivr)', err);
520
+ }
521
+
522
+ try {
523
+ out = await inlineTwemoji(out, timeoutMs);
524
+ }
525
+ catch (err) {
526
+ reportFailure('the emoji (kept on cdnjs)', err);
527
+ }
528
+
529
+ // Residual audit: decide from what is actually left in the document, not from which code path ran.
530
+ // Matchers that stop recognising the markup would otherwise leave CDN references behind without a word.
531
+ const leftOnJsdelivr = /<script\b[^<>]*\bsrc=["']https:\/\/cdn\.jsdelivr\.net\//.test(out) ||
532
+ (out.match(STYLE_BLOCK) || []).some((block) => block.includes('cdn.jsdelivr.net'));
533
+ if (leftOnJsdelivr && !runtimeOnCdn && !fontsOnCdn) {
534
+ console.warn('[discord-html-transcripts-fix] inlineAssets: the transcript still loads assets from jsDelivr but no download failed — ' +
535
+ 'the markup was not recognised. This is a bug in discord-html-transcripts-fix, please report it.');
536
+ }
537
+ // A preconnect makes the browser open a connection to jsDelivr on page load — exactly the request
538
+ // inlining exists to avoid. It stays only while something this package emitted still loads from there.
539
+ if (!leftOnJsdelivr) {
540
+ out = out.replace(/<link[^<>]*rel="preconnect"[^<>]*href="https:\/\/cdn\.jsdelivr\.net\/"[^<>]*\/?>/g, '');
541
+ }
542
+
543
+ return out;
544
+ }
545
+
@@ -9,13 +9,48 @@ exports.parseDiscordEmoji = parseDiscordEmoji;
9
9
  exports.streamToString = streamToString;
10
10
  exports.safeJsonForScript = safeJsonForScript;
11
11
  exports.safeHref = safeHref;
12
+ exports.safeImageSrc = safeImageSrc;
13
+ exports.safeLinkHref = safeLinkHref;
14
+ exports.describeError = describeError;
12
15
  exports.safeColor = safeColor;
13
16
  exports.safeImageMime = safeImageMime;
14
17
  exports.escapeHtml = escapeHtml;
15
18
  exports.resolveTimestampFormat = resolveTimestampFormat;
16
19
  exports.formatMessageTimestamp = formatMessageTimestamp;
20
+ exports.isForwardReference = isForwardReference;
21
+ exports.isForwardMessage = isForwardMessage;
22
+ exports.isLibraryStructure = isLibraryStructure;
23
+ const discord_js_1 = require("discord.js");
17
24
  const twemoji_1 = __importDefault(require("twemoji"));
18
25
 
26
+ // MessageReferenceType.Forward. Spelled as the API value on purpose: the enum only
27
+ // exists from discord.js 14.16, and reading it on an older 14.x throws for every
28
+ // message that has a reference.
29
+ const MESSAGE_REFERENCE_TYPE_FORWARD = 1;
30
+ // MessageFlags.HasSnapshot, for the same reason.
31
+ const MESSAGE_FLAG_HAS_SNAPSHOT = 1 << 14;
32
+
33
+ function isForwardReference(reference) {
34
+ return !!reference && reference.type === MESSAGE_REFERENCE_TYPE_FORWARD;
35
+ }
36
+
37
+ // Before 14.16, discord.js dropped the reference type and the snapshots, so a
38
+ // forward looked exactly like a reply. The message flags are kept raw by every
39
+ // 14.x and still tell the two apart.
40
+ function isForwardMessage(message) {
41
+ if (!message) return false;
42
+ if (isForwardReference(message.reference)) return true;
43
+ const flags = typeof message.flags === 'number' ? message.flags : message.flags?.bitfield;
44
+ return typeof flags === 'number' && (flags & MESSAGE_FLAG_HAS_SNAPSHOT) !== 0;
45
+ }
46
+
47
+ // True for anything discord.js built, as opposed to plain objects a caller passed
48
+ // in. Every structure carries its client; the second check also holds when a
49
+ // second copy of discord.js is installed and `instanceof` fails.
50
+ function isLibraryStructure(value) {
51
+ return value instanceof discord_js_1.Base || (value !== null && typeof value === 'object' && 'client' in value);
52
+ }
53
+
19
54
  const DEFAULT_TIMESTAMP_FORMAT = { dateFormat: 'dd/mm/yyyy', timeFormat: '24h' };
20
55
 
21
56
  function pad2(n) {
@@ -25,11 +60,27 @@ function pad2(n) {
25
60
  // Normalizes the user-facing options once, at render start, so every renderer
26
61
  // shares one reference "now" — otherwise a transcript rendered across midnight
27
62
  // could label the same day both "today" and with a full date.
63
+ // Accepts the documented values case-insensitively ('12H', 'MM/DD/YYYY'); anything
64
+ // else falls back to the default and is reported once per value instead of silently.
65
+ const warnedChoices = new Set();
66
+ function resolveChoice(name, value, allowed, fallback) {
67
+ if (value === undefined || value === null) return fallback;
68
+ const normalized = typeof value === 'string' ? value.trim().toLowerCase() : value;
69
+ if (allowed.includes(normalized)) return normalized;
70
+ const key = name + ':' + String(value);
71
+ if (!warnedChoices.has(key)) {
72
+ warnedChoices.add(key);
73
+ console.warn(`[discord-html-transcripts-fix] ${name} must be one of ${allowed.map((a) => `'${a}'`).join(', ')} ` +
74
+ `(got ${typeof value === 'string' ? JSON.stringify(value) : String(value)}); using '${fallback}'.`);
75
+ }
76
+ return fallback;
77
+ }
78
+
28
79
  function resolveTimestampFormat(options) {
29
80
  const o = options || {};
30
81
  return {
31
- dateFormat: o.dateFormat === 'mm/dd/yyyy' ? 'mm/dd/yyyy' : DEFAULT_TIMESTAMP_FORMAT.dateFormat,
32
- timeFormat: o.timeFormat === '12h' ? '12h' : DEFAULT_TIMESTAMP_FORMAT.timeFormat,
82
+ dateFormat: resolveChoice('dateFormat', o.dateFormat, ['dd/mm/yyyy', 'mm/dd/yyyy'], DEFAULT_TIMESTAMP_FORMAT.dateFormat),
83
+ timeFormat: resolveChoice('timeFormat', o.timeFormat, ['24h', '12h'], DEFAULT_TIMESTAMP_FORMAT.timeFormat),
33
84
  now: o.now instanceof Date ? o.now : new Date(),
34
85
  };
35
86
  }
@@ -144,6 +195,49 @@ function safeHref(url) {
144
195
  }
145
196
  }
146
197
 
198
+ // For URLs that end up as an image source or a file link inside a component, which
199
+ // renders them unchecked: web URLs, inline images, and Discord's attachment://
200
+ // references. Anything else — javascript:, other data: — becomes undefined.
201
+ function safeImageSrc(url) {
202
+ if (typeof url !== 'string' || !url) return undefined;
203
+ const trimmed = url.trim();
204
+ if (/^data:image\//i.test(trimmed) || /^attachment:\/\//i.test(trimmed)) return url;
205
+ try {
206
+ const proto = new URL(trimmed, 'https://example.invalid').protocol.toLowerCase();
207
+ return proto === 'http:' || proto === 'https:' ? url : undefined;
208
+ } catch (_e) {
209
+ return undefined;
210
+ }
211
+ }
212
+
213
+ // For links a component renders without checking them: what safeHref allows, plus
214
+ // the two Discord schemes such links legitimately carry — discord: (link buttons into
215
+ // the client) and attachment:// (file components). Neither runs anything.
216
+ function safeLinkHref(url) {
217
+ if (typeof url !== 'string' || !url) return undefined;
218
+ const trimmed = url.trim();
219
+ if (/^attachment:\/\//i.test(trimmed)) return url;
220
+ if (/^discord:/i.test(trimmed)) {
221
+ try {
222
+ new URL(trimmed);
223
+ return url;
224
+ } catch (_e) {
225
+ return '#';
226
+ }
227
+ }
228
+ return safeHref(url);
229
+ }
230
+
231
+ // An error for a log line: its message and stack only. A whole error object can
232
+ // carry what a caller's HTTP client attached to it, request headers included.
233
+ function describeError(err) {
234
+ try {
235
+ return err instanceof Error ? (err.stack || err.message) : String(err);
236
+ } catch (_e) {
237
+ return 'an error that cannot be printed';
238
+ }
239
+ }
240
+
147
241
  function safeColor(c, fallback = '#5865F2') {
148
242
  if (typeof c !== 'string') return fallback;
149
243
  return /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : fallback;