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.
package/README.md CHANGED
@@ -14,38 +14,20 @@ Forked from [discord-html-transcripts](https://github.com/ItzDerock/discord-html
14
14
  - **discord.js v14 or v15** — required peer dependency.
15
15
  - **[`sharp`](https://sharp.pixelplumbing.com/)** — *optional* peer dependency, only needed if you use `.withCompression()` to compress / convert transcript images to WebP.
16
16
 
17
+ The **generated HTML** additionally reaches out to third-party CDNs when it is *opened*
18
+ — jsDelivr for the `<discord-*>` component runtime and the gg sans font, cdnjs for
19
+ Twemoji SVGs. Set [`inlineAssets: true`](#self-contained-transcripts) to embed them and
20
+ get a file that renders offline.
21
+
17
22
  ## Install
18
23
 
19
24
  ```bash
20
25
  npm install discord-html-transcripts-fix
21
26
  ```
22
27
 
23
- `discord.js` is the only **required** peer dependency — React, Lit SSR, the markdown parser, etc. are installed automatically. `sharp` is an optional peer (image compression only).
24
-
25
- <details>
26
- <summary>Silencing the <code>node-domexception</code> deprecation warning on install</summary>
27
-
28
- Lit SSR depends on `node-fetch`, which still pulls in the deprecated
29
- `fetch-blob → node-domexception` chain, so `npm install` prints:
28
+ `discord.js` is the only **required** peer dependency — React, the markdown parser, etc. are installed automatically. `sharp` is an optional peer (image compression only).
30
29
 
31
- ```
32
- npm warn deprecated node-domexception@1.0.0: Use your platform's native DOMException instead
33
- ```
34
-
35
- It is cosmetic — install-time only, never at runtime, and `npm audit` reports nothing
36
- for it. If you want it gone, swap `node-fetch` for a dependency-free drop-in in **your
37
- own** `package.json` (npm only honours `overrides` in the root project, so this cannot
38
- be shipped from here):
39
-
40
- ```json
41
- {
42
- "overrides": {
43
- "node-fetch": "npm:node-fetch-native@^1.6.7"
44
- }
45
- }
46
- ```
47
-
48
- </details>
30
+ The install is clean: no deprecation warnings and no `npm audit` findings.
49
31
 
50
32
  ## Quick start
51
33
 
@@ -85,9 +67,11 @@ const stream = await createTranscript(channel, {
85
67
  | `filter` | `(m) => boolean` | `() => true` | Predicate to filter messages. |
86
68
  | `returnType` | `'attachment'` \| `'buffer'` \| `'string'` \| `'stream'` | `'attachment'` | Return value shape. In TypeScript pass the `ExportReturnType` enum. `'stream'` returns a Node `Readable` and is best for 5k+ message exports. |
87
69
  | `filename` | `string` | `transcript-{channel-id}.html` | Output filename when returning as attachment. |
88
- | `saveImages` | `boolean` | `false` | Download images and inline them as base64 data URLs. |
70
+ | `saveImages` | `boolean` | `false` | Download image **attachments** and inline them as base64 data URLs. Avatars, embed images and custom emoji keep loading from Discord. |
89
71
  | `favicon` | `'guild'` \| `string` | `'guild'` | Page favicon — `'guild'` uses the server icon, or pass a URL. |
90
- | `hydrate` | `boolean` | `false` | Server-side hydrate via `@lit-labs/ssr` (slower; usually leave off). |
72
+ | `hydrate` | `boolean` | `false` | Enables the client-side spoiler-reveal script. |
73
+ | `inlineAssets` | `boolean` | `false` | Embed the component runtime, the gg sans font and the emoji so the file renders without third-party CDNs. See below. |
74
+ | `inlineAssetsTimeout` | `number` | `30000` | Timeout in ms for each of those downloads, connection setup included. |
91
75
  | `dateFormat` | `'dd/mm/yyyy'` \| `'mm/dd/yyyy'` | `'dd/mm/yyyy'` | Date order for message timestamps older than yesterday. |
92
76
  | `timeFormat` | `'24h'` \| `'12h'` | `'24h'` | Clock format for message timestamps — `07:16` vs `07:16 AM`. |
93
77
  | `language` | `'en'` \| `'de'` | `'en'` | UI language for participant labels, filter strings, etc. |
@@ -97,6 +81,58 @@ const stream = await createTranscript(channel, {
97
81
  | `poweredBy` | `boolean` | `false` | Show the original "Powered by discord-html-transcripts" credit link. Only renders when `statsFooter` is disabled. |
98
82
  | `callbacks` | `{ resolveUser, resolveRole, resolveChannel, resolveImageSrc }` | — | Custom resolvers for mentions / image URLs. |
99
83
 
84
+ ### Self-contained transcripts
85
+
86
+ By default the rendered file is not standalone: opening it fetches the `<discord-*>`
87
+ component runtime and the gg sans font from jsDelivr and the emoji SVGs from cdnjs.
88
+ Offline, on a network that blocks those CDNs, or after a CDN outage the transcript
89
+ renders unstyled — and every viewer's IP reaches both CDNs, which can matter for
90
+ archived tickets.
91
+
92
+ ```js
93
+ await createTranscript(channel, {
94
+ inlineAssets: true,
95
+ saveImages: true, // also embeds image attachments
96
+ });
97
+ ```
98
+
99
+ `inlineAssets` embeds the component runtime, all ten gg sans font files and every emoji
100
+ the transcript shows, so opening the file makes **no request to a third-party CDN**.
101
+ Verified in a browser: it renders like the CDN version — reactions included — loads the
102
+ runtime from about 35 `blob:` modules, and the only remaining network request goes to
103
+ Discord's own CDN for avatars.
104
+
105
+ | | Default | `inlineAssets: true` |
106
+ | --- | --- | --- |
107
+ | File size | ~72 kB | ~960 kB |
108
+ | Hosts contacted on open | jsDelivr, cdnjs, Discord CDN | Discord CDN only (`cdn.discordapp.com`, `media.discordapp.net`) |
109
+ | Without network | unstyled | styled; avatars, and attachments unless `saveImages` is set, are missing |
110
+
111
+ What stays on Discord's CDN: avatars always; attachment images unless `saveImages` is
112
+ set; embed images, stickers, custom emoji and Components V2 media. Emoji typed as URLs
113
+ in messages are never rewritten — only the images the renderer itself emits are
114
+ embedded.
115
+
116
+ The downloads happen once per process and are cached, and concurrent exports share
117
+ them, so the first transcript pays about a second and later ones are unaffected. If an
118
+ asset cannot be fetched the CDN reference is kept and a warning naming the cause is
119
+ logged — the export never fails over this:
120
+
121
+ - A CDN that is unreachable or answers 5xx is given up on after the first failed wave
122
+ instead of waiting out the timeout for every file, and retried a minute later.
123
+ - A response that is not the expected file (a captive portal or error page answering
124
+ `200`) is never embedded and never cached.
125
+ - Emoji newer than Twemoji 14.0.2 do not exist on the CDN at all; they are reported once
126
+ per process instead of on every export.
127
+ - A bug in the inliner itself goes to `console.error` with its stack rather than being
128
+ reported as a CDN problem.
129
+
130
+ `inlineAssetsTimeout` (default `30000` ms) caps each download, connection setup
131
+ included; numeric strings from environment variables are accepted. Per-file details are
132
+ available with `DEBUG=discord-html-transcripts:selfContained`. With a stream return type
133
+ the document is buffered before it is streamed, so `stream` saves no memory in
134
+ combination with `inlineAssets`.
135
+
100
136
  ### Message timestamps
101
137
 
102
138
  Timestamps are worded the way Discord words them, relative to when the transcript was
@@ -193,11 +229,11 @@ In addition to plain text, replies, embeds, and attachments, the viewer supports
193
229
  - **Action rows** — buttons with proper spacing and Discord-style colors (`primary`, `secondary`, `success`, `destructive`)
194
230
  - **Stickers** — PNG, APNG, GIF, Lottie placeholder
195
231
  - **Polls** — question, answer bars with vote counts and percentages, expiry
196
- - **Forwarded messages** (`messageSnapshots`) — quoted-block style with original author, recursive nesting
232
+ - **Forwarded messages** (`messageSnapshots`) — quoted-block style with their text, attachments, embeds and components, naming the source channel (same server only, and never the original author, which Discord hides), recursive nesting; a forward whose snapshot is missing is marked as unavailable
197
233
  - **Voice messages** — `🎤` indicator, inline SVG waveform from `attachment.waveform`, duration
198
234
  - **Pinned messages** — Discord-style amber left rail (no extra icon clutter)
199
235
  - **Slash command interactions** — `{user} used /cmd` header + clickable pill that reveals parameters
200
- - **System messages** — `ChannelPinnedMessage`, `ChannelNameChange`, `ChannelIconChange`, `ThreadCreated`, `ChatInputCommand`, `ContextMenuCommand`, `Call`, `ChannelFollowAdd`, `RecipientRemove`, `RoleSubscriptionPurchase`, guild incident reports, poll result, AutoMod actions
236
+ - **System messages** — `ChannelPinnedMessage`, `ChannelNameChange`, `ChannelIconChange`, `ThreadCreated`, `ChatInputCommand`, `ContextMenuCommand`, `Call`, `ChannelFollowAdd`, `RecipientRemove`, `RoleSubscriptionPurchase`, guild incident reports, poll result, AutoMod actions, and any other type as a neutral "System message" line
201
237
  - **Cross-guild replies** — show a "Message from another server" pill
202
238
  - **Burst / super-reactions** flagged
203
239
  - **Thread state badges** — `Archived`, `Locked`
@@ -217,16 +253,33 @@ In addition to plain text, replies, embeds, and attachments, the viewer supports
217
253
  - **Critical fix** markdown links with `javascript:`, `data:`, `vbscript:` and other dangerous URI schemes are rewritten to `#`
218
254
  - **Hardened** inline `style="color:…"` sinks in the mention popup are hex-validated to block CSS injection
219
255
  - **Hardened** `data:` URI MIME types from the image downloader are restricted to image types only (no `text/html` smuggling)
256
+ - **Hardened** URLs that components render without checking them: image sources (embed author icons, thumbnails, images and footer icons, Components V2 thumbnails and media galleries) only pass as web URLs, inline images or `attachment://`; links (link buttons, Components V2 file links) only as web, `mailto:`, `discord:` or `attachment://` links. A `javascript:` file link in a Components V2 message used to stay clickable
220
257
  - **Fixed** `process.exit(1)` on discord.js version mismatch removed — library no longer kills the host bot
221
258
 
222
259
  ### Robustness
223
260
 
224
261
  - **Fix** invalid Discord timestamp markers (`<t:abc:F>`, oversized values) no longer abort the transcript with `RangeError`
225
262
  - **Fix** per-AST-node error boundary in `MessageSingleASTNode` and per-message error boundary in `DiscordMessage` — one broken message can never kill the whole render
263
+ - **Fix** a failed image download with `saveImages` — or a custom `resolveImageSrc` that throws — no longer fails the whole export: the image keeps its link and a warning names it
226
264
  - **Fix** `parseDiscordEmoji` no longer throws on deleted reactions with `emoji.name === null`
227
265
  - **Fix** `formatBytes(null/undefined/NaN)` no longer returns `NaN undefined`
228
266
  - **Fix** `createTranscript` slice uses the resolved limit instead of the raw `limit`
229
267
  - **Fix** `statsFooter` (custom template / `false`) is now forwarded end-to-end — it used to be silently ignored by `createTranscript`/`generateFromMessages`
268
+ - **Fix** `dateFormat`, `timeFormat`, `inlineAssets` and `inlineAssetsTimeout` are forwarded by `createTranscript`/`generateFromMessages` — 2.1.0 and 2.2.0 silently ignored them, the same way `statsFooter` once was. A test derived from the typings now fails whenever a declared option does not reach the renderer
269
+ - **Fix** `hydrate` combined with `returnType: 'stream'` / `stream: true` returns a `Readable` again instead of a string
270
+ - **Fix** `inlineAssets` hardened now that it actually runs: reaction emoji render as images instead of base64 text, user-typed URLs are never rewritten, the timeout also bounds connection setup, a CDN outage is not waited out per file, error pages are never embedded or cached, and a bug in the inliner is no longer reported as a CDN problem
271
+ - **Fix** `inlineAssets` + stream yields Buffer chunks like every other stream (a single string chunk broke `Buffer.concat`)
272
+ - **Fix** invalid `dateFormat` / `timeFormat` / `inlineAssetsTimeout` values are reported instead of silently replaced; case and numeric strings are tolerated
273
+ - **Fix** forwarded messages show their content — discord.js hands over `messageSnapshots` as a Collection, and an array check left every forward empty ("Message could not be loaded."). A forward is no longer mistaken for a reply or a cross-server message (on discord.js before 14.16 too), its header names the source channel when that channel is on the same server, and a forward whose snapshot is missing says so. The original author is never shown, as in Discord, even though discord.js hands it over when the bot has the original message cached
274
+ - **Fix** forwarded messages show their embeds and Components V2 content in full instead of a "1 embed" count, and forwarded images go through `saveImages` / `resolveImageSrc` like any other instead of staying on Discord's expiring CDN links. They also count in the stats footer and for the image, attachment and embed filters
275
+ - **Fix** link previews, GIFs (`gifv`) and video embeds get their own layout — discord.js keeps the embed type only in `embed.data`, so every embed rendered as `rich` and an image preview showed up as an empty card with a small thumbnail
276
+ - **Fix** super reactions and voice-message durations show — the renderer read the raw API names (`count_details`, `burst_colors`, `duration_secs`), which discord.js renames
277
+ - **Fix** reply authors get their role color, and embed footer icons and audio file sizes show — the values were passed as camelCase attributes, which HTML lowercases, so the components never received them. Members without a colored role are no longer painted black
278
+ - **Fix** reaction counts of 1,000 and more showed `NaN` — the abbreviated "1.5K" went to a component that only reads numbers; the full count is shown now
279
+ - **Fix** system messages of types without their own wording — Server Discovery notices, invite reminders, premium upsells, purchase notifications, and any type Discord adds later — were silently dropped from the transcript. They appear as a neutral "System message" line with Discord's text now (new i18n key `systemMessage`)
280
+ - **Fix** the stats footer counts one of something in the singular — "1 message · 1 participant · 1 image" instead of "1 messages", in German too (new i18n keys `statsMessage`, `statsParticipant`, `statsImage`)
281
+ - **Fix** select menu options show their emoji instead of its URL as text, and AutoMod alerts name the channel instead of printing `<#id>` (its id remains when the server does not know it)
282
+ - **Fix** profile cards no longer depend on the order users appear in: someone first seen without member data, as the user of a slash command for example, now gets their nickname, roles and color from their own messages. One malformed user or message in plain-object input no longer drops every profile of the transcript, and a guild member that cannot be read falls back to the user's own name and avatar
230
283
  - **Fix** embed fields render through a proper async component (was an inline `async` arrow inside `.map()`)
231
284
  - **Fix** `JoinMessage` text is deterministic per message id — re-rendering the same channel always yields the same join line
232
285
  - **Fix** random `console.log` calls in production paths replaced by the `debug` namespace
@@ -253,7 +306,8 @@ In addition to plain text, replies, embeds, and attachments, the viewer supports
253
306
  - **`sharp` declared as an optional peer dependency** — needed only for `.withCompression()`, no longer a hidden requirement
254
307
  - **Discord-style message timestamps** — today shows the bare time (`07:16`), yesterday reads `Yesterday at 07:16`, anything older gets `11/08/2026 07:16`. Configurable via `dateFormat` and `timeFormat`. Previously the raw ISO string (`2026-08-14T07:16:07.422Z`) was rendered, because the web component only formats real `Date` objects and an HTML attribute always arrives as a string
255
308
  - **`hydrate: true` actually works** — the markup was handed to Lit as a plain string, which Lit HTML-escapes, so the option emitted a page of visible `&lt;!DOCTYPE html&gt;…` source text instead of a transcript
256
- - **`lit` added as an explicit dependency** — it was previously only reachable as a transitive dependency of `@lit-labs/ssr`, which does not resolve under pnpm's strict layout
309
+ - **`@lit-labs/ssr` and `lit` removed entirely** — the `hydrate` path pushed the finished markup through Lit SSR, which was measured to contribute exactly four inert `<!--lit-part-->` comments and nothing else, since the page never loads a Lit hydration client. Two dependencies and the deprecated `node-fetch → fetch-blob → node-domexception` chain for four comments. `hydrate: true` keeps its real effect (the spoiler-reveal script), and the install is now warning-free
310
+ - **`inlineAssets` option** — embeds the component runtime, the gg sans font and the emoji so a transcript opens without any third-party CDN
257
311
  - **TypeScript declarations match runtime** — `ExportReturnType.Stream`, `language`, `i18n`, `stream`, and `withConcurrency()` are now exposed in the types
258
312
  - `discord.js` remains the only **required** peer dependency
259
313
 
@@ -267,6 +321,18 @@ In addition to plain text, replies, embeds, and attachments, the viewer supports
267
321
  | `TranscriptImageDownloader` | Builder for a custom image-saving callback (`withMaxSize`, `withConcurrency`, `withCompression`, `build`). |
268
322
  | `DiscordMessages` | The underlying React component, for advanced/custom rendering. |
269
323
 
324
+ ## Development
325
+
326
+ ```bash
327
+ npm test
328
+ npm run test:update
329
+ ```
330
+
331
+ `npm test` runs the unit tests and the golden transcripts: every scenario in
332
+ `tests/fixtures/scenarios.js` is rendered from real discord.js objects and compared with
333
+ `tests/golden/`. After an intended change to the output, `npm run test:update` rewrites
334
+ the snapshots — review the diff before committing it.
335
+
270
336
  ## License
271
337
 
272
338
  [Apache-2.0](./LICENSE) — same as the original package.
@@ -20,25 +20,15 @@ const static_1 = require("react-dom/static");
20
20
  const buildProfiles_1 = require("../utils/buildProfiles");
21
21
  const client_1 = require("../static/client");
22
22
  const fs_1 = require("fs");
23
+ const stream_1 = require("stream");
23
24
  const path_1 = __importDefault(require("path"));
25
+ const selfContained_1 = require("../utils/selfContained");
24
26
  const transcript_1 = __importDefault(require("./transcript"));
25
27
  const utils_1 = require("../utils/utils");
26
28
  const styles_1 = require("./renderers/components/styles");
27
29
  const DiscordImage_1 = require("./renderers/components/DiscordImage");
28
30
  const DiscordHighlightedCode_1 = require("./renderers/components/DiscordHighlightedCode");
29
31
 
30
- // Warn at most once per process when hydration is requested without @lit-labs/ssr.
31
- let warnedMissingLitSsr = false;
32
-
33
- // Moves <!DOCTYPE html> back to the very front of the document. Lit SSR emits
34
- // <!--lit-part--> markers around its result, and a comment before the doctype
35
- // makes browsers fall back to quirks mode.
36
- function hoistDoctype(markup) {
37
- const match = markup.match(/<!DOCTYPE\s+html[^>]*>/i);
38
- if (!match || markup.startsWith(match[0])) return markup;
39
- return match[0] + markup.slice(0, match.index) + markup.slice(match.index + match[0].length);
40
- }
41
-
42
32
  // Lock to an exact resolved version — semver ranges in src= aren't cacheable by CDNs.
43
33
  let discordComponentsVersion = '4.0.2';
44
34
  try {
@@ -49,16 +39,26 @@ try {
49
39
  if (/^\d+\.\d+\.\d+/.test(cleaned)) discordComponentsVersion = cleaned;
50
40
  } catch (_b) { /* ignore */ }
51
41
 
42
+ // Images of a message, including those it forwards — they are part of the transcript too.
43
+ function countImages(m, depth = 0) {
44
+ let n = 0;
45
+ if (m?.attachments && typeof m.attachments.forEach === 'function') {
46
+ m.attachments.forEach((a) => {
47
+ if (typeof a?.contentType === 'string' && a.contentType.startsWith('image/')) n++;
48
+ });
49
+ }
50
+ if (depth < 5 && m?.messageSnapshots && typeof m.messageSnapshots.forEach === 'function') {
51
+ m.messageSnapshots.forEach((snap) => { n += countImages(snap?.message || snap, depth + 1); });
52
+ }
53
+ return n;
54
+ }
55
+
52
56
  function computeStats(messages) {
53
57
  let images = 0;
54
58
  let humans = new Set();
55
59
  let firstTs = null, lastTs = null;
56
60
  for (const m of messages) {
57
- if (m.attachments && m.attachments.forEach) {
58
- m.attachments.forEach((a) => {
59
- if (typeof a.contentType === 'string' && a.contentType.startsWith('image/')) images++;
60
- });
61
- }
61
+ images += countImages(m);
62
62
  if (m.author && !m.author.bot) humans.add(m.author.id);
63
63
  const ts = m.createdAt instanceof Date ? m.createdAt.getTime() : (snowflakeToDate(m.id)?.getTime() || null);
64
64
  if (ts) {
@@ -114,7 +114,7 @@ async function render(_a) {
114
114
 
115
115
  // Single-pass collector (profiles + extended in one walk)
116
116
  const allCtx = await (0, buildProfiles_1.buildAllContext)(messages, channel).catch((e) => {
117
- console.warn('[discord-html-transcripts-fix] buildAllContext failed:', e?.message || e);
117
+ console.warn('[discord-html-transcripts-fix] buildAllContext failed:', e);
118
118
  return { profiles: {}, users: {}, roles: {}, channels: {} };
119
119
  });
120
120
 
@@ -192,44 +192,27 @@ async function render(_a) {
192
192
 
193
193
  const { prelude } = await (0, static_1.prerenderToNodeStream)(docTree);
194
194
 
195
- if (options.hydrate) {
196
- const markup = await (0, utils_1.streamToString)(prelude);
197
- // @lit-labs/ssr and lit are regular dependencies, so this normally always
198
- // resolves. The guard only covers a broken or partial install: instead of
199
- // failing the whole export we fall back to the non-SSR'd markup, which is
200
- // still a fully working transcript because the <discord-*> definitions are
201
- // loaded from the CDN module tag above regardless of this branch.
202
- try {
203
- const { render: renderLit } = await import('@lit-labs/ssr');
204
- const { html: litHtml } = await import('lit');
205
- const { unsafeHTML } = await import('lit/directives/unsafe-html.js');
206
- const { collectResult } = await import('@lit-labs/ssr/lib/render-result.js');
207
- // `markup` must go through unsafeHTML: handing lit a bare string makes
208
- // it a text value, which lit HTML-escapes — that turned the whole
209
- // transcript into visible &lt;!DOCTYPE html&gt;… source text.
210
- const result = renderLit(litHtml`${unsafeHTML(markup)}`);
211
- const rendered = await collectResult(result);
212
- // lit wraps its output in <!--lit-part--> markers. Any comment ahead of
213
- // the doctype pushes browsers into quirks mode and wrecks the layout,
214
- // so the doctype has to lead the document again.
215
- return hoistDoctype(rendered);
216
- }
217
- catch (err) {
218
- // Only a missing module triggers the fallback — a genuine render
219
- // failure must still surface instead of being silently swallowed.
220
- const code = err && err.code;
221
- if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') throw err;
222
- if (!warnedMissingLitSsr) {
223
- warnedMissingLitSsr = true;
224
- console.warn('[discord-html-transcripts-fix] `hydrate: true` could not load @lit-labs/ssr / lit, ' +
225
- 'although both ship as dependencies of this package. Returning non-hydrated markup — the ' +
226
- 'transcript still renders correctly. Reinstalling your dependencies should fix this.');
227
- }
228
- return markup;
229
- }
195
+ // `hydrate` needs no branch of its own here. It used to push the finished
196
+ // markup through @lit-labs/ssr, which contributed only four inert
197
+ // <!--lit-part--> comments and was removed; its remaining effect, the
198
+ // spoiler-reveal script, is already part of the tree above. A leftover
199
+ // `if (options.hydrate) return string` kept overriding the stream contract,
200
+ // so hydrate + stream handed callers a string where they expect a Readable.
201
+ const wantsStream = options.returnType === 'stream' || options.stream;
202
+
203
+ if (options.inlineAssets) {
204
+ // Inlining rewrites the finished document, so it cannot be streamed as it
205
+ // is produced. The stream contract is still honoured — the buffered result
206
+ // is handed back as a Readable.
207
+ const markup = await (0, selfContained_1.inlineExternalAssets)(await (0, utils_1.streamToString)(prelude), {
208
+ timeout: options.inlineAssetsTimeout,
209
+ });
210
+ // Same chunk type as the plain stream: Buffers, not one object-mode string
211
+ // (consumers that Buffer.concat() the chunks would otherwise throw).
212
+ return wantsStream ? stream_1.Readable.from([Buffer.from(markup, 'utf8')], { objectMode: false }) : markup;
230
213
  }
231
214
 
232
- if (options.returnType === 'stream' || options.stream) {
215
+ if (wantsStream) {
233
216
  // Return the underlying Node stream — caller is responsible for piping.
234
217
  return prelude;
235
218
  }
@@ -281,10 +264,13 @@ function renderStatsFooter(stats, lang, i18n, opts) {
281
264
  }
282
265
 
283
266
  const dict = (i18n && i18n[lang]) || (i18n && i18n.en) || {};
267
+ // "1 message", not "1 messages".
268
+ const count = (n, one, many, fallbackOne, fallbackMany) =>
269
+ (n === 1 ? (dict[one] || fallbackOne) : (dict[many] || fallbackMany)).replace('{n}', String(n));
284
270
  const parts = [
285
- (dict.statsMessages || '{n} messages').replace('{n}', values.messages),
286
- (dict.statsParticipants || '{n} participants').replace('{n}', values.participants),
287
- (dict.statsImages || '{n} images').replace('{n}', values.images),
271
+ count(stats.messageCount, 'statsMessage', 'statsMessages', '{n} message', '{n} messages'),
272
+ count(stats.participantCount, 'statsParticipant', 'statsParticipants', '{n} participant', '{n} participants'),
273
+ count(stats.imageCount, 'statsImage', 'statsImages', '{n} image', '{n} images'),
288
274
  ];
289
275
  if (values.from && values.to) parts.push(values.from + ' → ' + values.to);
290
276
  if (values.span) parts.push(values.span);
@@ -329,6 +315,7 @@ discord-action-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin
329
315
  .dht-forwarded-icon{margin-right:4px}
330
316
  .dht-forwarded-body{color:#dbdee1}
331
317
  .dht-forwarded-attachments{margin-top:4px;font-size:11px;color:#949ba4}
318
+ .dht-forwarded-media{margin-top:4px}
332
319
  .dht-edit-history{font-size:12px;color:#b5bac1;margin-top:4px}
333
320
  .dht-edit-history summary{cursor:pointer;color:#949ba4}
334
321
  .dht-edit-history li{margin-top:4px}
@@ -23,7 +23,17 @@ async function Attachment({ attachment, context, message }) {
23
23
 
24
24
  switch (attachmentType) {
25
25
  case types_1.AttachmentTypes.Image: {
26
- const downloaded = await context.callbacks.resolveImageSrc(attachment.toJSON(), message.toJSON());
26
+ let downloaded;
27
+ try {
28
+ downloaded = await context.callbacks.resolveImageSrc(attachment.toJSON(), message.toJSON());
29
+ }
30
+ catch (err) {
31
+ // Rendering runs in async components outside the per-message boundary: a
32
+ // failed download (saveImages, a custom resolver) used to reject the whole
33
+ // export. The image keeps its link instead.
34
+ console.warn('[discord-html-transcripts-fix] could not save image', attachment.id, (0, utils_1.describeError)(err));
35
+ downloaded = undefined;
36
+ }
27
37
  if (downloaded !== null) {
28
38
  url = downloaded ?? url;
29
39
  }
@@ -37,8 +47,10 @@ async function Attachment({ attachment, context, message }) {
37
47
  return (0, jsx_runtime_1.jsx)("discord-video-attachment", { slot: "attachments", href: url }, attachment.id);
38
48
  }
39
49
  case types_1.AttachmentTypes.Audio: {
40
- // Voice messages have waveform + duration_secs — message.js renders the indicator
41
- return (0, jsx_runtime_1.jsx)("discord-audio-attachment", { slot: "attachments", href: url, bytes: bytes, bytesUnit: bytesUnit, name: attachment.name, title: attachment.description || undefined }, attachment.id);
50
+ // Voice messages have waveform + duration_secs — message.js renders the indicator.
51
+ // "bytes-unit", like the file attachment below: HTML lowercases attribute
52
+ // names, and `bytesUnit` arrived as `bytesunit`, which is not observed.
53
+ return (0, jsx_runtime_1.jsx)("discord-audio-attachment", { slot: "attachments", href: url, bytes: bytes, "bytes-unit": bytesUnit, name: attachment.name, title: attachment.description || undefined }, attachment.id);
42
54
  }
43
55
  case types_1.AttachmentTypes.File: {
44
56
  return (0, jsx_runtime_1.jsx)("discord-file-attachment", { slot: "attachments", href: url, bytes: bytes, "bytes-unit": bytesUnit, name: attachment.name, title: attachment.description || undefined }, attachment.id);
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const jsx_runtime_1 = require("react/jsx-runtime");
4
4
  const utils_1 = require("./utils");
5
+ const utils_2 = require("../../../utils/utils");
5
6
  function DiscordMediaGallery({ component }) {
6
7
  if (!component.items || component.items.length === 0) {
7
8
  return null;
@@ -10,7 +11,7 @@ function DiscordMediaGallery({ component }) {
10
11
  const imagesToShow = component.items.slice(0, 10);
11
12
  const hasMore = component.items.length > 10;
12
13
  return ((0, jsx_runtime_1.jsx)("div", { style: (0, utils_1.getGalleryLayout)(count), children: imagesToShow.map((media, idx) => {
13
- const url = media.media.url;
14
+ const url = (0, utils_2.safeImageSrc)(media.media?.url);
14
15
  const isVideo = typeof url === 'string' && /\.(mp4|webm|mov)(\?|$)/i.test(url);
15
16
  const img = isVideo
16
17
  ? (0, jsx_runtime_1.jsx)("video", { src: url, autoPlay: true, loop: true, muted: true, playsInline: true, style: { width: '100%', height: '100%', objectFit: 'cover' } })
@@ -19,13 +19,19 @@ function DiscordSelectMenu({ component, }) {
19
19
  border: '1px solid #1e1f22',
20
20
  maxHeight: '320px',
21
21
  overflowY: 'auto',
22
- }, children: component.options.map((option, idx) => ((0, jsx_runtime_1.jsxs)("div", { style: {
22
+ }, children: component.options.map((option, idx) => {
23
+ // parseDiscordEmoji returns an image URL — empty when the emoji has
24
+ // neither id nor name. As plain children it showed up as raw URL text
25
+ // in the opened menu instead of the emoji.
26
+ const emojiSrc = option.emoji ? (0, utils_1.parseDiscordEmoji)(option.emoji) : '';
27
+ return ((0, jsx_runtime_1.jsxs)("div", { style: {
23
28
  padding: '8px 12px',
24
29
  cursor: 'pointer',
25
30
  display: 'flex',
26
31
  alignItems: 'center',
27
32
  borderBottom: idx < component.options.length - 1 ? '1px solid #1e1f22' : 'none',
28
- }, children: [option.emoji && (0, jsx_runtime_1.jsx)("span", { style: { marginRight: '8px' }, children: (0, utils_1.parseDiscordEmoji)(option.emoji) }), (0, jsx_runtime_1.jsx)("span", { children: option.label })] }, idx))) }))] }));
33
+ }, children: [emojiSrc && (0, jsx_runtime_1.jsx)("span", { style: { marginRight: '8px', display: 'flex', alignItems: 'center' }, children: (0, jsx_runtime_1.jsx)("img", { src: emojiSrc, alt: option.emoji.name || 'emoji', style: { width: '16px', height: '16px' } }) }), (0, jsx_runtime_1.jsx)("span", { children: option.label })] }, idx));
34
+ }) }))] }));
29
35
  }
30
36
  exports.default = DiscordSelectMenu;
31
37
  //# sourceMappingURL=Select%20Menu.js.map
@@ -30,7 +30,10 @@ function ComponentRow({ component, id, context, }) {
30
30
  return ((0, jsx_runtime_1.jsx)(Container_1.default, { accentColor: accentColor, spoiler: spoiler, children: (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: component.components.map((nestedComponent, id) => ((0, jsx_runtime_1.jsx)(ComponentRow, { component: nestedComponent, id: id, context: context }, id))) }) }, id));
31
31
  }
32
32
  case discord_js_1.ComponentType.File: {
33
- const attachmentComponent = (0, jsx_runtime_1.jsx)("discord-file-attachment", { href: component.file.url });
33
+ // <discord-file-attachment> turns href into a link without checking it.
34
+ const fileHref = (0, utils_1.safeLinkHref)(component.file?.url);
35
+ // A rejected link gets no href at all: "#" would open the transcript itself.
36
+ const attachmentComponent = (0, jsx_runtime_1.jsx)("discord-file-attachment", { href: fileHref === '#' ? undefined : fileHref });
34
37
  if (component.spoiler) {
35
38
  return ((0, jsx_runtime_1.jsx)("discord-spoiler", { slot: "attachment", children: attachmentComponent }, component.id));
36
39
  }
@@ -54,7 +57,7 @@ function Component({ component, id, }) {
54
57
  var _a;
55
58
  switch (component.type) {
56
59
  case discord_js_1.ComponentType.Button:
57
- return ((0, jsx_runtime_1.jsx)(Button_1.default, { type: styles_1.ButtonStyleMapping[component.style], url: (_a = component.url) !== null && _a !== void 0 ? _a : undefined, emoji: component.emoji ? (0, utils_1.parseDiscordEmoji)(component.emoji) : undefined, children: component.label }, id));
60
+ return ((0, jsx_runtime_1.jsx)(Button_1.default, { type: styles_1.ButtonStyleMapping[component.style], url: (_a = component.url) !== null && _a !== void 0 ? (0, utils_1.safeLinkHref)(_a) : undefined, emoji: component.emoji ? (0, utils_1.parseDiscordEmoji)(component.emoji) : undefined, children: component.label }, id));
58
61
  case discord_js_1.ComponentType.StringSelect:
59
62
  case discord_js_1.ComponentType.UserSelect:
60
63
  case discord_js_1.ComponentType.RoleSelect:
@@ -62,7 +65,7 @@ function Component({ component, id, }) {
62
65
  case discord_js_1.ComponentType.ChannelSelect:
63
66
  return (0, jsx_runtime_1.jsx)(Select_Menu_1.default, { component: component }, id);
64
67
  case discord_js_1.ComponentType.Thumbnail:
65
- return (0, jsx_runtime_1.jsx)(Thumbnail_1.default, { url: component.media.url }, id);
68
+ return (0, jsx_runtime_1.jsx)(Thumbnail_1.default, { url: (0, utils_1.safeImageSrc)(component.media?.url) }, id);
66
69
  default:
67
70
  return undefined;
68
71
  }
@@ -136,7 +136,7 @@ async function MessageSingleASTNode({ node, context }) {
136
136
  try {
137
137
  return await renderASTNode(node, context);
138
138
  } catch (err) {
139
- console.warn('[discord-html-transcripts-fix] AST node render failed:', err && err.message ? err.message : err);
139
+ console.warn('[discord-html-transcripts-fix] AST node render failed:', err);
140
140
  try {
141
141
  if (typeof node.content === 'string') return node.content;
142
142
  if (Array.isArray(node.content)) return (0, jsx_runtime_1.jsx)(MessageASTNodes, { nodes: node.content, context });
@@ -16,17 +16,28 @@ function t(context, key, fallback) {
16
16
  }
17
17
 
18
18
  async function DiscordEmbed({ embed, context }) {
19
- const type = embed.type || 'rich';
19
+ // discord.js' Embed class has no `type` getter; the type only lives on the raw
20
+ // payload in `embed.data`. Reading `embed.type` alone made every embed 'rich'
21
+ // in production, so link previews, GIFs and videos never got their own layout.
22
+ // Plain-object embeds without `data` keep working through the fallback.
23
+ const type = embed.data?.type ?? embed.type ?? 'rich';
20
24
  const url = embed.url ? (0, utils_1.safeHref)(embed.url) : undefined;
21
- const imageUrl = embed.image?.proxyURL ?? embed.image?.url;
25
+ // Image previews, GIFs and videos carry their picture in `thumbnail`; only rich
26
+ // embeds use `image` for it.
27
+ const preview = type === 'image' || type === 'gifv' || type === 'video' ? embed.thumbnail : null;
28
+ const ownImageUrl = embed.image?.proxyURL ?? embed.image?.url;
29
+ const imageUrl = ownImageUrl ?? preview?.proxyURL ?? preview?.url;
22
30
  const videoUrl = embed.video?.proxyURL ?? embed.video?.url;
23
31
  const provider = embed.provider?.name;
24
32
  const safeVid = videoUrl ? (0, utils_1.safeHref)(videoUrl) : null;
25
- const safeImg = imageUrl ? (0, utils_1.safeHref)(imageUrl) : null;
33
+ const safeImg = (0, utils_1.safeImageSrc)(imageUrl) ?? null;
34
+ // The rich card already shows `thumbnail` itself, so a preview that falls
35
+ // through to it (a GIF without a video, say) must not appear twice.
36
+ const safeOwnImg = (0, utils_1.safeImageSrc)(ownImageUrl) ?? null;
26
37
 
27
38
  // Image-only embeds (link previews): render the image alone with optional caption
28
39
  if (type === 'image' && safeImg) {
29
- return (0, jsx_runtime_1.jsx)("div", { className: "dht-embed dht-embed--image", children: (0, jsx_runtime_1.jsx)("a", { href: url || safeImg, target: "_blank", rel: "noreferrer", children: (0, jsx_runtime_1.jsx)("img", { src: safeImg, alt: embed.title || '', className: "dht-embed-image", loading: "lazy" }) }) });
40
+ return (0, jsx_runtime_1.jsx)("div", { className: "dht-embed dht-embed--image", children: (0, jsx_runtime_1.jsx)("a", { href: url || (0, utils_1.safeHref)(imageUrl), target: "_blank", rel: "noreferrer", children: (0, jsx_runtime_1.jsx)("img", { src: safeImg, alt: embed.title || '', className: "dht-embed-image", loading: "lazy" }) }) });
30
41
  }
31
42
  // GIFV — auto-play loop video
32
43
  if (type === 'gifv' && safeVid) {
@@ -35,7 +46,7 @@ async function DiscordEmbed({ embed, context }) {
35
46
  // Video embed (YouTube/Vimeo/etc.) — show thumbnail with a play overlay linking to original
36
47
  if (type === 'video' && (safeVid || safeImg)) {
37
48
  return (0, jsx_runtime_1.jsxs)("div", { className: "dht-embed dht-embed--video", children: [
38
- (0, jsx_runtime_1.jsxs)("a", { href: url || safeVid || safeImg, target: "_blank", rel: "noreferrer", className: "dht-embed-video-wrap", children: [
49
+ (0, jsx_runtime_1.jsxs)("a", { href: url || safeVid || (0, utils_1.safeHref)(imageUrl), target: "_blank", rel: "noreferrer", className: "dht-embed-video-wrap", children: [
39
50
  safeImg && (0, jsx_runtime_1.jsx)("img", { src: safeImg, alt: embed.title || '', className: "dht-embed-video-thumb", loading: "lazy" }),
40
51
  (0, jsx_runtime_1.jsx)("div", { className: "dht-embed-video-play", children: '▶' })
41
52
  ] }),
@@ -46,11 +57,12 @@ async function DiscordEmbed({ embed, context }) {
46
57
 
47
58
  // Default rich embed
48
59
  const title = embed.title ?? undefined;
49
- const authorImage = embed.author?.proxyIconURL ?? embed.author?.iconURL;
60
+ // The component renders these unchecked; only web and inline image URLs pass.
61
+ const authorImage = (0, utils_1.safeImageSrc)(embed.author?.proxyIconURL ?? embed.author?.iconURL);
50
62
  const authorName = embed.author?.name;
51
63
  const authorUrl = embed.author?.url ? (0, utils_1.safeHref)(embed.author.url) : undefined;
52
64
  const color = embed.hexColor ? (0, utils_1.safeColor)(embed.hexColor, undefined) : undefined;
53
- const thumb = embed.thumbnail?.proxyURL ?? embed.thumbnail?.url;
65
+ const thumb = (0, utils_1.safeImageSrc)(embed.thumbnail?.proxyURL ?? embed.thumbnail?.url);
54
66
 
55
67
  return ((0, jsx_runtime_1.jsxs)("discord-embed", {
56
68
  "embed-title": title,
@@ -59,7 +71,7 @@ async function DiscordEmbed({ embed, context }) {
59
71
  "author-name": authorName,
60
72
  "author-url": authorUrl,
61
73
  color: color,
62
- image: safeImg,
74
+ image: safeOwnImg,
63
75
  thumbnail: thumb,
64
76
  url: url,
65
77
  children: [
@@ -75,7 +87,10 @@ async function DiscordEmbed({ embed, context }) {
75
87
  safeVid && ((0, jsx_runtime_1.jsx)("div", { className: "dht-embed-video", children: (0, jsx_runtime_1.jsxs)("a", { href: safeVid, target: "_blank", rel: "noreferrer", children: ['▶ Open video', provider ? ` (${provider})` : ''] }) })),
76
88
  embed.footer && ((0, jsx_runtime_1.jsx)("discord-embed-footer", {
77
89
  slot: "footer",
78
- footerImage: embed.footer.proxyIconURL ?? embed.footer.iconURL,
90
+ // Attribute names are lowercased by HTML; `footerImage` arrived as
91
+ // `footerimage`, which the component does not observe, so footer
92
+ // icons never showed.
93
+ "footer-image": (0, utils_1.safeImageSrc)(embed.footer.proxyIconURL ?? embed.footer.iconURL),
79
94
  timestamp: (0, utils_1.formatMessageTimestamp)(embed.timestamp, context?.timestampFormat, t(context, 'yesterdayAt', 'Yesterday at {time}')),
80
95
  children: embed.footer.text
81
96
  }))