discord-html-transcripts-fix 2.0.0 → 2.2.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 +73 -3
- package/dist/generator/index.js +32 -7
- package/dist/generator/renderers/embed.js +6 -1
- package/dist/generator/renderers/message.js +17 -6
- package/dist/generator/renderers/systemMessage.js +5 -5
- package/dist/static/client.js +2 -0
- package/dist/types.d.ts +36 -1
- package/dist/utils/selfContained.js +193 -0
- package/dist/utils/utils.js +61 -0
- package/package.json +2 -3
package/README.md
CHANGED
|
@@ -14,13 +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 cdnjs for Twemoji SVGs. Set
|
|
19
|
+
[`inlineAssets: true`](#self-contained-transcripts) to embed them and get a file that
|
|
20
|
+
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,
|
|
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).
|
|
29
|
+
|
|
30
|
+
The install is clean: no deprecation warnings and no `npm audit` findings.
|
|
24
31
|
|
|
25
32
|
## Quick start
|
|
26
33
|
|
|
@@ -62,7 +69,11 @@ const stream = await createTranscript(channel, {
|
|
|
62
69
|
| `filename` | `string` | `transcript-{channel-id}.html` | Output filename when returning as attachment. |
|
|
63
70
|
| `saveImages` | `boolean` | `false` | Download images and inline them as base64 data URLs. |
|
|
64
71
|
| `favicon` | `'guild'` \| `string` | `'guild'` | Page favicon — `'guild'` uses the server icon, or pass a URL. |
|
|
65
|
-
| `hydrate` | `boolean` | `false` |
|
|
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 offline. See below. |
|
|
74
|
+
| `inlineAssetsTimeout` | `number` | `30000` | Per-request timeout in ms while downloading those assets. |
|
|
75
|
+
| `dateFormat` | `'dd/mm/yyyy'` \| `'mm/dd/yyyy'` | `'dd/mm/yyyy'` | Date order for message timestamps older than yesterday. |
|
|
76
|
+
| `timeFormat` | `'24h'` \| `'12h'` | `'24h'` | Clock format for message timestamps — `07:16` vs `07:16 AM`. |
|
|
66
77
|
| `language` | `'en'` \| `'de'` | `'en'` | UI language for participant labels, filter strings, etc. |
|
|
67
78
|
| `i18n` | `Partial<Record<lang, Record<key,string>>>` | — | Override individual strings per language. |
|
|
68
79
|
| `statsFooter` | `false` \| `{ enabled?, template? }` | `{ enabled: true }` | Bottom stats line. See below. |
|
|
@@ -70,12 +81,67 @@ const stream = await createTranscript(channel, {
|
|
|
70
81
|
| `poweredBy` | `boolean` | `false` | Show the original "Powered by discord-html-transcripts" credit link. Only renders when `statsFooter` is disabled. |
|
|
71
82
|
| `callbacks` | `{ resolveUser, resolveRole, resolveChannel, resolveImageSrc }` | — | Custom resolvers for mentions / image URLs. |
|
|
72
83
|
|
|
84
|
+
### Self-contained transcripts
|
|
85
|
+
|
|
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. Offline, on a network
|
|
88
|
+
that blocks those CDNs, or after a CDN outage the transcript renders unstyled — and
|
|
89
|
+
every viewer's IP reaches both CDNs, which can matter for archived tickets.
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
await createTranscript(channel, {
|
|
93
|
+
inlineAssets: true,
|
|
94
|
+
saveImages: true, // also inlines Discord's avatars/attachments
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`inlineAssets` embeds the component runtime and every emoji used, so the file opens
|
|
99
|
+
with **zero external requests**. Verified in a browser: the inlined transcript renders
|
|
100
|
+
identically to the CDN version (same layout, same shadow DOM), loading 35 modules from
|
|
101
|
+
`blob:` URLs and nothing from the network.
|
|
102
|
+
|
|
103
|
+
| | Default | `inlineAssets: true` |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| File size | ~72 kB | ~620 kB |
|
|
106
|
+
| External hosts on open | jsDelivr, cdnjs, Discord CDN | Discord CDN only (none with `saveImages`) |
|
|
107
|
+
| Works offline | no | yes |
|
|
108
|
+
|
|
109
|
+
The download happens once per process and is cached, so the first transcript pays
|
|
110
|
+
about a second and later ones are unaffected. If an asset cannot be fetched the CDN
|
|
111
|
+
reference is kept and a warning is logged — the export never fails over this. Use
|
|
112
|
+
`inlineAssetsTimeout` (default `30000` ms) to bound the downloads.
|
|
113
|
+
|
|
114
|
+
### Message timestamps
|
|
115
|
+
|
|
116
|
+
Timestamps are worded the way Discord words them, relative to when the transcript was
|
|
117
|
+
generated:
|
|
118
|
+
|
|
119
|
+
| When the message was sent | Rendered as |
|
|
120
|
+
| --- | --- |
|
|
121
|
+
| Today | `07:16` |
|
|
122
|
+
| Yesterday | `Yesterday at 07:16` (`Gestern um 07:16` with `language: 'de'`) |
|
|
123
|
+
| Anything older | `11/08/2026 07:16` |
|
|
124
|
+
|
|
125
|
+
The reference point is transcript creation time, so an archived transcript keeps saying
|
|
126
|
+
the same thing no matter when it is opened. The exact instant stays machine-readable in
|
|
127
|
+
`data-timestamp` (epoch ms) and `data-timestamp-iso` on every `<discord-message>`.
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
await createTranscript(channel, {
|
|
131
|
+
dateFormat: 'mm/dd/yyyy', // default 'dd/mm/yyyy'
|
|
132
|
+
timeFormat: '12h', // default '24h'
|
|
133
|
+
});
|
|
134
|
+
// → "07:16 AM", "Yesterday at 10:45 PM", "08/11/2026 02:30 PM"
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Times use the timezone of the machine generating the transcript.
|
|
138
|
+
|
|
73
139
|
### Configurable stats footer
|
|
74
140
|
|
|
75
141
|
```js
|
|
76
142
|
await createTranscript(channel, {
|
|
77
143
|
statsFooter: {
|
|
78
|
-
template: '{messages}
|
|
144
|
+
template: '{messages} messages from {participants} people · {images} images · {from} → {to}',
|
|
79
145
|
},
|
|
80
146
|
});
|
|
81
147
|
|
|
@@ -199,6 +265,10 @@ In addition to plain text, replies, embeds, and attachments, the viewer supports
|
|
|
199
265
|
|
|
200
266
|
- **`react`, `react-dom`, `debug` moved into regular dependencies** so users don't install them manually (`debug` was actually a missing runtime dep in the original — `images.js` requires it)
|
|
201
267
|
- **`sharp` declared as an optional peer dependency** — needed only for `.withCompression()`, no longer a hidden requirement
|
|
268
|
+
- **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
|
+
- **`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
|
+
- **`@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 renders with zero external requests
|
|
202
272
|
- **TypeScript declarations match runtime** — `ExportReturnType.Stream`, `language`, `i18n`, `stream`, and `withConcurrency()` are now exposed in the types
|
|
203
273
|
- `discord.js` remains the only **required** peer dependency
|
|
204
274
|
|
package/dist/generator/index.js
CHANGED
|
@@ -20,7 +20,9 @@ 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");
|
|
@@ -119,7 +121,14 @@ async function render(_a) {
|
|
|
119
121
|
stats,
|
|
120
122
|
};
|
|
121
123
|
|
|
122
|
-
|
|
124
|
+
// Resolved once so every message in this transcript is measured against the
|
|
125
|
+
// same "now" and shares one date/time format.
|
|
126
|
+
const timestampFormat = (0, utils_1.resolveTimestampFormat)({
|
|
127
|
+
dateFormat: options.dateFormat,
|
|
128
|
+
timeFormat: options.timeFormat,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const renderContext = Object.assign({ lang, i18n, dateSeps, stats, timestampFormat }, callbacks ? { callbacks } : {});
|
|
123
132
|
|
|
124
133
|
const docTree = (0, jsx_runtime_1.jsxs)("html", { lang, children: [
|
|
125
134
|
(0, jsx_runtime_1.jsxs)("head", { children: [
|
|
@@ -173,15 +182,31 @@ async function render(_a) {
|
|
|
173
182
|
|
|
174
183
|
const { prelude } = await (0, static_1.prerenderToNodeStream)(docTree);
|
|
175
184
|
|
|
185
|
+
// `hydrate` used to additionally push the finished markup through
|
|
186
|
+
// @lit-labs/ssr. That round-trip was measured to contribute exactly four
|
|
187
|
+
// inert <!--lit-part--> comments and nothing else — the page never loads a
|
|
188
|
+
// lit hydration client, and the <discord-*> elements hydrate themselves once
|
|
189
|
+
// their definitions are registered. Two dependencies (and the deprecated
|
|
190
|
+
// node-fetch → fetch-blob → node-domexception chain they dragged along) for
|
|
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.
|
|
193
|
+
const wantsStream = options.returnType === 'stream' || options.stream;
|
|
194
|
+
|
|
195
|
+
if (options.inlineAssets) {
|
|
196
|
+
// Inlining rewrites the finished document, so it cannot be streamed as it
|
|
197
|
+
// is produced. The stream contract is still honoured — the buffered result
|
|
198
|
+
// is handed back as a Readable.
|
|
199
|
+
const markup = await (0, selfContained_1.inlineExternalAssets)(await (0, utils_1.streamToString)(prelude), {
|
|
200
|
+
timeout: options.inlineAssetsTimeout,
|
|
201
|
+
});
|
|
202
|
+
return wantsStream ? stream_1.Readable.from([markup]) : markup;
|
|
203
|
+
}
|
|
204
|
+
|
|
176
205
|
if (options.hydrate) {
|
|
177
|
-
|
|
178
|
-
const { render: renderLit } = await import('@lit-labs/ssr');
|
|
179
|
-
const result = renderLit(markup);
|
|
180
|
-
const { collectResult } = await import('@lit-labs/ssr/lib/render-result.js');
|
|
181
|
-
return await collectResult(result);
|
|
206
|
+
return await (0, utils_1.streamToString)(prelude);
|
|
182
207
|
}
|
|
183
208
|
|
|
184
|
-
if (
|
|
209
|
+
if (wantsStream) {
|
|
185
210
|
// Return the underlying Node stream — caller is responsible for piping.
|
|
186
211
|
return prelude;
|
|
187
212
|
}
|
|
@@ -10,6 +10,11 @@ const embeds_1 = require("../../utils/embeds");
|
|
|
10
10
|
const content_1 = __importStar(require("./content"));
|
|
11
11
|
const utils_1 = require("../../utils/utils");
|
|
12
12
|
|
|
13
|
+
function t(context, key, fallback) {
|
|
14
|
+
const dict = context?.i18n?.[context?.lang] || context?.i18n?.en || {};
|
|
15
|
+
return dict[key] || fallback || key;
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
async function DiscordEmbed({ embed, context }) {
|
|
14
19
|
const type = embed.type || 'rich';
|
|
15
20
|
const url = embed.url ? (0, utils_1.safeHref)(embed.url) : undefined;
|
|
@@ -71,7 +76,7 @@ async function DiscordEmbed({ embed, context }) {
|
|
|
71
76
|
embed.footer && ((0, jsx_runtime_1.jsx)("discord-embed-footer", {
|
|
72
77
|
slot: "footer",
|
|
73
78
|
footerImage: embed.footer.proxyIconURL ?? embed.footer.iconURL,
|
|
74
|
-
timestamp: embed.timestamp
|
|
79
|
+
timestamp: (0, utils_1.formatMessageTimestamp)(embed.timestamp, context?.timestampFormat, t(context, 'yesterdayAt', 'Yesterday at {time}')),
|
|
75
80
|
children: embed.footer.text
|
|
76
81
|
}))
|
|
77
82
|
]
|
|
@@ -110,6 +110,13 @@ async function DiscordMessage({ message, context }) {
|
|
|
110
110
|
const snapshots = Array.isArray(message.messageSnapshots) ? message.messageSnapshots : [];
|
|
111
111
|
|
|
112
112
|
const editedAtIso = message.editedAt instanceof Date ? message.editedAt.toISOString() : null;
|
|
113
|
+
// Hover text for the "(edited)" marker — the ISO string stays in
|
|
114
|
+
// data-edit-iso for tooling, but humans get the same wording as the
|
|
115
|
+
// message timestamps instead of "2026-08-14T07:16:07.422Z".
|
|
116
|
+
const editedAtLabel = (0, utils_1.formatMessageTimestamp)(message.editedAt, context?.timestampFormat, t(context, 'yesterdayAt', 'Yesterday at {time}'));
|
|
117
|
+
const editedAtTitle = editedAtLabel
|
|
118
|
+
? t(context, 'editedAt', 'Edited at {time}').replace('{time}', editedAtLabel)
|
|
119
|
+
: undefined;
|
|
113
120
|
const pinned = !!message.pinned;
|
|
114
121
|
|
|
115
122
|
// Forum applied tags — only on the first message of a forum thread (the post itself)
|
|
@@ -138,17 +145,20 @@ async function DiscordMessage({ message, context }) {
|
|
|
138
145
|
// no longer render a separate "/cmd opt:val by user" line.
|
|
139
146
|
const slashData = buildSlashCommandData(message);
|
|
140
147
|
|
|
141
|
-
//
|
|
142
|
-
// Date
|
|
143
|
-
//
|
|
144
|
-
//
|
|
148
|
+
// The timestamp is formatted here rather than handed to <discord-message>
|
|
149
|
+
// as a Date or ISO string. Skyra's converter only formats real Date
|
|
150
|
+
// objects — an attribute always arrives as a string, so it passed the raw
|
|
151
|
+
// "2026-08-14T07:16:07.422Z" straight through to the page. Preformatting
|
|
152
|
+
// also lets us match Discord's own today/yesterday/date wording and honour
|
|
153
|
+
// the dateFormat + timeFormat options.
|
|
145
154
|
const createdAtIso = message.createdAt instanceof Date
|
|
146
155
|
? message.createdAt.toISOString()
|
|
147
156
|
: (typeof message.createdAt === 'string' ? message.createdAt : undefined);
|
|
157
|
+
const timestampLabel = (0, utils_1.formatMessageTimestamp)(message.createdAt, context?.timestampFormat, t(context, 'yesterdayAt', 'Yesterday at {time}'));
|
|
148
158
|
|
|
149
159
|
return ((0, jsx_runtime_1.jsxs)("discord-message", {
|
|
150
160
|
id: `m-${message.id}`,
|
|
151
|
-
timestamp:
|
|
161
|
+
timestamp: timestampLabel,
|
|
152
162
|
edited: message.editedAt !== null,
|
|
153
163
|
server: (isCrossGuildReply || isCrosspost || isCrossposted) ? true : undefined,
|
|
154
164
|
highlight: message.mentions.everyone || pinned,
|
|
@@ -158,6 +168,7 @@ async function DiscordMessage({ message, context }) {
|
|
|
158
168
|
"data-author-name": authorName,
|
|
159
169
|
"data-roles": memberRoleIds,
|
|
160
170
|
"data-timestamp": ts ? String(ts) : undefined,
|
|
171
|
+
"data-timestamp-iso": createdAtIso,
|
|
161
172
|
"data-text": lowerText,
|
|
162
173
|
"data-pinned": pinned ? 'true' : undefined,
|
|
163
174
|
"data-has-image": hasImage ? 'true' : undefined,
|
|
@@ -184,7 +195,7 @@ async function DiscordMessage({ message, context }) {
|
|
|
184
195
|
(0, jsx_runtime_1.jsx)("span", { className: "dht-activity-icon", children: '🎮' }),
|
|
185
196
|
(0, jsx_runtime_1.jsxs)("span", { children: [activity.type, ' invite', activity.partyId ? ` · Party ${activity.partyId.slice(-6)}` : ''] })
|
|
186
197
|
] }),
|
|
187
|
-
editedAtIso && (0, jsx_runtime_1.jsx)("span", { className: "dht-edit-marker", title: editedAtIso, "data-edit-iso": editedAtIso, "data-i18n": "edited", "data-i18n-params": JSON.stringify({ time: editedAtIso }), children: '(' + t(context, 'edited', 'edited') + ')' }),
|
|
198
|
+
editedAtIso && (0, jsx_runtime_1.jsx)("span", { className: "dht-edit-marker", title: editedAtTitle || editedAtIso, "data-edit-iso": editedAtIso, "data-i18n": "edited", "data-i18n-params": JSON.stringify({ time: editedAtLabel || editedAtIso }), children: '(' + t(context, 'edited', 'edited') + ')' }),
|
|
188
199
|
Array.isArray(message.editHistory) && message.editHistory.length > 0 && renderEditHistory(message.editHistory, context),
|
|
189
200
|
snapshots.length > 0 && renderSnapshots(snapshots, context),
|
|
190
201
|
message.content && ((0, jsx_runtime_1.jsx)(content_1.default, { content: message.content, context: Object.assign({}, context, { type: message.webhookId ? content_1.RenderType.WEBHOOK : content_1.RenderType.NORMAL }) })),
|
|
@@ -18,11 +18,11 @@ async function SystemMessage({ message, context }) {
|
|
|
18
18
|
const color = message.member?.roles?.color?.hexColor;
|
|
19
19
|
const authorId = author?.id;
|
|
20
20
|
const ctx = context || {};
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
// Preformatted for the same reason as in message.js: skyra only formats real
|
|
22
|
+
// Date objects, and an attribute always arrives as a string — so an ISO value
|
|
23
|
+
// was rendered verbatim. This also keeps system messages worded like the
|
|
24
|
+
// regular ones (today / yesterday / full date).
|
|
25
|
+
const ts = (0, utils_1.formatMessageTimestamp)(message.createdAt, ctx.timestampFormat, t(ctx, 'yesterdayAt', 'Yesterday at {time}'));
|
|
26
26
|
|
|
27
27
|
switch (message.type) {
|
|
28
28
|
case discord_js_1.MessageType.RecipientAdd:
|
package/dist/static/client.js
CHANGED
|
@@ -61,6 +61,7 @@ const defaultDicts = {
|
|
|
61
61
|
en: {
|
|
62
62
|
edited: 'edited',
|
|
63
63
|
editedAt: 'Edited at {time}',
|
|
64
|
+
yesterdayAt: 'Yesterday at {time}',
|
|
64
65
|
joined: 'joined the server',
|
|
65
66
|
pinned: 'pinned {message} to this channel.',
|
|
66
67
|
pinnedLink: 'a message',
|
|
@@ -114,6 +115,7 @@ const defaultDicts = {
|
|
|
114
115
|
de: {
|
|
115
116
|
edited: 'bearbeitet',
|
|
116
117
|
editedAt: 'Bearbeitet am {time}',
|
|
118
|
+
yesterdayAt: 'Gestern um {time}',
|
|
117
119
|
joined: 'ist dem Server beigetreten',
|
|
118
120
|
pinned: 'hat {message} in diesem Channel angepinnt.',
|
|
119
121
|
pinnedLink: 'eine Nachricht',
|
package/dist/types.d.ts
CHANGED
|
@@ -61,7 +61,7 @@ export type GenerateFromMessagesOptions<T extends ExportReturnType> = Partial<{
|
|
|
61
61
|
* Stats footer rendered at the bottom (e.g. "12 messages · 3 participants · 2 images · …").
|
|
62
62
|
* - `false` to disable entirely
|
|
63
63
|
* - `{ enabled: false }` to disable
|
|
64
|
-
* - `{ template: '{messages}
|
|
64
|
+
* - `{ template: '{messages} messages from {participants} people · {images} images · {from} → {to}' }`
|
|
65
65
|
* to render with a custom string. Supported placeholders:
|
|
66
66
|
* `{messages}` `{participants}` `{images}` `{from}` `{to}` `{span}`
|
|
67
67
|
*
|
|
@@ -71,6 +71,41 @@ export type GenerateFromMessagesOptions<T extends ExportReturnType> = Partial<{
|
|
|
71
71
|
enabled?: boolean;
|
|
72
72
|
template?: string;
|
|
73
73
|
};
|
|
74
|
+
/**
|
|
75
|
+
* Embed the third-party assets the transcript would otherwise load from a CDN
|
|
76
|
+
* at view time — the `<discord-*>` web component runtime (from jsDelivr) and
|
|
77
|
+
* the Twemoji SVGs (from cdnjs) — directly into the HTML.
|
|
78
|
+
*
|
|
79
|
+
* Without this the file needs internet access whenever it is *opened*: offline,
|
|
80
|
+
* behind a CDN-blocking network or after a CDN outage the transcript renders
|
|
81
|
+
* unstyled. It also means every viewer's IP reaches those CDNs, which may
|
|
82
|
+
* matter for GDPR-sensitive ticket archives.
|
|
83
|
+
*
|
|
84
|
+
* Costs roughly +550 kB per file and one download at generation time (cached
|
|
85
|
+
* per process). Discord's own CDN is untouched — use `saveImages` for that.
|
|
86
|
+
*
|
|
87
|
+
* Never fails the export: if an asset cannot be fetched, the CDN reference is
|
|
88
|
+
* kept and a warning is logged.
|
|
89
|
+
* @default false
|
|
90
|
+
*/
|
|
91
|
+
inlineAssets: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* Per-request timeout in ms while downloading the assets for `inlineAssets`.
|
|
94
|
+
* @default 30000
|
|
95
|
+
*/
|
|
96
|
+
inlineAssetsTimeout: number;
|
|
97
|
+
/**
|
|
98
|
+
* Date order used whenever a message timestamp is older than yesterday
|
|
99
|
+
* (e.g. `11/08/2026 07:16`).
|
|
100
|
+
* @default 'dd/mm/yyyy'
|
|
101
|
+
*/
|
|
102
|
+
dateFormat: 'dd/mm/yyyy' | 'mm/dd/yyyy';
|
|
103
|
+
/**
|
|
104
|
+
* Clock format for message timestamps — `'24h'` renders `07:16`,
|
|
105
|
+
* `'12h'` renders `07:16 AM`.
|
|
106
|
+
* @default '24h'
|
|
107
|
+
*/
|
|
108
|
+
timeFormat: '24h' | '12h';
|
|
74
109
|
/**
|
|
75
110
|
* UI language for the built-in strings (participant labels, filter UI, stats footer, …).
|
|
76
111
|
* @default 'en'
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.inlineExternalAssets = inlineExternalAssets;
|
|
7
|
+
exports.clearInlineAssetCache = clearInlineAssetCache;
|
|
8
|
+
const undici_1 = require("undici");
|
|
9
|
+
const utils_1 = require("./utils");
|
|
10
|
+
const debug_1 = __importDefault(require("debug"));
|
|
11
|
+
|
|
12
|
+
const log = (0, debug_1.default)('discord-html-transcripts:selfContained');
|
|
13
|
+
|
|
14
|
+
const JSDELIVR = 'https://cdn.jsdelivr.net';
|
|
15
|
+
// Every specifier inside the jsDelivr "+esm" graph is an absolute /npm/… path,
|
|
16
|
+
// which is what makes the blob rewrite below safe. Matching the quoted path
|
|
17
|
+
// directly rather than trying to parse import/export syntax is deliberate: a
|
|
18
|
+
// syntax-shaped pattern silently skipped side-effect imports (`import"/npm/x";`)
|
|
19
|
+
// because its optional `from` group ran ahead to the next `from` in the file.
|
|
20
|
+
const SPECIFIER = /["'](\/npm\/[^"']+)["']/g;
|
|
21
|
+
// Anything the blob rewrite could not resolve on its own.
|
|
22
|
+
const RELATIVE_SPECIFIER = /(?:from|import)\s*\(?\s*["']\.{1,2}\//;
|
|
23
|
+
const TWEMOJI_URL = /https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/twemoji\/[0-9.]+\/svg\/[0-9a-f-]+\.svg/g;
|
|
24
|
+
|
|
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.
|
|
27
|
+
const graphCache = new Map();
|
|
28
|
+
const emojiCache = new Map();
|
|
29
|
+
|
|
30
|
+
function clearInlineAssetCache() {
|
|
31
|
+
graphCache.clear();
|
|
32
|
+
emojiCache.clear();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function fetchText(url, timeoutMs) {
|
|
36
|
+
const res = await (0, undici_1.request)(url, { headersTimeout: timeoutMs, bodyTimeout: timeoutMs });
|
|
37
|
+
if (res.statusCode !== 200) {
|
|
38
|
+
await res.body.dump();
|
|
39
|
+
throw new Error(`GET ${url} → HTTP ${res.statusCode}`);
|
|
40
|
+
}
|
|
41
|
+
return await res.body.text();
|
|
42
|
+
}
|
|
43
|
+
|
|
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
|
+
async function fetchModuleGraph(entry, timeoutMs) {
|
|
48
|
+
const cached = graphCache.get(entry);
|
|
49
|
+
if (cached) return cached;
|
|
50
|
+
|
|
51
|
+
const sources = new Map();
|
|
52
|
+
const deps = new Map();
|
|
53
|
+
const queue = [entry];
|
|
54
|
+
|
|
55
|
+
while (queue.length) {
|
|
56
|
+
const spec = queue.shift();
|
|
57
|
+
if (sources.has(spec)) continue;
|
|
58
|
+
const src = await fetchText(JSDELIVR + spec, timeoutMs);
|
|
59
|
+
if (RELATIVE_SPECIFIER.test(src)) {
|
|
60
|
+
// A relative specifier would resolve against the blob URL at runtime,
|
|
61
|
+
// which cannot work — bail out rather than ship a broken page.
|
|
62
|
+
throw new Error(`relative import in ${spec}`);
|
|
63
|
+
}
|
|
64
|
+
sources.set(spec, src);
|
|
65
|
+
const own = [];
|
|
66
|
+
for (const m of src.matchAll(SPECIFIER)) {
|
|
67
|
+
own.push(m[1]);
|
|
68
|
+
if (!sources.has(m[1])) queue.push(m[1]);
|
|
69
|
+
}
|
|
70
|
+
deps.set(spec, own);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const order = [];
|
|
74
|
+
const state = new Map();
|
|
75
|
+
const visit = (node) => {
|
|
76
|
+
const s = state.get(node);
|
|
77
|
+
if (s === 'done') return;
|
|
78
|
+
if (s === 'open') throw new Error(`import cycle at ${node}`);
|
|
79
|
+
state.set(node, 'open');
|
|
80
|
+
for (const d of deps.get(node) || []) visit(d);
|
|
81
|
+
state.set(node, 'done');
|
|
82
|
+
order.push(node);
|
|
83
|
+
};
|
|
84
|
+
visit(entry);
|
|
85
|
+
|
|
86
|
+
// Dry-run of the browser-side rewrite. If any /npm/… specifier would survive
|
|
87
|
+
// it, the page would fail at runtime with an unresolvable module — better to
|
|
88
|
+
// find that here and keep the CDN reference instead.
|
|
89
|
+
const built = new Set();
|
|
90
|
+
for (const spec of order) {
|
|
91
|
+
let src = sources.get(spec);
|
|
92
|
+
for (const dep of built) src = src.split(`"${dep}"`).join('""').split(`'${dep}'`).join("''");
|
|
93
|
+
const leftover = src.match(SPECIFIER);
|
|
94
|
+
if (leftover) throw new Error(`unresolved specifier ${leftover[0]} in ${spec}`);
|
|
95
|
+
built.add(spec);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const graph = { entry, order, sources: Object.fromEntries(sources) };
|
|
99
|
+
log('fetched %d modules for %s', order.length, entry);
|
|
100
|
+
graphCache.set(entry, graph);
|
|
101
|
+
return graph;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Rebuilds the module graph in the browser: each module's /npm/… specifiers are
|
|
105
|
+
// swapped for the blob URL of the dependency built just before it, so no import
|
|
106
|
+
// map and no network access are involved.
|
|
107
|
+
function buildBootstrap(graph) {
|
|
108
|
+
const payload = (0, utils_1.safeJsonForScript)({ entry: graph.entry, order: graph.order, sources: graph.sources });
|
|
109
|
+
return `(function(){try{var G=${payload};var urls={};` +
|
|
110
|
+
`G.order.forEach(function(spec){var src=G.sources[spec];` +
|
|
111
|
+
`Object.keys(urls).forEach(function(dep){` +
|
|
112
|
+
`src=src.split('"'+dep+'"').join('"'+urls[dep]+'"').split("'"+dep+"'").join("'"+urls[dep]+"'");});` +
|
|
113
|
+
`urls[spec]=URL.createObjectURL(new Blob([src],{type:'text/javascript'}));});` +
|
|
114
|
+
`import(urls[G.entry]).catch(function(e){console.error('[discord-html-transcripts-fix] inlined component runtime failed to start:',e);});` +
|
|
115
|
+
`}catch(e){console.error('[discord-html-transcripts-fix] inlined component runtime is malformed:',e);}})();`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function inlineTwemoji(html, timeoutMs) {
|
|
119
|
+
const urls = [...new Set(html.match(TWEMOJI_URL) || [])];
|
|
120
|
+
if (urls.length === 0) return html;
|
|
121
|
+
|
|
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);
|
|
138
|
+
}
|
|
139
|
+
if (dataUri) {
|
|
140
|
+
out = out.split(url).join(dataUri);
|
|
141
|
+
inlined++;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
log('inlined %d/%d emoji', inlined, urls.length);
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Replaces the third-party CDN references in a rendered transcript with inline
|
|
150
|
+
* copies, so the file renders without jsDelivr or cdnjs.
|
|
151
|
+
*
|
|
152
|
+
* Discord's own CDN (avatars, attachments) is deliberately left alone — that is
|
|
153
|
+
* what `saveImages` covers.
|
|
154
|
+
*
|
|
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.
|
|
157
|
+
*/
|
|
158
|
+
async function inlineExternalAssets(html, options) {
|
|
159
|
+
const opts = options || {};
|
|
160
|
+
const timeoutMs = typeof opts.timeout === 'number' ? opts.timeout : 30000;
|
|
161
|
+
let out = html;
|
|
162
|
+
|
|
163
|
+
const scriptTag = out.match(/<script[^>]*type="module"[^>]*src="https:\/\/cdn\.jsdelivr\.net(\/npm\/[^"]+)"[^>]*><\/script>/);
|
|
164
|
+
if (scriptTag) {
|
|
165
|
+
try {
|
|
166
|
+
const graph = await fetchModuleGraph(scriptTag[1], timeoutMs);
|
|
167
|
+
const tag = `<script>${buildBootstrap(graph)}</script>`;
|
|
168
|
+
// Replacement MUST be a function: minified module sources contain `$'`
|
|
169
|
+
// and `$&`, which String.replace would expand as match references and
|
|
170
|
+
// splice half the document into the middle of the script.
|
|
171
|
+
out = out.replace(scriptTag[0], () => tag);
|
|
172
|
+
}
|
|
173
|
+
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));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
out = await inlineTwemoji(out, timeoutMs);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
console.warn('[discord-html-transcripts-fix] inlineAssets: could not inline emoji, keeping CDN references — ' +
|
|
184
|
+
(err && err.message ? err.message : err));
|
|
185
|
+
}
|
|
186
|
+
|
|
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, '');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return out;
|
|
193
|
+
}
|
package/dist/utils/utils.js
CHANGED
|
@@ -12,8 +12,69 @@ exports.safeHref = safeHref;
|
|
|
12
12
|
exports.safeColor = safeColor;
|
|
13
13
|
exports.safeImageMime = safeImageMime;
|
|
14
14
|
exports.escapeHtml = escapeHtml;
|
|
15
|
+
exports.resolveTimestampFormat = resolveTimestampFormat;
|
|
16
|
+
exports.formatMessageTimestamp = formatMessageTimestamp;
|
|
15
17
|
const twemoji_1 = __importDefault(require("twemoji"));
|
|
16
18
|
|
|
19
|
+
const DEFAULT_TIMESTAMP_FORMAT = { dateFormat: 'dd/mm/yyyy', timeFormat: '24h' };
|
|
20
|
+
|
|
21
|
+
function pad2(n) {
|
|
22
|
+
return n < 10 ? '0' + n : String(n);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Normalizes the user-facing options once, at render start, so every renderer
|
|
26
|
+
// shares one reference "now" — otherwise a transcript rendered across midnight
|
|
27
|
+
// could label the same day both "today" and with a full date.
|
|
28
|
+
function resolveTimestampFormat(options) {
|
|
29
|
+
const o = options || {};
|
|
30
|
+
return {
|
|
31
|
+
dateFormat: o.dateFormat === 'mm/dd/yyyy' ? 'mm/dd/yyyy' : DEFAULT_TIMESTAMP_FORMAT.dateFormat,
|
|
32
|
+
timeFormat: o.timeFormat === '12h' ? '12h' : DEFAULT_TIMESTAMP_FORMAT.timeFormat,
|
|
33
|
+
now: o.now instanceof Date ? o.now : new Date(),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function formatClock(d, timeFormat) {
|
|
38
|
+
const minutes = pad2(d.getMinutes());
|
|
39
|
+
if (timeFormat === '12h') {
|
|
40
|
+
const h = d.getHours();
|
|
41
|
+
const hour12 = h % 12 === 0 ? 12 : h % 12;
|
|
42
|
+
return `${pad2(hour12)}:${minutes} ${h < 12 ? 'AM' : 'PM'}`;
|
|
43
|
+
}
|
|
44
|
+
return `${pad2(d.getHours())}:${minutes}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatCalendarDate(d, dateFormat) {
|
|
48
|
+
const day = pad2(d.getDate());
|
|
49
|
+
const month = pad2(d.getMonth() + 1);
|
|
50
|
+
return dateFormat === 'mm/dd/yyyy'
|
|
51
|
+
? `${month}/${day}/${d.getFullYear()}`
|
|
52
|
+
: `${day}/${month}/${d.getFullYear()}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function startOfDay(d) {
|
|
56
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Mirrors how Discord labels message timestamps: today shows the bare time,
|
|
60
|
+
// yesterday is spelled out, anything older gets the full date. The reference
|
|
61
|
+
// point is transcript creation time, so an archived transcript keeps saying the
|
|
62
|
+
// same thing no matter when it is opened.
|
|
63
|
+
// Returns undefined for unusable input so callers can omit the attribute
|
|
64
|
+
// entirely rather than render an empty timestamp.
|
|
65
|
+
function formatMessageTimestamp(value, format, yesterdayTemplate) {
|
|
66
|
+
const d = value instanceof Date ? value : (typeof value === 'string' || typeof value === 'number' ? new Date(value) : null);
|
|
67
|
+
if (!d || !Number.isFinite(d.getTime())) return undefined;
|
|
68
|
+
|
|
69
|
+
const fmt = format && format.now instanceof Date ? format : resolveTimestampFormat(format);
|
|
70
|
+
const time = formatClock(d, fmt.timeFormat);
|
|
71
|
+
const dayDiff = Math.round((startOfDay(fmt.now) - startOfDay(d)) / 86400000);
|
|
72
|
+
|
|
73
|
+
if (dayDiff === 0) return time;
|
|
74
|
+
if (dayDiff === 1) return (yesterdayTemplate || 'Yesterday at {time}').replace('{time}', time);
|
|
75
|
+
return `${formatCalendarDate(d, fmt.dateFormat)} ${time}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
17
78
|
function isDefined(value) {
|
|
18
79
|
return value !== undefined && value !== null;
|
|
19
80
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discord-html-transcripts-fix",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "A nicely formatted html transcript generator for discord.js. Bugfix fork with support for the latest discord.js and Components v2.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -31,7 +31,6 @@
|
|
|
31
31
|
"LICENSE"
|
|
32
32
|
],
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@lit-labs/ssr": "^3.3.1",
|
|
35
34
|
"@skyra/discord-components-core": "^4.0.2",
|
|
36
35
|
"debug": "^4.4.3",
|
|
37
36
|
"discord-markdown-parser": "~1.3.0",
|
|
@@ -40,7 +39,7 @@
|
|
|
40
39
|
"react-dom": "^19.2.3",
|
|
41
40
|
"simple-markdown": "^0.7.3",
|
|
42
41
|
"twemoji": "^14.0.2",
|
|
43
|
-
"undici": "^7.
|
|
42
|
+
"undici": "^7.29.0"
|
|
44
43
|
},
|
|
45
44
|
"peerDependencies": {
|
|
46
45
|
"discord.js": "^14.0.0 || ^15.0.0",
|