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.
- package/README.md +79 -25
- package/dist/generator/index.js +32 -22
- package/dist/generator/renderers/attachment.js +15 -3
- package/dist/generator/renderers/components/DiscordHighlightedCode.js +30 -4
- package/dist/generator/renderers/components/Media Gallery.js +2 -1
- package/dist/generator/renderers/components/Select Menu.js +8 -2
- package/dist/generator/renderers/components/TranscriptHeader.js +22 -2
- package/dist/generator/renderers/components.js +6 -3
- package/dist/generator/renderers/content.js +45 -10
- package/dist/generator/renderers/embed.js +24 -9
- package/dist/generator/renderers/message.js +76 -28
- package/dist/generator/renderers/reply.js +18 -5
- package/dist/generator/renderers/systemMessage.js +20 -5
- package/dist/index.js +7 -0
- package/dist/static/client.js +10 -0
- package/dist/types.d.ts +19 -10
- package/dist/utils/buildProfiles.js +75 -17
- package/dist/utils/selfContained.js +402 -50
- package/dist/utils/utils.js +107 -2
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -15,9 +15,9 @@ Forked from [discord-html-transcripts](https://github.com/ItzDerock/discord-html
|
|
|
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
17
|
The **generated HTML** additionally reaches out to third-party CDNs when it is *opened*
|
|
18
|
-
— jsDelivr for the `<discord-*>` component runtime and
|
|
19
|
-
[`inlineAssets: true`](#self-contained-transcripts) to embed them and
|
|
20
|
-
renders offline.
|
|
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
21
|
|
|
22
22
|
## Install
|
|
23
23
|
|
|
@@ -67,11 +67,11 @@ const stream = await createTranscript(channel, {
|
|
|
67
67
|
| `filter` | `(m) => boolean` | `() => true` | Predicate to filter messages. |
|
|
68
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. |
|
|
69
69
|
| `filename` | `string` | `transcript-{channel-id}.html` | Output filename when returning as attachment. |
|
|
70
|
-
| `saveImages` | `boolean` | `false` | Download
|
|
70
|
+
| `saveImages` | `boolean` | `false` | Download image **attachments** and inline them as base64 data URLs. Avatars, embed images and custom emoji keep loading from Discord. |
|
|
71
71
|
| `favicon` | `'guild'` \| `string` | `'guild'` | Page favicon — `'guild'` uses the server icon, or pass a URL. |
|
|
72
72
|
| `hydrate` | `boolean` | `false` | Enables the client-side spoiler-reveal script. |
|
|
73
|
-
| `inlineAssets` | `boolean` | `false` | Embed the component runtime and emoji so the file renders
|
|
74
|
-
| `inlineAssetsTimeout` | `number` | `30000` |
|
|
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. |
|
|
75
75
|
| `dateFormat` | `'dd/mm/yyyy'` \| `'mm/dd/yyyy'` | `'dd/mm/yyyy'` | Date order for message timestamps older than yesterday. |
|
|
76
76
|
| `timeFormat` | `'24h'` \| `'12h'` | `'24h'` | Clock format for message timestamps — `07:16` vs `07:16 AM`. |
|
|
77
77
|
| `language` | `'en'` \| `'de'` | `'en'` | UI language for participant labels, filter strings, etc. |
|
|
@@ -84,32 +84,54 @@ const stream = await createTranscript(channel, {
|
|
|
84
84
|
### Self-contained transcripts
|
|
85
85
|
|
|
86
86
|
By default the rendered file is not standalone: opening it fetches the `<discord-*>`
|
|
87
|
-
component runtime from jsDelivr and the emoji SVGs from cdnjs.
|
|
88
|
-
that blocks those CDNs, or after a CDN outage the transcript
|
|
89
|
-
every viewer's IP reaches both CDNs, which can matter for
|
|
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.
|
|
90
91
|
|
|
91
92
|
```js
|
|
92
93
|
await createTranscript(channel, {
|
|
93
94
|
inlineAssets: true,
|
|
94
|
-
saveImages: true, // also
|
|
95
|
+
saveImages: true, // also embeds image attachments
|
|
95
96
|
});
|
|
96
97
|
```
|
|
97
98
|
|
|
98
|
-
`inlineAssets` embeds the component runtime
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
`blob:`
|
|
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.
|
|
102
104
|
|
|
103
105
|
| | Default | `inlineAssets: true` |
|
|
104
106
|
| --- | --- | --- |
|
|
105
|
-
| File size | ~72 kB | ~
|
|
106
|
-
|
|
|
107
|
-
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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`.
|
|
113
135
|
|
|
114
136
|
### Message timestamps
|
|
115
137
|
|
|
@@ -207,11 +229,11 @@ In addition to plain text, replies, embeds, and attachments, the viewer supports
|
|
|
207
229
|
- **Action rows** — buttons with proper spacing and Discord-style colors (`primary`, `secondary`, `success`, `destructive`)
|
|
208
230
|
- **Stickers** — PNG, APNG, GIF, Lottie placeholder
|
|
209
231
|
- **Polls** — question, answer bars with vote counts and percentages, expiry
|
|
210
|
-
- **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
|
|
211
233
|
- **Voice messages** — `🎤` indicator, inline SVG waveform from `attachment.waveform`, duration
|
|
212
234
|
- **Pinned messages** — Discord-style amber left rail (no extra icon clutter)
|
|
213
235
|
- **Slash command interactions** — `{user} used /cmd` header + clickable pill that reveals parameters
|
|
214
|
-
- **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
|
|
215
237
|
- **Cross-guild replies** — show a "Message from another server" pill
|
|
216
238
|
- **Burst / super-reactions** flagged
|
|
217
239
|
- **Thread state badges** — `Archived`, `Locked`
|
|
@@ -231,16 +253,36 @@ In addition to plain text, replies, embeds, and attachments, the viewer supports
|
|
|
231
253
|
- **Critical fix** markdown links with `javascript:`, `data:`, `vbscript:` and other dangerous URI schemes are rewritten to `#`
|
|
232
254
|
- **Hardened** inline `style="color:…"` sinks in the mention popup are hex-validated to block CSS injection
|
|
233
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
|
|
234
257
|
- **Fixed** `process.exit(1)` on discord.js version mismatch removed — library no longer kills the host bot
|
|
235
258
|
|
|
236
259
|
### Robustness
|
|
237
260
|
|
|
238
261
|
- **Fix** invalid Discord timestamp markers (`<t:abc:F>`, oversized values) no longer abort the transcript with `RangeError`
|
|
239
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
|
|
240
264
|
- **Fix** `parseDiscordEmoji` no longer throws on deleted reactions with `emoji.name === null`
|
|
241
265
|
- **Fix** `formatBytes(null/undefined/NaN)` no longer returns `NaN undefined`
|
|
242
266
|
- **Fix** `createTranscript` slice uses the resolved limit instead of the raw `limit`
|
|
243
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** text outside the Basic Multilingual Plane showed as pairs of `�` — fancy-font ("math italic") server and channel names, rare ideographs, symbols — in messages, embeds and Components V2 containers. The markdown parser cuts its text at every non-ASCII character and counts UTF-16 code units, so such a character arrived as two lone halves that could not be encoded. Text is merged back (and renders as one piece per run instead of one node per non-ASCII character, which also shrinks the HTML). Reply and thread previews, the search text, activity party ids and the server's initial letters no longer cut through a character either, and a server name with a double space no longer reads "Aundefined"
|
|
279
|
+
- **Fix** a fenced code block in a language highlight.js does not know — ` ```ansi `, ` ```log `, a typo such as `js2` — made the whole export fail, because the error was raised while rendering, outside the per-message safety net. Such blocks now show as plain text, like in Discord; `ansi` blocks lose their color escape codes
|
|
280
|
+
- **Fix** a role-subscription tier name containing `$&`, `$'`, `` $` `` or `$$` was altered when the message was composed; it is shown exactly as written
|
|
281
|
+
- **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
|
|
282
|
+
- **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`)
|
|
283
|
+
- **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`)
|
|
284
|
+
- **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)
|
|
285
|
+
- **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
|
|
244
286
|
- **Fix** embed fields render through a proper async component (was an inline `async` arrow inside `.map()`)
|
|
245
287
|
- **Fix** `JoinMessage` text is deterministic per message id — re-rendering the same channel always yields the same join line
|
|
246
288
|
- **Fix** random `console.log` calls in production paths replaced by the `debug` namespace
|
|
@@ -268,7 +310,7 @@ In addition to plain text, replies, embeds, and attachments, the viewer supports
|
|
|
268
310
|
- **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
|
|
269
311
|
- **`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 `<!DOCTYPE html>…` source text instead of a transcript
|
|
270
312
|
- **`@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
|
|
271
|
-
- **`inlineAssets` option** — embeds the component runtime and emoji so a transcript
|
|
313
|
+
- **`inlineAssets` option** — embeds the component runtime, the gg sans font and the emoji so a transcript opens without any third-party CDN
|
|
272
314
|
- **TypeScript declarations match runtime** — `ExportReturnType.Stream`, `language`, `i18n`, `stream`, and `withConcurrency()` are now exposed in the types
|
|
273
315
|
- `discord.js` remains the only **required** peer dependency
|
|
274
316
|
|
|
@@ -282,6 +324,18 @@ In addition to plain text, replies, embeds, and attachments, the viewer supports
|
|
|
282
324
|
| `TranscriptImageDownloader` | Builder for a custom image-saving callback (`withMaxSize`, `withConcurrency`, `withCompression`, `build`). |
|
|
283
325
|
| `DiscordMessages` | The underlying React component, for advanced/custom rendering. |
|
|
284
326
|
|
|
327
|
+
## Development
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
npm test
|
|
331
|
+
npm run test:update
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`npm test` runs the unit tests and the golden transcripts: every scenario in
|
|
335
|
+
`tests/fixtures/scenarios.js` is rendered from real discord.js objects and compared with
|
|
336
|
+
`tests/golden/`. After an intended change to the output, `npm run test:update` rewrites
|
|
337
|
+
the snapshots — review the diff before committing it.
|
|
338
|
+
|
|
285
339
|
## License
|
|
286
340
|
|
|
287
341
|
[Apache-2.0](./LICENSE) — same as the original package.
|
package/dist/generator/index.js
CHANGED
|
@@ -39,16 +39,26 @@ try {
|
|
|
39
39
|
if (/^\d+\.\d+\.\d+/.test(cleaned)) discordComponentsVersion = cleaned;
|
|
40
40
|
} catch (_b) { /* ignore */ }
|
|
41
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
|
+
|
|
42
56
|
function computeStats(messages) {
|
|
43
57
|
let images = 0;
|
|
44
58
|
let humans = new Set();
|
|
45
59
|
let firstTs = null, lastTs = null;
|
|
46
60
|
for (const m of messages) {
|
|
47
|
-
|
|
48
|
-
m.attachments.forEach((a) => {
|
|
49
|
-
if (typeof a.contentType === 'string' && a.contentType.startsWith('image/')) images++;
|
|
50
|
-
});
|
|
51
|
-
}
|
|
61
|
+
images += countImages(m);
|
|
52
62
|
if (m.author && !m.author.bot) humans.add(m.author.id);
|
|
53
63
|
const ts = m.createdAt instanceof Date ? m.createdAt.getTime() : (snowflakeToDate(m.id)?.getTime() || null);
|
|
54
64
|
if (ts) {
|
|
@@ -104,7 +114,7 @@ async function render(_a) {
|
|
|
104
114
|
|
|
105
115
|
// Single-pass collector (profiles + extended in one walk)
|
|
106
116
|
const allCtx = await (0, buildProfiles_1.buildAllContext)(messages, channel).catch((e) => {
|
|
107
|
-
console.warn('[discord-html-transcripts-fix] buildAllContext failed:', e
|
|
117
|
+
console.warn('[discord-html-transcripts-fix] buildAllContext failed:', e);
|
|
108
118
|
return { profiles: {}, users: {}, roles: {}, channels: {} };
|
|
109
119
|
});
|
|
110
120
|
|
|
@@ -182,14 +192,12 @@ async function render(_a) {
|
|
|
182
192
|
|
|
183
193
|
const { prelude } = await (0, static_1.prerenderToNodeStream)(docTree);
|
|
184
194
|
|
|
185
|
-
// `hydrate` used to
|
|
186
|
-
// @lit-labs/ssr
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
// four comments was a bad trade, so the round-trip is gone. The option keeps
|
|
192
|
-
// its real effect: it enables the spoiler-reveal script emitted above.
|
|
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.
|
|
193
201
|
const wantsStream = options.returnType === 'stream' || options.stream;
|
|
194
202
|
|
|
195
203
|
if (options.inlineAssets) {
|
|
@@ -199,11 +207,9 @@ async function render(_a) {
|
|
|
199
207
|
const markup = await (0, selfContained_1.inlineExternalAssets)(await (0, utils_1.streamToString)(prelude), {
|
|
200
208
|
timeout: options.inlineAssetsTimeout,
|
|
201
209
|
});
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (options.hydrate) {
|
|
206
|
-
return await (0, utils_1.streamToString)(prelude);
|
|
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;
|
|
207
213
|
}
|
|
208
214
|
|
|
209
215
|
if (wantsStream) {
|
|
@@ -258,10 +264,13 @@ function renderStatsFooter(stats, lang, i18n, opts) {
|
|
|
258
264
|
}
|
|
259
265
|
|
|
260
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));
|
|
261
270
|
const parts = [
|
|
262
|
-
(
|
|
263
|
-
(
|
|
264
|
-
(
|
|
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'),
|
|
265
274
|
];
|
|
266
275
|
if (values.from && values.to) parts.push(values.from + ' → ' + values.to);
|
|
267
276
|
if (values.span) parts.push(values.span);
|
|
@@ -306,6 +315,7 @@ discord-action-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin
|
|
|
306
315
|
.dht-forwarded-icon{margin-right:4px}
|
|
307
316
|
.dht-forwarded-body{color:#dbdee1}
|
|
308
317
|
.dht-forwarded-attachments{margin-top:4px;font-size:11px;color:#949ba4}
|
|
318
|
+
.dht-forwarded-media{margin-top:4px}
|
|
309
319
|
.dht-edit-history{font-size:12px;color:#b5bac1;margin-top:4px}
|
|
310
320
|
.dht-edit-history summary{cursor:pointer;color:#949ba4}
|
|
311
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
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -7,6 +7,7 @@ exports.DiscordHighlightStyles = void 0;
|
|
|
7
7
|
exports.DiscordHighlightedCode = DiscordHighlightedCode;
|
|
8
8
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
9
9
|
const highlight_js_1 = __importDefault(require("highlight.js"));
|
|
10
|
+
const utils_1 = require("../../../utils/utils");
|
|
10
11
|
// styles from https://github.com/cherryblossom000/discord-syntax-highlighting/tree/main
|
|
11
12
|
exports.DiscordHighlightStyles = `
|
|
12
13
|
.hljs-ansi-control-sequence {
|
|
@@ -190,10 +191,35 @@ exports.DiscordHighlightStyles = `
|
|
|
190
191
|
background-color: #67060c;
|
|
191
192
|
}
|
|
192
193
|
`;
|
|
194
|
+
const ESC = String.fromCharCode(27);
|
|
195
|
+
// Discord colors ANSI blocks; the transcript shows their text without the control sequences.
|
|
196
|
+
const ANSI_SGR = new RegExp(ESC + '\\[[0-9;]*m', 'g');
|
|
197
|
+
|
|
198
|
+
function escapeHtml(text) {
|
|
199
|
+
return String(text)
|
|
200
|
+
.replace(/&/g, '&')
|
|
201
|
+
.replace(/</g, '<')
|
|
202
|
+
.replace(/>/g, '>')
|
|
203
|
+
.replace(/"/g, '"')
|
|
204
|
+
.replace(/'/g, ''');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// The block is rendered while React runs, outside every per-message safety net, so a
|
|
208
|
+
// throw here used to reject the whole export: highlight.js raises an error for a language
|
|
209
|
+
// it does not know ("ansi", "log", a typo such as "js2"), and users write those all the time.
|
|
210
|
+
// Those blocks show as plain text, which is what Discord does for them.
|
|
211
|
+
function highlightCode(content, language) {
|
|
212
|
+
const name = typeof language === 'string' ? language.trim() : '';
|
|
213
|
+
try {
|
|
214
|
+
if (!name) return highlight_js_1.default.highlightAuto(content).value;
|
|
215
|
+
if (highlight_js_1.default.getLanguage(name)) return highlight_js_1.default.highlight(content, { language: name }).value;
|
|
216
|
+
} catch (err) {
|
|
217
|
+
console.warn('[discord-html-transcripts-fix] could not highlight a code block:', (0, utils_1.describeError)(err));
|
|
218
|
+
}
|
|
219
|
+
return escapeHtml(name.toLowerCase() === 'ansi' ? content.replace(ANSI_SGR, '') : content);
|
|
220
|
+
}
|
|
221
|
+
|
|
193
222
|
function DiscordHighlightedCode(props) {
|
|
194
|
-
|
|
195
|
-
? highlight_js_1.default.highlight(props.content, { language: props.language })
|
|
196
|
-
: highlight_js_1.default.highlightAuto(props.content);
|
|
197
|
-
return (0, jsx_runtime_1.jsx)("discord-code", { multiline: true, className: "theme-dark hljs", dangerouslySetInnerHTML: { __html: highlighted.value } });
|
|
223
|
+
return (0, jsx_runtime_1.jsx)("discord-code", { multiline: true, className: "theme-dark hljs", dangerouslySetInnerHTML: { __html: highlightCode(props.content, props.language) } });
|
|
198
224
|
}
|
|
199
225
|
//# sourceMappingURL=DiscordHighlightedCode.js.map
|
|
@@ -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
|
|
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) =>
|
|
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: [
|
|
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
|
|
@@ -2,12 +2,32 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.TranscriptHeader = TranscriptHeader;
|
|
4
4
|
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
|
|
6
|
+
// The first user-perceived character of a word. word[0] is one UTF-16 code unit —
|
|
7
|
+
// half of a character outside the Basic Multilingual Plane (fancy-font letters, emoji),
|
|
8
|
+
// which cannot be encoded — and would also cut an accent off its letter or an emoji
|
|
9
|
+
// sequence apart.
|
|
10
|
+
const segmenter = typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
|
|
11
|
+
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
12
|
+
: null;
|
|
13
|
+
function firstCharacter(word) {
|
|
14
|
+
if (segmenter) {
|
|
15
|
+
for (const { segment } of segmenter.segment(word)) return segment;
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
const codePoint = word.codePointAt(0);
|
|
19
|
+
return codePoint === undefined ? '' : String.fromCodePoint(codePoint);
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
function TranscriptHeader(props) {
|
|
6
23
|
// If guild has no icon, we take first letter of guild name words
|
|
7
24
|
// i.e. Guild A -> GA
|
|
8
25
|
// and OneWordGuildName -> O
|
|
9
|
-
|
|
10
|
-
|
|
26
|
+
if (typeof props.guildName !== 'string') {
|
|
27
|
+
console.warn('[discord-html-transcripts-fix] the server has no name (an unavailable guild?); the header shows none');
|
|
28
|
+
}
|
|
29
|
+
const words = String(props.guildName ?? '').split(/\s+/).filter(Boolean);
|
|
30
|
+
const placeholder = words.length > 1 ? firstCharacter(words[0]) + firstCharacter(words[1]) : firstCharacter(words[0] ?? '');
|
|
11
31
|
return ((0, jsx_runtime_1.jsxs)("div", { className: "discord-header", children: [(0, jsx_runtime_1.jsx)("div", { className: "discord-header-icon", children: props.guildIcon ? ((0, jsx_runtime_1.jsx)("img", { src: props.guildIcon, alt: "guild icon" })) : ((0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)("span", { children: placeholder }) })) }), (0, jsx_runtime_1.jsxs)("div", { className: "discord-header-text", children: [(0, jsx_runtime_1.jsx)("div", { className: "discord-header-text-guild", children: props.guildName }), (0, jsx_runtime_1.jsxs)("div", { className: "discord-header-text-channel", children: ["#", props.channelName] }), props.children] })] }));
|
|
12
32
|
}
|
|
13
33
|
//# sourceMappingURL=TranscriptHeader.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
|
-
|
|
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
|
|
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
|
}
|
|
@@ -32,6 +32,44 @@ function hexToRgba(hex, alpha) {
|
|
|
32
32
|
return `rgba(${r},${g},${b},${alpha})`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// discord-markdown-parser ends a text node before every character outside
|
|
36
|
+
// [0-9A-Za-z] and whitespace, and it counts UTF-16 code units. A character outside
|
|
37
|
+
// the Basic Multilingual Plane — fancy-font letters, rare CJK, symbols — therefore
|
|
38
|
+
// arrives as two text nodes holding one lone surrogate half each, and encoding the
|
|
39
|
+
// page turned every half into U+FFFD. Adjacent text nodes are joined again, which
|
|
40
|
+
// also stops non-ASCII text from becoming one node (and one markup separator) per
|
|
41
|
+
// character. Plain text content is all that is merged; nothing else changes.
|
|
42
|
+
function mergeTextNodes(nodes) {
|
|
43
|
+
if (!Array.isArray(nodes)) return nodes;
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const node of nodes) {
|
|
46
|
+
if (Array.isArray(node)) {
|
|
47
|
+
out.push(mergeTextNodes(node));
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!node || typeof node !== 'object') {
|
|
51
|
+
out.push(node);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const previous = out[out.length - 1];
|
|
55
|
+
if (node.type === 'text' && typeof node.content === 'string'
|
|
56
|
+
&& previous && !Array.isArray(previous) && previous.type === 'text' && typeof previous.content === 'string') {
|
|
57
|
+
out[out.length - 1] = Object.assign({}, previous, { content: previous.content + node.content });
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const merged = Object.assign({}, node);
|
|
61
|
+
if (Array.isArray(node.content)) merged.content = mergeTextNodes(node.content);
|
|
62
|
+
if (Array.isArray(node.items)) merged.items = mergeTextNodes(node.items);
|
|
63
|
+
out.push(merged);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseMarkdown(source, mode) {
|
|
69
|
+
const parsed = discord_markdown_parser_1.default(source, mode);
|
|
70
|
+
return mergeTextNodes(Array.isArray(parsed) ? parsed : [parsed]);
|
|
71
|
+
}
|
|
72
|
+
|
|
35
73
|
// Discord-specific block-level markdown that discord-markdown-parser misses in inline mode.
|
|
36
74
|
// Run BEFORE the parser, extract heading/subtext lines, parse the rest, splice back together.
|
|
37
75
|
const HEADING_RE = /^(#{1,3})\s+(.+?)\s*$/;
|
|
@@ -47,9 +85,7 @@ function preParseDiscord(content, mode) {
|
|
|
47
85
|
|
|
48
86
|
const flushInline = () => {
|
|
49
87
|
if (!inlineBuffer) return;
|
|
50
|
-
|
|
51
|
-
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
|
52
|
-
nodes.push(...arr);
|
|
88
|
+
nodes.push(...parseMarkdown(inlineBuffer, mode));
|
|
53
89
|
inlineBuffer = '';
|
|
54
90
|
};
|
|
55
91
|
|
|
@@ -77,16 +113,14 @@ function preParseDiscord(content, mode) {
|
|
|
77
113
|
if (h) {
|
|
78
114
|
flushInline();
|
|
79
115
|
const level = h[1].length;
|
|
80
|
-
|
|
81
|
-
nodes.push({ type: 'heading', level, content: Array.isArray(headingInner) ? headingInner : [headingInner] });
|
|
116
|
+
nodes.push({ type: 'heading', level, content: parseMarkdown(h[2], mode) });
|
|
82
117
|
continue;
|
|
83
118
|
}
|
|
84
119
|
|
|
85
120
|
const s = line.match(SUBTEXT_RE);
|
|
86
121
|
if (s) {
|
|
87
122
|
flushInline();
|
|
88
|
-
|
|
89
|
-
nodes.push({ type: 'subtext', content: Array.isArray(innerNodes) ? innerNodes : [innerNodes] });
|
|
123
|
+
nodes.push({ type: 'subtext', content: parseMarkdown(s[1], mode) });
|
|
90
124
|
continue;
|
|
91
125
|
}
|
|
92
126
|
|
|
@@ -103,14 +137,15 @@ function preParseDiscord(content, mode) {
|
|
|
103
137
|
|
|
104
138
|
async function MessageContent({ content, context }) {
|
|
105
139
|
if (context.type === RenderType.REPLY && content.length > 180) {
|
|
106
|
-
|
|
140
|
+
// Not through the middle of a surrogate pair: half of one cannot be encoded.
|
|
141
|
+
content = (0, utils_1.truncateText)(content, 180) + '...';
|
|
107
142
|
}
|
|
108
143
|
const mode = context.type === RenderType.EMBED || context.type === RenderType.WEBHOOK ? 'extended' : 'normal';
|
|
109
144
|
// REPLY mode is always inline-only (heading inside a reply preview makes no sense).
|
|
110
145
|
// For all other modes (NORMAL/EMBED/WEBHOOK) we pre-process headings/subtext/code-fences
|
|
111
146
|
// so they render correctly even though discord-markdown-parser is inline-only.
|
|
112
147
|
const parsed = context.type === RenderType.REPLY
|
|
113
|
-
?
|
|
148
|
+
? parseMarkdown(content, mode)
|
|
114
149
|
: preParseDiscord(content, mode);
|
|
115
150
|
|
|
116
151
|
const nodes = Array.isArray(parsed) ? parsed : [parsed];
|
|
@@ -136,7 +171,7 @@ async function MessageSingleASTNode({ node, context }) {
|
|
|
136
171
|
try {
|
|
137
172
|
return await renderASTNode(node, context);
|
|
138
173
|
} catch (err) {
|
|
139
|
-
console.warn('[discord-html-transcripts-fix] AST node render failed:', err
|
|
174
|
+
console.warn('[discord-html-transcripts-fix] AST node render failed:', err);
|
|
140
175
|
try {
|
|
141
176
|
if (typeof node.content === 'string') return node.content;
|
|
142
177
|
if (Array.isArray(node.content)) return (0, jsx_runtime_1.jsx)(MessageASTNodes, { nodes: node.content, context });
|