discord-html-transcripts-fix 2.2.0 → 2.3.1

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.
@@ -20,34 +20,287 @@ const JSDELIVR = 'https://cdn.jsdelivr.net';
20
20
  const SPECIFIER = /["'](\/npm\/[^"']+)["']/g;
21
21
  // Anything the blob rewrite could not resolve on its own.
22
22
  const RELATIVE_SPECIFIER = /(?:from|import)\s*\(?\s*["']\.{1,2}\//;
23
- const TWEMOJI_URL = /https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/twemoji\/[0-9.]+\/svg\/[0-9a-f-]+\.svg/g;
24
23
 
25
- // Module graphs and emoji SVGs are identical for every transcript in a process,
26
- // so they are fetched once and reused. Keyed by URL.
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.
27
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();
28
68
  const emojiCache = new Map();
69
+ const failures = new Map();
70
+ const failureReasons = new Map();
71
+ const warnedUnavailable = new Set();
72
+ let warnedBadTimeout = false;
29
73
 
30
74
  function clearInlineAssetCache() {
31
75
  graphCache.clear();
76
+ graphInFlight.clear();
77
+ fileInFlight.clear();
78
+ fontCache.clear();
32
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;
33
110
  }
34
111
 
35
- async function fetchText(url, timeoutMs) {
36
- const res = await (0, undici_1.request)(url, { headersTimeout: timeoutMs, bodyTimeout: timeoutMs });
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
+ }
37
228
  if (res.statusCode !== 200) {
38
229
  await res.body.dump();
39
- throw new Error(`GET ${url} → HTTP ${res.statusCode}`);
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`);
40
240
  }
41
- return await res.body.text();
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;
42
266
  }
43
267
 
44
- // Walks the module graph breadth-first and returns the sources plus a
45
- // dependency-first ordering, so each module can be rewritten once all of its
46
- // dependencies already have a blob URL.
47
268
  async function fetchModuleGraph(entry, timeoutMs) {
48
269
  const cached = graphCache.get(entry);
49
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
+ }
50
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) {
51
304
  const sources = new Map();
52
305
  const deps = new Map();
53
306
  const queue = [entry];
@@ -55,11 +308,12 @@ async function fetchModuleGraph(entry, timeoutMs) {
55
308
  while (queue.length) {
56
309
  const spec = queue.shift();
57
310
  if (sources.has(spec)) continue;
58
- const src = await fetchText(JSDELIVR + spec, timeoutMs);
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);
59
313
  if (RELATIVE_SPECIFIER.test(src)) {
60
314
  // A relative specifier would resolve against the blob URL at runtime,
61
315
  // which cannot work — bail out rather than ship a broken page.
62
- throw new Error(`relative import in ${spec}`);
316
+ throw new AssetFetchError(`relative import in ${spec}`);
63
317
  }
64
318
  sources.set(spec, src);
65
319
  const own = [];
@@ -75,7 +329,7 @@ async function fetchModuleGraph(entry, timeoutMs) {
75
329
  const visit = (node) => {
76
330
  const s = state.get(node);
77
331
  if (s === 'done') return;
78
- if (s === 'open') throw new Error(`import cycle at ${node}`);
332
+ if (s === 'open') throw new AssetFetchError(`import cycle at ${node}`);
79
333
  state.set(node, 'open');
80
334
  for (const d of deps.get(node) || []) visit(d);
81
335
  state.set(node, 'done');
@@ -91,7 +345,7 @@ async function fetchModuleGraph(entry, timeoutMs) {
91
345
  let src = sources.get(spec);
92
346
  for (const dep of built) src = src.split(`"${dep}"`).join('""').split(`'${dep}'`).join("''");
93
347
  const leftover = src.match(SPECIFIER);
94
- if (leftover) throw new Error(`unresolved specifier ${leftover[0]} in ${spec}`);
348
+ if (leftover) throw new AssetFetchError(`unresolved specifier ${leftover[0]} in ${spec}`);
95
349
  built.add(spec);
96
350
  }
97
351
 
@@ -115,52 +369,131 @@ function buildBootstrap(graph) {
115
369
  `}catch(e){console.error('[discord-html-transcripts-fix] inlined component runtime is malformed:',e);}})();`;
116
370
  }
117
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
+
118
417
  async function inlineTwemoji(html, timeoutMs) {
119
- const urls = [...new Set(html.match(TWEMOJI_URL) || [])];
418
+ const urls = [...new Set([...html.matchAll(TWEMOJI_ATTRIBUTE)].map((m) => m[2]))];
120
419
  if (urls.length === 0) return html;
121
420
 
122
- let out = html;
123
- let inlined = 0;
124
- for (const url of urls) {
125
- let dataUri = emojiCache.get(url);
126
- if (dataUri === undefined) {
127
- try {
128
- const svg = await fetchText(url, timeoutMs);
129
- dataUri = 'data:image/svg+xml;base64,' + Buffer.from(svg, 'utf8').toString('base64');
130
- }
131
- catch (err) {
132
- // A missing emoji must not fail the export — the original URL stays,
133
- // so that single image falls back to the CDN.
134
- log('twemoji %s failed: %s', url, err && err.message ? err.message : err);
135
- dataUri = null;
136
- }
137
- emojiCache.set(url, dataUri);
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'));
138
431
  }
139
- if (dataUri) {
140
- out = out.split(url).join(dataUri);
141
- inlined++;
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));
142
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(', ')}`);
143
452
  }
144
- log('inlined %d/%d emoji', inlined, urls.length);
145
- return out;
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));
146
476
  }
147
477
 
148
478
  /**
149
479
  * Replaces the third-party CDN references in a rendered transcript with inline
150
- * copies, so the file renders without jsDelivr or cdnjs.
480
+ * copies — the component runtime and the gg sans font from jsDelivr, the emoji
481
+ * from cdnjs — so the file renders without either.
151
482
  *
152
- * Discord's own CDN (avatars, attachments) is deliberately left alone — that is
153
- * what `saveImages` covers.
483
+ * Discord's own CDN is deliberately left alone: avatars always load from it, and
484
+ * attachment images do unless `saveImages` is set.
154
485
  *
155
- * Never throws: if anything cannot be fetched the original markup is returned
156
- * unchanged, which still renders correctly as long as the CDNs are reachable.
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.
157
488
  */
158
489
  async function inlineExternalAssets(html, options) {
159
490
  const opts = options || {};
160
- const timeoutMs = typeof opts.timeout === 'number' ? opts.timeout : 30000;
491
+ const timeoutMs = resolveTimeout(opts.timeout);
161
492
  let out = html;
493
+ let runtimeOnCdn = false;
494
+ let fontsOnCdn = false;
162
495
 
163
- const scriptTag = out.match(/<script[^>]*type="module"[^>]*src="https:\/\/cdn\.jsdelivr\.net(\/npm\/[^"]+)"[^>]*><\/script>/);
496
+ const scriptTag = out.match(/<script[^<>]*type="module"[^<>]*src="https:\/\/cdn\.jsdelivr\.net(\/npm\/[^"]+)"[^<>]*><\/script>/);
164
497
  if (scriptTag) {
165
498
  try {
166
499
  const graph = await fetchModuleGraph(scriptTag[1], timeoutMs);
@@ -171,23 +504,42 @@ async function inlineExternalAssets(html, options) {
171
504
  out = out.replace(scriptTag[0], () => tag);
172
505
  }
173
506
  catch (err) {
174
- console.warn('[discord-html-transcripts-fix] inlineAssets: could not inline the component runtime, ' +
175
- 'keeping the CDN reference — ' + (err && err.message ? err.message : err));
507
+ runtimeOnCdn = true;
508
+ reportFailure('the component runtime (kept on jsDelivr)', err);
176
509
  }
177
510
  }
178
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
+
179
522
  try {
180
523
  out = await inlineTwemoji(out, timeoutMs);
181
524
  }
182
525
  catch (err) {
183
- console.warn('[discord-html-transcripts-fix] inlineAssets: could not inline emoji, keeping CDN references — ' +
184
- (err && err.message ? err.message : err));
526
+ reportFailure('the emoji (kept on cdnjs)', err);
185
527
  }
186
528
 
187
- // The preconnect hints only make sense while the CDNs are still referenced.
188
- if (!out.includes('cdn.jsdelivr.net/npm/')) {
189
- out = out.replace(/<link[^>]*rel="preconnect"[^>]*href="https:\/\/cdn\.jsdelivr\.net\/"[^>]*\/?>/g, '');
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, '');
190
541
  }
191
542
 
192
543
  return out;
193
544
  }
545
+