discord-html-transcripts-fix 2.1.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 CHANGED
@@ -14,38 +14,20 @@ Forked from [discord-html-transcripts](https://github.com/ItzDerock/discord-html
14
14
  - **discord.js v14 or v15** — required peer dependency.
15
15
  - **[`sharp`](https://sharp.pixelplumbing.com/)** — *optional* peer dependency, only needed if you use `.withCompression()` to compress / convert transcript images to WebP.
16
16
 
17
+ The **generated HTML** additionally reaches out to third-party CDNs when it is *opened*
18
+ — jsDelivr for the `<discord-*>` component runtime and 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, Lit SSR, the markdown parser, etc. are installed automatically. `sharp` is an optional peer (image compression only).
24
-
25
- <details>
26
- <summary>Silencing the <code>node-domexception</code> deprecation warning on install</summary>
27
-
28
- Lit SSR depends on `node-fetch`, which still pulls in the deprecated
29
- `fetch-blob → node-domexception` chain, so `npm install` prints:
28
+ `discord.js` is the only **required** peer dependency — React, the markdown parser, etc. are installed automatically. `sharp` is an optional peer (image compression only).
30
29
 
31
- ```
32
- npm warn deprecated node-domexception@1.0.0: Use your platform's native DOMException instead
33
- ```
34
-
35
- It is cosmetic — install-time only, never at runtime, and `npm audit` reports nothing
36
- for it. If you want it gone, swap `node-fetch` for a dependency-free drop-in in **your
37
- own** `package.json` (npm only honours `overrides` in the root project, so this cannot
38
- be shipped from here):
39
-
40
- ```json
41
- {
42
- "overrides": {
43
- "node-fetch": "npm:node-fetch-native@^1.6.7"
44
- }
45
- }
46
- ```
47
-
48
- </details>
30
+ The install is clean: no deprecation warnings and no `npm audit` findings.
49
31
 
50
32
  ## Quick start
51
33
 
@@ -87,7 +69,9 @@ const stream = await createTranscript(channel, {
87
69
  | `filename` | `string` | `transcript-{channel-id}.html` | Output filename when returning as attachment. |
88
70
  | `saveImages` | `boolean` | `false` | Download images and inline them as base64 data URLs. |
89
71
  | `favicon` | `'guild'` \| `string` | `'guild'` | Page favicon — `'guild'` uses the server icon, or pass a URL. |
90
- | `hydrate` | `boolean` | `false` | Server-side hydrate via `@lit-labs/ssr` (slower; usually leave off). |
72
+ | `hydrate` | `boolean` | `false` | Enables the client-side spoiler-reveal script. |
73
+ | `inlineAssets` | `boolean` | `false` | Embed the component runtime and emoji so the file renders offline. See below. |
74
+ | `inlineAssetsTimeout` | `number` | `30000` | Per-request timeout in ms while downloading those assets. |
91
75
  | `dateFormat` | `'dd/mm/yyyy'` \| `'mm/dd/yyyy'` | `'dd/mm/yyyy'` | Date order for message timestamps older than yesterday. |
92
76
  | `timeFormat` | `'24h'` \| `'12h'` | `'24h'` | Clock format for message timestamps — `07:16` vs `07:16 AM`. |
93
77
  | `language` | `'en'` \| `'de'` | `'en'` | UI language for participant labels, filter strings, etc. |
@@ -97,6 +81,36 @@ const stream = await createTranscript(channel, {
97
81
  | `poweredBy` | `boolean` | `false` | Show the original "Powered by discord-html-transcripts" credit link. Only renders when `statsFooter` is disabled. |
98
82
  | `callbacks` | `{ resolveUser, resolveRole, resolveChannel, resolveImageSrc }` | — | Custom resolvers for mentions / image URLs. |
99
83
 
84
+ ### Self-contained transcripts
85
+
86
+ By default the rendered file is not standalone: opening it fetches the `<discord-*>`
87
+ component runtime 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
+
100
114
  ### Message timestamps
101
115
 
102
116
  Timestamps are worded the way Discord words them, relative to when the transcript was
@@ -253,7 +267,8 @@ In addition to plain text, replies, embeds, and attachments, the viewer supports
253
267
  - **`sharp` declared as an optional peer dependency** — needed only for `.withCompression()`, no longer a hidden requirement
254
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
255
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 `&lt;!DOCTYPE html&gt;…` source text instead of a transcript
256
- - **`lit` added as an explicit dependency** — it was previously only reachable as a transitive dependency of `@lit-labs/ssr`, which does not resolve under pnpm's strict layout
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
257
272
  - **TypeScript declarations match runtime** — `ExportReturnType.Stream`, `language`, `i18n`, `stream`, and `withConcurrency()` are now exposed in the types
258
273
  - `discord.js` remains the only **required** peer dependency
259
274
 
@@ -20,25 +20,15 @@ const static_1 = require("react-dom/static");
20
20
  const buildProfiles_1 = require("../utils/buildProfiles");
21
21
  const client_1 = require("../static/client");
22
22
  const fs_1 = require("fs");
23
+ const stream_1 = require("stream");
23
24
  const path_1 = __importDefault(require("path"));
25
+ const selfContained_1 = require("../utils/selfContained");
24
26
  const transcript_1 = __importDefault(require("./transcript"));
25
27
  const utils_1 = require("../utils/utils");
26
28
  const styles_1 = require("./renderers/components/styles");
27
29
  const DiscordImage_1 = require("./renderers/components/DiscordImage");
28
30
  const DiscordHighlightedCode_1 = require("./renderers/components/DiscordHighlightedCode");
29
31
 
30
- // Warn at most once per process when hydration is requested without @lit-labs/ssr.
31
- let warnedMissingLitSsr = false;
32
-
33
- // Moves <!DOCTYPE html> back to the very front of the document. Lit SSR emits
34
- // <!--lit-part--> markers around its result, and a comment before the doctype
35
- // makes browsers fall back to quirks mode.
36
- function hoistDoctype(markup) {
37
- const match = markup.match(/<!DOCTYPE\s+html[^>]*>/i);
38
- if (!match || markup.startsWith(match[0])) return markup;
39
- return match[0] + markup.slice(0, match.index) + markup.slice(match.index + match[0].length);
40
- }
41
-
42
32
  // Lock to an exact resolved version — semver ranges in src= aren't cacheable by CDNs.
43
33
  let discordComponentsVersion = '4.0.2';
44
34
  try {
@@ -192,44 +182,31 @@ async function render(_a) {
192
182
 
193
183
  const { prelude } = await (0, static_1.prerenderToNodeStream)(docTree);
194
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
+
195
205
  if (options.hydrate) {
196
- const markup = await (0, utils_1.streamToString)(prelude);
197
- // @lit-labs/ssr and lit are regular dependencies, so this normally always
198
- // resolves. The guard only covers a broken or partial install: instead of
199
- // failing the whole export we fall back to the non-SSR'd markup, which is
200
- // still a fully working transcript because the <discord-*> definitions are
201
- // loaded from the CDN module tag above regardless of this branch.
202
- try {
203
- const { render: renderLit } = await import('@lit-labs/ssr');
204
- const { html: litHtml } = await import('lit');
205
- const { unsafeHTML } = await import('lit/directives/unsafe-html.js');
206
- const { collectResult } = await import('@lit-labs/ssr/lib/render-result.js');
207
- // `markup` must go through unsafeHTML: handing lit a bare string makes
208
- // it a text value, which lit HTML-escapes — that turned the whole
209
- // transcript into visible &lt;!DOCTYPE html&gt;… source text.
210
- const result = renderLit(litHtml`${unsafeHTML(markup)}`);
211
- const rendered = await collectResult(result);
212
- // lit wraps its output in <!--lit-part--> markers. Any comment ahead of
213
- // the doctype pushes browsers into quirks mode and wrecks the layout,
214
- // so the doctype has to lead the document again.
215
- return hoistDoctype(rendered);
216
- }
217
- catch (err) {
218
- // Only a missing module triggers the fallback — a genuine render
219
- // failure must still surface instead of being silently swallowed.
220
- const code = err && err.code;
221
- if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') throw err;
222
- if (!warnedMissingLitSsr) {
223
- warnedMissingLitSsr = true;
224
- console.warn('[discord-html-transcripts-fix] `hydrate: true` could not load @lit-labs/ssr / lit, ' +
225
- 'although both ship as dependencies of this package. Returning non-hydrated markup — the ' +
226
- 'transcript still renders correctly. Reinstalling your dependencies should fix this.');
227
- }
228
- return markup;
229
- }
206
+ return await (0, utils_1.streamToString)(prelude);
230
207
  }
231
208
 
232
- if (options.returnType === 'stream' || options.stream) {
209
+ if (wantsStream) {
233
210
  // Return the underlying Node stream — caller is responsible for piping.
234
211
  return prelude;
235
212
  }
package/dist/types.d.ts CHANGED
@@ -71,6 +71,29 @@ 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;
74
97
  /**
75
98
  * Date order used whenever a message timestamp is older than yesterday
76
99
  * (e.g. `11/08/2026 07:16`).
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discord-html-transcripts-fix",
3
- "version": "2.1.0",
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,9 +31,7 @@
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
- "lit": "^3.3.1",
37
35
  "debug": "^4.4.3",
38
36
  "discord-markdown-parser": "~1.3.0",
39
37
  "highlight.js": "^11.11.1",