discord-html-transcripts-fix 1.8.2 → 2.1.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 +125 -10
- package/dist/downloader/images.d.ts +25 -1
- package/dist/generator/index.js +53 -5
- 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/index.js +2 -1
- package/dist/static/client.js +2 -0
- package/dist/types.d.ts +36 -5
- package/dist/utils/utils.js +61 -0
- package/package.json +22 -3
package/README.md
CHANGED
|
@@ -1,16 +1,51 @@
|
|
|
1
1
|
# `discord-html-transcripts-fix`
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/discord-html-transcripts-fix)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
|
|
3
7
|
A nicely formatted HTML transcript generator for [discord.js](https://discord.js.org/) with full **Components V2** support, an **interactive viewer**, and **hardened security**.
|
|
4
8
|
|
|
5
9
|
Forked from [discord-html-transcripts](https://github.com/ItzDerock/discord-html-transcripts).
|
|
6
10
|
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- **Node.js ≥ 20** — the image downloader uses `undici` v7.
|
|
14
|
+
- **discord.js v14 or v15** — required peer dependency.
|
|
15
|
+
- **[`sharp`](https://sharp.pixelplumbing.com/)** — *optional* peer dependency, only needed if you use `.withCompression()` to compress / convert transcript images to WebP.
|
|
16
|
+
|
|
7
17
|
## Install
|
|
8
18
|
|
|
9
19
|
```bash
|
|
10
20
|
npm install discord-html-transcripts-fix
|
|
11
21
|
```
|
|
12
22
|
|
|
13
|
-
|
|
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:
|
|
30
|
+
|
|
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>
|
|
14
49
|
|
|
15
50
|
## Quick start
|
|
16
51
|
|
|
@@ -18,11 +53,28 @@ Only `discord.js` is a peer dependency; everything else (React, Lit SSR, markdow
|
|
|
18
53
|
const { createTranscript } = require('discord-html-transcripts-fix');
|
|
19
54
|
|
|
20
55
|
const attachment = await createTranscript(channel, {
|
|
21
|
-
limit: -1,
|
|
56
|
+
limit: -1, // fetch every message
|
|
22
57
|
saveImages: false,
|
|
23
58
|
});
|
|
24
59
|
|
|
25
|
-
channel.send({ files: [attachment] });
|
|
60
|
+
await channel.send({ files: [attachment] });
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### TypeScript
|
|
64
|
+
|
|
65
|
+
In TypeScript, use the `ExportReturnType` enum for `returnType` (the return value is typed accordingly):
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { createTranscript, ExportReturnType } from 'discord-html-transcripts-fix';
|
|
69
|
+
|
|
70
|
+
const html = await createTranscript(channel, {
|
|
71
|
+
returnType: ExportReturnType.String, // => Promise<string>
|
|
72
|
+
language: 'de',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const stream = await createTranscript(channel, {
|
|
76
|
+
returnType: ExportReturnType.Stream, // => Promise<Readable>, ideal for huge tickets
|
|
77
|
+
});
|
|
26
78
|
```
|
|
27
79
|
|
|
28
80
|
## Options
|
|
@@ -31,24 +83,51 @@ channel.send({ files: [attachment] });
|
|
|
31
83
|
|---|---|---|---|
|
|
32
84
|
| `limit` | `number` | `-1` | Max messages to fetch. `-1` = recursive (all). |
|
|
33
85
|
| `filter` | `(m) => boolean` | `() => true` | Predicate to filter messages. |
|
|
34
|
-
| `returnType` | `'attachment'` \| `'buffer'` \| `'string'` \| `'stream'` | `'attachment'` | Return value shape. `'stream'` is best for 5k+ message exports. |
|
|
86
|
+
| `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. |
|
|
35
87
|
| `filename` | `string` | `transcript-{channel-id}.html` | Output filename when returning as attachment. |
|
|
36
88
|
| `saveImages` | `boolean` | `false` | Download images and inline them as base64 data URLs. |
|
|
37
89
|
| `favicon` | `'guild'` \| `string` | `'guild'` | Page favicon — `'guild'` uses the server icon, or pass a URL. |
|
|
38
90
|
| `hydrate` | `boolean` | `false` | Server-side hydrate via `@lit-labs/ssr` (slower; usually leave off). |
|
|
91
|
+
| `dateFormat` | `'dd/mm/yyyy'` \| `'mm/dd/yyyy'` | `'dd/mm/yyyy'` | Date order for message timestamps older than yesterday. |
|
|
92
|
+
| `timeFormat` | `'24h'` \| `'12h'` | `'24h'` | Clock format for message timestamps — `07:16` vs `07:16 AM`. |
|
|
39
93
|
| `language` | `'en'` \| `'de'` | `'en'` | UI language for participant labels, filter strings, etc. |
|
|
40
94
|
| `i18n` | `Partial<Record<lang, Record<key,string>>>` | — | Override individual strings per language. |
|
|
41
95
|
| `statsFooter` | `false` \| `{ enabled?, template? }` | `{ enabled: true }` | Bottom stats line. See below. |
|
|
42
|
-
| `footerText` | `string` |
|
|
43
|
-
| `poweredBy` | `boolean` | `false` | Show the original "Powered by discord-html-transcripts" credit link. |
|
|
44
|
-
| `callbacks` | `{ resolveUser, resolveRole, resolveChannel }` | — | Custom resolvers for mentions. |
|
|
96
|
+
| `footerText` | `string` | `Exported {number} message{s}.` | Legacy "Exported X messages" line. Only renders when `statsFooter` is disabled. |
|
|
97
|
+
| `poweredBy` | `boolean` | `false` | Show the original "Powered by discord-html-transcripts" credit link. Only renders when `statsFooter` is disabled. |
|
|
98
|
+
| `callbacks` | `{ resolveUser, resolveRole, resolveChannel, resolveImageSrc }` | — | Custom resolvers for mentions / image URLs. |
|
|
99
|
+
|
|
100
|
+
### Message timestamps
|
|
101
|
+
|
|
102
|
+
Timestamps are worded the way Discord words them, relative to when the transcript was
|
|
103
|
+
generated:
|
|
104
|
+
|
|
105
|
+
| When the message was sent | Rendered as |
|
|
106
|
+
| --- | --- |
|
|
107
|
+
| Today | `07:16` |
|
|
108
|
+
| Yesterday | `Yesterday at 07:16` (`Gestern um 07:16` with `language: 'de'`) |
|
|
109
|
+
| Anything older | `11/08/2026 07:16` |
|
|
110
|
+
|
|
111
|
+
The reference point is transcript creation time, so an archived transcript keeps saying
|
|
112
|
+
the same thing no matter when it is opened. The exact instant stays machine-readable in
|
|
113
|
+
`data-timestamp` (epoch ms) and `data-timestamp-iso` on every `<discord-message>`.
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
await createTranscript(channel, {
|
|
117
|
+
dateFormat: 'mm/dd/yyyy', // default 'dd/mm/yyyy'
|
|
118
|
+
timeFormat: '12h', // default '24h'
|
|
119
|
+
});
|
|
120
|
+
// → "07:16 AM", "Yesterday at 10:45 PM", "08/11/2026 02:30 PM"
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Times use the timezone of the machine generating the transcript.
|
|
45
124
|
|
|
46
125
|
### Configurable stats footer
|
|
47
126
|
|
|
48
127
|
```js
|
|
49
128
|
await createTranscript(channel, {
|
|
50
129
|
statsFooter: {
|
|
51
|
-
template: '{messages}
|
|
130
|
+
template: '{messages} messages from {participants} people · {images} images · {from} → {to}',
|
|
52
131
|
},
|
|
53
132
|
});
|
|
54
133
|
|
|
@@ -58,6 +137,23 @@ await createTranscript(channel, { statsFooter: false });
|
|
|
58
137
|
|
|
59
138
|
Placeholders: `{messages}`, `{participants}`, `{images}`, `{from}`, `{to}`, `{span}`.
|
|
60
139
|
|
|
140
|
+
### Image compression (optional)
|
|
141
|
+
|
|
142
|
+
`saveImages: true` inlines images as base64 without any extra dependency. To additionally
|
|
143
|
+
compress them (and optionally convert to WebP), install `sharp` and build a custom downloader:
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
const { createTranscript, TranscriptImageDownloader } = require('discord-html-transcripts-fix');
|
|
147
|
+
|
|
148
|
+
const resolveImageSrc = new TranscriptImageDownloader()
|
|
149
|
+
.withMaxSize(2048) // KB per image
|
|
150
|
+
.withConcurrency(8) // parallel downloads (default 6)
|
|
151
|
+
.withCompression(80, true) // quality 80, convert to WebP — requires `sharp`
|
|
152
|
+
.build();
|
|
153
|
+
|
|
154
|
+
await createTranscript(channel, { saveImages: true, callbacks: { resolveImageSrc } });
|
|
155
|
+
```
|
|
156
|
+
|
|
61
157
|
### Optional edit history
|
|
62
158
|
|
|
63
159
|
If your bot tracks edits, attach them to the message *before* rendering:
|
|
@@ -130,6 +226,7 @@ In addition to plain text, replies, embeds, and attachments, the viewer supports
|
|
|
130
226
|
- **Fix** `parseDiscordEmoji` no longer throws on deleted reactions with `emoji.name === null`
|
|
131
227
|
- **Fix** `formatBytes(null/undefined/NaN)` no longer returns `NaN undefined`
|
|
132
228
|
- **Fix** `createTranscript` slice uses the resolved limit instead of the raw `limit`
|
|
229
|
+
- **Fix** `statsFooter` (custom template / `false`) is now forwarded end-to-end — it used to be silently ignored by `createTranscript`/`generateFromMessages`
|
|
133
230
|
- **Fix** embed fields render through a proper async component (was an inline `async` arrow inside `.map()`)
|
|
134
231
|
- **Fix** `JoinMessage` text is deterministic per message id — re-rendering the same channel always yields the same join line
|
|
135
232
|
- **Fix** random `console.log` calls in production paths replaced by the `debug` namespace
|
|
@@ -153,8 +250,26 @@ In addition to plain text, replies, embeds, and attachments, the viewer supports
|
|
|
153
250
|
### DX
|
|
154
251
|
|
|
155
252
|
- **`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)
|
|
156
|
-
-
|
|
157
|
-
- `
|
|
253
|
+
- **`sharp` declared as an optional peer dependency** — needed only for `.withCompression()`, no longer a hidden requirement
|
|
254
|
+
- **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
|
+
- **`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
|
|
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
|
|
257
|
+
- **TypeScript declarations match runtime** — `ExportReturnType.Stream`, `language`, `i18n`, `stream`, and `withConcurrency()` are now exposed in the types
|
|
258
|
+
- `discord.js` remains the only **required** peer dependency
|
|
259
|
+
|
|
260
|
+
## API
|
|
261
|
+
|
|
262
|
+
| Export | Description |
|
|
263
|
+
|---|---|
|
|
264
|
+
| `createTranscript(channel, options?)` | Fetch a channel's messages and render a transcript. |
|
|
265
|
+
| `generateFromMessages(messages, channel, options?)` | Render a transcript from a message array/collection you already have. |
|
|
266
|
+
| `ExportReturnType` | Enum: `Attachment` \| `Buffer` \| `String` \| `Stream`. |
|
|
267
|
+
| `TranscriptImageDownloader` | Builder for a custom image-saving callback (`withMaxSize`, `withConcurrency`, `withCompression`, `build`). |
|
|
268
|
+
| `DiscordMessages` | The underlying React component, for advanced/custom rendering. |
|
|
269
|
+
|
|
270
|
+
## License
|
|
271
|
+
|
|
272
|
+
[Apache-2.0](./LICENSE) — same as the original package.
|
|
158
273
|
|
|
159
274
|
## Credits
|
|
160
275
|
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import type { APIAttachment, APIMessage, Awaitable } from 'discord.js';
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Subset of sharp's `WebpOptions`. `sharp` is an OPTIONAL peer dependency — it is
|
|
4
|
+
* only needed for `.withCompression(..., convertToWebP)`. Typing it locally (instead
|
|
5
|
+
* of `import('sharp')`) means consumers who don't use compression are not forced to
|
|
6
|
+
* install sharp and don't get a "Cannot find module 'sharp'" error when type-checking.
|
|
7
|
+
*/
|
|
8
|
+
export interface WebpOptions {
|
|
9
|
+
quality?: number;
|
|
10
|
+
alphaQuality?: number;
|
|
11
|
+
lossless?: boolean;
|
|
12
|
+
nearLossless?: boolean;
|
|
13
|
+
smartSubsample?: boolean;
|
|
14
|
+
effort?: number;
|
|
15
|
+
loop?: number;
|
|
16
|
+
delay?: number | number[];
|
|
17
|
+
minSize?: boolean;
|
|
18
|
+
mixed?: boolean;
|
|
19
|
+
force?: boolean;
|
|
20
|
+
[option: string]: unknown;
|
|
21
|
+
}
|
|
3
22
|
/**
|
|
4
23
|
* Callback used to save an image attachment.
|
|
5
24
|
* The returned string is the URL that will be used in the transcript.
|
|
@@ -22,6 +41,11 @@ export declare class TranscriptImageDownloader {
|
|
|
22
41
|
* @param size The maximum file size in kilobytes
|
|
23
42
|
*/
|
|
24
43
|
withMaxSize(size: number): this;
|
|
44
|
+
/**
|
|
45
|
+
* Sets the maximum number of images downloaded concurrently.
|
|
46
|
+
* @param n Concurrency (default 6)
|
|
47
|
+
*/
|
|
48
|
+
withConcurrency(n: number): this;
|
|
25
49
|
/**
|
|
26
50
|
* Sets the compression quality for each image. This requires `sharp` to be installed.
|
|
27
51
|
* Optionally, images can be converted to WebP format which is smaller in size.
|
package/dist/generator/index.js
CHANGED
|
@@ -27,6 +27,18 @@ const styles_1 = require("./renderers/components/styles");
|
|
|
27
27
|
const DiscordImage_1 = require("./renderers/components/DiscordImage");
|
|
28
28
|
const DiscordHighlightedCode_1 = require("./renderers/components/DiscordHighlightedCode");
|
|
29
29
|
|
|
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
|
+
|
|
30
42
|
// Lock to an exact resolved version — semver ranges in src= aren't cacheable by CDNs.
|
|
31
43
|
let discordComponentsVersion = '4.0.2';
|
|
32
44
|
try {
|
|
@@ -119,7 +131,14 @@ async function render(_a) {
|
|
|
119
131
|
stats,
|
|
120
132
|
};
|
|
121
133
|
|
|
122
|
-
|
|
134
|
+
// Resolved once so every message in this transcript is measured against the
|
|
135
|
+
// same "now" and shares one date/time format.
|
|
136
|
+
const timestampFormat = (0, utils_1.resolveTimestampFormat)({
|
|
137
|
+
dateFormat: options.dateFormat,
|
|
138
|
+
timeFormat: options.timeFormat,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const renderContext = Object.assign({ lang, i18n, dateSeps, stats, timestampFormat }, callbacks ? { callbacks } : {});
|
|
123
142
|
|
|
124
143
|
const docTree = (0, jsx_runtime_1.jsxs)("html", { lang, children: [
|
|
125
144
|
(0, jsx_runtime_1.jsxs)("head", { children: [
|
|
@@ -175,10 +194,39 @@ async function render(_a) {
|
|
|
175
194
|
|
|
176
195
|
if (options.hydrate) {
|
|
177
196
|
const markup = await (0, utils_1.streamToString)(prelude);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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 <!DOCTYPE html>… 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
|
+
}
|
|
182
230
|
}
|
|
183
231
|
|
|
184
232
|
if (options.returnType === 'stream' || options.stream) {
|
|
@@ -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/index.js
CHANGED
|
@@ -78,8 +78,9 @@ async function generateFromMessages(messages, channel, options = {}) {
|
|
|
78
78
|
channel,
|
|
79
79
|
saveImages: options.saveImages ?? false,
|
|
80
80
|
callbacks,
|
|
81
|
-
poweredBy: options.poweredBy ??
|
|
81
|
+
poweredBy: options.poweredBy ?? false,
|
|
82
82
|
footerText: options.footerText ?? 'Exported {number} message{s}.',
|
|
83
|
+
statsFooter: options.statsFooter,
|
|
83
84
|
favicon: options.favicon ?? 'guild',
|
|
84
85
|
hydrate: options.hydrate ?? false,
|
|
85
86
|
language: options.language ?? 'en',
|
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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AttachmentBuilder, Message } from 'discord.js';
|
|
2
2
|
import type { RenderMessageContext } from './generator';
|
|
3
|
+
import type { Readable } from 'stream';
|
|
3
4
|
export declare enum AttachmentTypes {
|
|
4
5
|
Audio = 0,
|
|
5
6
|
Video = 1,
|
|
@@ -9,9 +10,10 @@ export declare enum AttachmentTypes {
|
|
|
9
10
|
export declare enum ExportReturnType {
|
|
10
11
|
Buffer = "buffer",
|
|
11
12
|
String = "string",
|
|
12
|
-
Attachment = "attachment"
|
|
13
|
+
Attachment = "attachment",
|
|
14
|
+
Stream = "stream"
|
|
13
15
|
}
|
|
14
|
-
export type ObjectType<T extends ExportReturnType> = T extends ExportReturnType.Buffer ? Buffer : T extends ExportReturnType.String ? string : AttachmentBuilder;
|
|
16
|
+
export type ObjectType<T extends ExportReturnType> = T extends ExportReturnType.Buffer ? Buffer : T extends ExportReturnType.String ? string : T extends ExportReturnType.Stream ? Readable : AttachmentBuilder;
|
|
15
17
|
export type GenerateFromMessagesOptions<T extends ExportReturnType> = Partial<{
|
|
16
18
|
/**
|
|
17
19
|
* The type of object to return
|
|
@@ -33,8 +35,9 @@ export type GenerateFromMessagesOptions<T extends ExportReturnType> = Partial<{
|
|
|
33
35
|
*/
|
|
34
36
|
filename: string;
|
|
35
37
|
/**
|
|
36
|
-
* Whether to include the "Powered by discord-html-transcripts"
|
|
37
|
-
*
|
|
38
|
+
* Whether to include the "Powered by discord-html-transcripts" credit link.
|
|
39
|
+
* Only renders when the stats footer is disabled (`statsFooter: false`).
|
|
40
|
+
* @default false
|
|
38
41
|
*/
|
|
39
42
|
poweredBy: boolean;
|
|
40
43
|
/**
|
|
@@ -58,7 +61,7 @@ export type GenerateFromMessagesOptions<T extends ExportReturnType> = Partial<{
|
|
|
58
61
|
* Stats footer rendered at the bottom (e.g. "12 messages · 3 participants · 2 images · …").
|
|
59
62
|
* - `false` to disable entirely
|
|
60
63
|
* - `{ enabled: false }` to disable
|
|
61
|
-
* - `{ template: '{messages}
|
|
64
|
+
* - `{ template: '{messages} messages from {participants} people · {images} images · {from} → {to}' }`
|
|
62
65
|
* to render with a custom string. Supported placeholders:
|
|
63
66
|
* `{messages}` `{participants}` `{images}` `{from}` `{to}` `{span}`
|
|
64
67
|
*
|
|
@@ -68,6 +71,34 @@ export type GenerateFromMessagesOptions<T extends ExportReturnType> = Partial<{
|
|
|
68
71
|
enabled?: boolean;
|
|
69
72
|
template?: string;
|
|
70
73
|
};
|
|
74
|
+
/**
|
|
75
|
+
* Date order used whenever a message timestamp is older than yesterday
|
|
76
|
+
* (e.g. `11/08/2026 07:16`).
|
|
77
|
+
* @default 'dd/mm/yyyy'
|
|
78
|
+
*/
|
|
79
|
+
dateFormat: 'dd/mm/yyyy' | 'mm/dd/yyyy';
|
|
80
|
+
/**
|
|
81
|
+
* Clock format for message timestamps — `'24h'` renders `07:16`,
|
|
82
|
+
* `'12h'` renders `07:16 AM`.
|
|
83
|
+
* @default '24h'
|
|
84
|
+
*/
|
|
85
|
+
timeFormat: '24h' | '12h';
|
|
86
|
+
/**
|
|
87
|
+
* UI language for the built-in strings (participant labels, filter UI, stats footer, …).
|
|
88
|
+
* @default 'en'
|
|
89
|
+
*/
|
|
90
|
+
language: 'en' | 'de';
|
|
91
|
+
/**
|
|
92
|
+
* Override individual built-in strings per language. Merged over the defaults.
|
|
93
|
+
* Keys are the string ids used internally (e.g. `statsMessages`, `statsParticipants`).
|
|
94
|
+
*/
|
|
95
|
+
i18n: Partial<Record<'en' | 'de', Record<string, string>>>;
|
|
96
|
+
/**
|
|
97
|
+
* Return a Node `Readable` stream of the rendered HTML instead of buffering it.
|
|
98
|
+
* Equivalent to `returnType: ExportReturnType.Stream`. Best for very large exports.
|
|
99
|
+
* @default false
|
|
100
|
+
*/
|
|
101
|
+
stream: boolean;
|
|
71
102
|
}>;
|
|
72
103
|
export type CreateTranscriptOptions<T extends ExportReturnType> = Partial<GenerateFromMessagesOptions<T> & {
|
|
73
104
|
/**
|
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,10 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discord-html-transcripts-fix",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.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",
|
|
7
7
|
"license": "Apache-2.0",
|
|
8
|
+
"author": "Developer-Sven (https://github.com/Developer-Sven)",
|
|
9
|
+
"homepage": "https://github.com/Developer-Sven/discord-html-transcripts-fix#readme",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/Developer-Sven/discord-html-transcripts-fix.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/Developer-Sven/discord-html-transcripts-fix/issues"
|
|
16
|
+
},
|
|
8
17
|
"keywords": [
|
|
9
18
|
"discord",
|
|
10
19
|
"discord.js",
|
|
@@ -24,6 +33,7 @@
|
|
|
24
33
|
"dependencies": {
|
|
25
34
|
"@lit-labs/ssr": "^3.3.1",
|
|
26
35
|
"@skyra/discord-components-core": "^4.0.2",
|
|
36
|
+
"lit": "^3.3.1",
|
|
27
37
|
"debug": "^4.4.3",
|
|
28
38
|
"discord-markdown-parser": "~1.3.0",
|
|
29
39
|
"highlight.js": "^11.11.1",
|
|
@@ -31,10 +41,19 @@
|
|
|
31
41
|
"react-dom": "^19.2.3",
|
|
32
42
|
"simple-markdown": "^0.7.3",
|
|
33
43
|
"twemoji": "^14.0.2",
|
|
34
|
-
"undici": "^7.
|
|
44
|
+
"undici": "^7.29.0"
|
|
35
45
|
},
|
|
36
46
|
"peerDependencies": {
|
|
37
|
-
"discord.js": "^14.0.0 || ^15.0.0"
|
|
47
|
+
"discord.js": "^14.0.0 || ^15.0.0",
|
|
48
|
+
"sharp": ">=0.30.0"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"sharp": {
|
|
52
|
+
"optional": true
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=20"
|
|
38
57
|
},
|
|
39
58
|
"scripts": {
|
|
40
59
|
"prepublishOnly": "node -e \"require('fs').accessSync('dist/index.js'); require('fs').accessSync('dist/index.d.ts');\" || (echo 'dist/index.js or dist/index.d.ts missing — refusing to publish' && exit 1)"
|