discord-html-transcripts-fix 1.0.0 → 1.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
@@ -1,92 +1,113 @@
1
- # `discord-html-transcripts` (Bugfix Fork)
1
+ # `discord-html-transcripts-fix`
2
2
 
3
- This is a fork of [discord-html-transcripts](https://github.com/ItzDerock/discord-html-transcripts) with bug fixes for compatibility with the latest discord.js version and Components v2.
3
+ A nicely formatted HTML transcript generator for discord.js with full **Components V2** support, an **interactive viewer** (search, lightbox, mention popups, i18n) and **hardened security**.
4
+
5
+ Forked from [discord-html-transcripts](https://github.com/ItzDerock/discord-html-transcripts).
4
6
 
5
7
  ## Changes compared to the original
6
8
 
7
- - Fixed compatibility with the latest discord.js version
8
- - Added support for Discord Components v2
9
+ ### Security
10
+ - **Critical fix**: `</script>` breakout via inlined JSON is prevented (`<`, `>`, `&`, U+2028/2029 are escaped)
11
+ - **Critical fix**: markdown links with `javascript:`, `data:`, `vbscript:` and similar dangerous URI schemes are rewritten to `#`
12
+ - **Hardened**: inline `style="color:…"` sinks in the mention popup are hex-validated to block CSS injection
13
+ - **Hardened**: `data:` URI MIME types from the image downloader are restricted to image types only (no `text/html` smuggling)
14
+ - **Fixed**: `process.exit(1)` on discord.js version mismatch removed — library no longer kills the host bot
15
+
16
+ ### Robustness
17
+ - **Fix**: invalid Discord timestamp markers (`<t:abc:F>`, oversized values) no longer abort the transcript with `RangeError`
18
+ - **Fix**: per-AST-node error boundary in `MessageSingleASTNode` and per-message error boundary in `DiscordMessage` — one broken message can never kill the whole render any more
19
+ - **Fix**: `parseDiscordEmoji` no longer throws on deleted reactions with `emoji.name === null`
20
+ - **Fix**: `formatBytes(null/undefined/NaN)` no longer returns `NaN undefined`
21
+ - **Fix**: `createTranscript` slice uses the resolved limit instead of the raw `limit`
22
+ - **Fix**: embed fields are rendered through a proper async component (was an inline `async` arrow inside `.map()` — undefined React behaviour)
23
+ - **Fix**: `JoinMessage` text is now deterministic per message ID — re-rendering the same channel always yields the same join line
24
+ - **Fix**: random `console.log` calls in production paths replaced by the `debug` namespace
25
+
26
+ ### New: interactive viewer
27
+ - **Clickable mentions** — clicking a user / role / channel mention opens a Discord-style popup with avatar, display name, username, all roles (colored pills), server-since, account-since, color, member count, channel topic, etc.
28
+ - **Search bar** — Ctrl+F is intercepted, live highlighting with "x of n matches", prev / next, Enter / Shift+Enter to navigate, Escape to close
29
+ - **Image lightbox** — click any image to open fullscreen, arrow keys to nav, Escape to close
30
+ - **Participant sidebar (TOC)** — toggle button (top-left), sorted list of every participant, click to jump to their first message
31
+ - **Back-to-top** floating button fades in after scrolling
32
+ - **Date separators** between messages — `— Tuesday, May 13 2026 —` style, automatically inserted on day boundaries
33
+ - **Stats footer** — `324 messages · 12 participants · 5 images` (auto-computed)
34
+ - **Language switcher** (i18n) — built-in English and German dictionaries, switch live in the viewer. Pass your own via `options.i18n: { de: { … } }` to extend
35
+
36
+ ### New: more Discord content rendered
37
+ - **Stickers** — image, name, alt
38
+ - **Polls** — question, answer bars with vote counts and percentages, expiry
39
+ - **Forwarded messages (`messageSnapshots`)** — quoted-block style with original author
40
+ - **Voice messages** — `🎤` indicator, inline SVG waveform from `attachment.waveform`, duration
41
+ - **Edit timestamp tooltip** on `(edited)` marker
42
+ - **Optional edit history** — pass `message.editHistory = [{content, editedAt}, …]` and the viewer shows a collapsible `<details>` block
43
+ - **Pinned indicator** (`📌` badge on pinned messages)
44
+ - **App badge** for application/bot messages with `applicationId`
45
+ - **Suppressed embeds flag** is honored — when set, embeds aren't rendered (and a small `(embeds hidden)` note is shown instead)
46
+ - **Cross-guild replies** are no longer silently dropped — they show a "Message from another server" pill
47
+ - **Burst / super-reactions** are flagged
48
+ - **Thread state badges** — `Archived`, `Locked`
49
+ - **GIFV / animated GIFs** are rendered with `<video autoplay loop muted>` like in Discord
50
+ - **Attachment description (alt text)** is used as `alt`/`title`
51
+ - **`<id:guide>`, `<id:browse>`, `<id:customize>`** etc. pseudo-channels show as styled pills with proper labels
52
+ - **`</cmd:id>` slash command mentions** render as blue pills
53
+ - **Embed video** link and **embed provider** ("YouTube" etc.) are shown
54
+ - **`RoleSubscriptionPurchase`** system message renders properly
55
+ - **`AutoModerationAction`** shows the matched rule name and blocked content excerpt
56
+ - **Container component** with `accent_color` (left border) and `spoiler` support
57
+ - **Media gallery** per-item spoiler support
58
+ - Many additional system message types (`ChannelNameChange`, `ChannelIconChange`, `ThreadCreated`, `ChatInputCommand`, `ContextMenuCommand`, `Call`, `ChannelFollowAdd`, `RecipientRemove`, guild incident reports, poll result)
59
+
60
+ ### Performance
61
+ - **Single-pass profile collector** — `buildAllContext` builds profiles + extended dicts in one walk over the message list
62
+ - **Image downloader is concurrent** — bounded pool (default 6, configurable via `withConcurrency`) instead of sequential
63
+ - **`@skyra/discord-components-core` version is pinned** to an exact resolved version → CDN cacheable
64
+ - **Inline JSON** is shipped via `<script type="application/json">` so it doesn't block HTML parse
65
+ - **Emoji URL resolution** is memoized
66
+ - **`returnType: 'stream'`** option streams the rendered HTML out instead of buffering — usable for 5,000+ message tickets
67
+
68
+ ### DX
69
+ - **`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)
70
+ - TypeScript build target unchanged; only `discord.js` remains a peer dependency
9
71
 
10
72
  ## Requirements
11
73
 
12
- This package requires `react` and `react-dom` as peer dependencies. Install them before using this package:
74
+ Only `discord.js` as a peer dependency. Everything else is auto-installed:
13
75
 
14
76
  ```bash
15
- npm install react@^19.2.3 react-dom@^19.2.3
77
+ npm install discord-html-transcripts-fix
16
78
  ```
17
79
 
18
80
  ## Usage
19
81
 
20
- ### Example usage using the built in message fetcher.
21
-
22
82
  ```js
23
- const discordTranscripts = require('discord-html-transcripts');
24
- // or (if using typescript) import * as discordTranscripts from 'discord-html-transcripts';
25
-
26
- const channel = message.channel; // or however you get your TextChannel
27
-
28
- // Must be awaited
29
- const attachment = await discordTranscripts.createTranscript(channel);
30
-
31
- channel.send({
32
- files: [attachment],
83
+ const { createTranscript } = require('discord-html-transcripts-fix');
84
+
85
+ const attachment = await createTranscript(channel, {
86
+ // existing options
87
+ limit: -1,
88
+ saveImages: false,
89
+ poweredBy: true,
90
+ // new options
91
+ language: 'de', // 'en' (default) or 'de' — also exposed live in the viewer
92
+ i18n: { de: { joined: 'kam an' } }, // optional overrides per language
93
+ returnType: 'stream', // 'buffer' | 'string' | 'attachment' (default) | 'stream'
33
94
  });
34
- ```
35
-
36
- ### Or if you prefer, you can pass in your own messages.
37
-
38
- ```js
39
- const discordTranscripts = require('discord-html-transcripts');
40
- // or (if using typescript) import * as discordTranscripts from 'discord-html-transcripts';
41
95
 
42
- const messages = someWayToGetMessages(); // Must be Collection<string, Message> or Message[]
43
- const channel = someWayToGetChannel(); // Used for ticket name, guild icon, and guild name
44
-
45
- // Must be awaited
46
- const attachment = await discordTranscripts.generateFromMessages(messages, channel);
47
-
48
- channel.send({
49
- files: [attachment],
50
- });
96
+ channel.send({ files: [attachment] });
51
97
  ```
52
98
 
53
- ## Configuration
99
+ ### Hooking edit history (optional)
54
100
 
55
- Both methods of generating a transcript allow for an option object as the last parameter.
56
- **All configuration options are optional!**
57
-
58
- ### Built in Message Fetcher
101
+ If your bot tracks message edits, attach the history to the message before passing it to the renderer:
59
102
 
60
103
  ```js
61
- const attachment = await discordTranscripts.createTranscript(channel, {
62
- limit: -1, // Max amount of messages to fetch. `-1` recursively fetches.
63
- returnType: 'attachment', // Valid options: 'buffer' | 'string' | 'attachment' Default: 'attachment' OR use the enum ExportReturnType
64
- filename: 'transcript.html', // Only valid with returnType is 'attachment'. Name of attachment.
65
- saveImages: false, // Download all images and include the image data in the HTML (allows viewing the image even after it has been deleted) (! WILL INCREASE FILE SIZE !)
66
- footerText: "Exported {number} message{s}", // Change text at footer, don't forget to put {number} to show how much messages got exported, and {s} for plural
67
- callbacks: {
68
- // register custom callbacks for the following:
69
- resolveChannel: (channelId: string) => Awaitable<Channel | null>,
70
- resolveUser: (userId: string) => Awaitable<User | null>,
71
- resolveRole: (roleId: string) => Awaitable<Role | null>,
72
- resolveImageSrc: (
73
- attachment: APIAttachment,
74
- message: APIMessage
75
- ) => Awaitable<string | null | undefined>
76
- },
77
- poweredBy: true, // Whether to include the "Powered by discord-html-transcripts" footer
78
- hydrate: true, // Whether to hydrate the html server-side
79
- filter: (message) => true // Filter messages, e.g. (message) => !message.author.bot
80
- });
104
+ message.editHistory = [
105
+ { content: 'first version', editedAt: new Date('2025-05-13T11:02Z') },
106
+ { content: 'corrected version', editedAt: new Date('2025-05-13T11:05Z') },
107
+ ];
81
108
  ```
82
109
 
83
- ### Providing your own messages
84
-
85
- ```js
86
- const attachment = await discordTranscripts.generateFromMessages(messages, channel, {
87
- // Same as createTranscript, except no limit or filter
88
- });
89
- ```
110
+ The viewer will then render a collapsible `<details>` block next to the `(edited)` marker.
90
111
 
91
112
  ## Credits
92
113
 
@@ -1,36 +1,7 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
2
  var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
3
+ var ownKeys = function(o){ownKeys = Object.getOwnPropertyNames || function (o){var ar=[];for (var k in o) if (Object.prototype.hasOwnProperty.call(o,k)) ar[ar.length]=k;return ar;};return ownKeys(o);};
4
+ return function (mod){if (mod && mod.__esModule) return mod;var result={};if (mod != null) for (var k=ownKeys(mod), i=0; i<k.length; i++) if (k[i] !== "default") Object.defineProperty(result,k[i],{get:function(){return mod[k[i]];}});Object.defineProperty(result,"default",{value:mod,enumerable:true});return result;};
34
5
  })();
35
6
  var __importDefault = (this && this.__importDefault) || function (mod) {
36
7
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -39,76 +10,80 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
10
  exports.TranscriptImageDownloader = void 0;
40
11
  const undici_1 = require("undici");
41
12
  const debug_1 = __importDefault(require("debug"));
42
- /**
43
- * Builder to build a image saving callback.
44
- */
13
+ const utils_1 = require("../utils/utils");
14
+
15
+ // Small inline concurrency limiter — no extra dep
16
+ function createLimiter(max) {
17
+ let active = 0;
18
+ const queue = [];
19
+ const next = () => {
20
+ if (active >= max || queue.length === 0) return;
21
+ active++;
22
+ const { fn, resolve, reject } = queue.shift();
23
+ fn().then((v) => { active--; resolve(v); next(); }, (e) => { active--; reject(e); next(); });
24
+ };
25
+ return (fn) => new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); next(); });
26
+ }
27
+
45
28
  class TranscriptImageDownloader {
46
29
  constructor() {
47
30
  this.log = TranscriptImageDownloader.log;
31
+ this.maxConcurrent = 6;
32
+ this._sharpModule = null;
33
+ this._limit = null;
48
34
  }
49
- /**
50
- * Sets the maximum file size for *each* individual image.
51
- * @param size The maximum file size in kilobytes
52
- */
53
35
  withMaxSize(size) {
54
36
  this.maxFileSize = size;
55
37
  return this;
56
38
  }
57
- /**
58
- * Sets the compression quality for each image. This requires `sharp` to be installed.
59
- * Optionally, images can be converted to WebP format which is smaller in size.
60
- * @param quality The quality of the image (1 lowest - 100 highest). Lower quality means smaller file size.
61
- * @param convertToWebP Whether to convert the image to WebP format
62
- */
39
+ withConcurrency(n) {
40
+ this.maxConcurrent = Math.max(1, Number(n) || 1);
41
+ return this;
42
+ }
63
43
  withCompression(quality = 80, convertToWebP = false, options = {}) {
64
- if (quality < 1 || quality > 100)
65
- throw new Error('Quality must be between 1 and 100');
66
- // try and import sharp
67
- Promise.resolve().then(() => __importStar(require('sharp'))).catch((err) => {
68
- console.error(err);
69
- console.error(`[discord-html-transcripts] Failed to import 'sharp'. Image compression requires the 'sharp' package to be installed. Either install sharp or remove the compression options.`);
70
- });
44
+ if (quality < 1 || quality > 100) throw new Error('Quality must be between 1 and 100');
45
+ // Hoist the sharp import so we don't pay it per image
46
+ Promise.resolve()
47
+ .then(() => __importStar(require('sharp')))
48
+ .then((s) => { this._sharpModule = s; })
49
+ .catch((err) => {
50
+ this.log('sharp import failed: %s', err && err.message ? err.message : err);
51
+ });
71
52
  this.compression = { quality, convertToWebP, options };
72
53
  return this;
73
54
  }
74
- /**
75
- * Builds the image saving callback.
76
- */
77
55
  build() {
56
+ if (!this._limit) this._limit = createLimiter(this.maxConcurrent);
78
57
  return async (attachment) => {
79
- // if the attachment is not an image, return null
80
- if (!attachment.width || !attachment.height)
81
- return undefined;
82
- // if the max file size is set, check if the file size is within the limit
83
- if (this.maxFileSize && attachment.size > this.maxFileSize * 1024)
84
- return undefined;
85
- // fetch the image
86
- this.log(`Fetching attachment ${attachment.id}: ${attachment.url}`);
87
- const response = await (0, undici_1.request)(attachment.url).catch((err) => {
88
- console.error(`[discord-html-transcripts] Failed to download image for transcript: `, err);
89
- return null;
90
- });
91
- if (!response)
92
- return undefined;
93
- const mimetype = response.headers['content-type'];
94
- const buffer = await response.body.arrayBuffer().then((res) => Buffer.from(res));
95
- this.log(`Finished fetching ${attachment.id} (${buffer.length} bytes)`);
96
- // if the compression options are set, compress the image
97
- if (this.compression) {
98
- const sharp = await Promise.resolve().then(() => __importStar(require('sharp')));
99
- this.log(`Compressing ${attachment.id} with 'sharp'`);
100
- const sharpbuf = await sharp
101
- .default(buffer)
102
- .webp(Object.assign({ quality: this.compression.quality, force: this.compression.convertToWebP, effort: 2 }, this.compression.options))
103
- .toBuffer({ resolveWithObject: true });
104
- this.log(`Finished compressing ${attachment.id} (${sharpbuf.info.size} bytes)`);
105
- return `data:image/${sharpbuf.info.format};base64,${sharpbuf.data.toString('base64')}`;
106
- }
107
- // return the base64 string
108
- return `data:${mimetype};base64,${buffer.toString('base64')}`;
58
+ if (!attachment.width || !attachment.height) return undefined;
59
+ if (this.maxFileSize && attachment.size > this.maxFileSize * 1024) return undefined;
60
+ return this._limit(() => this._fetchOne(attachment));
109
61
  };
110
62
  }
63
+ async _fetchOne(attachment) {
64
+ this.log('fetch %s %s', attachment.id, attachment.url);
65
+ const response = await (0, undici_1.request)(attachment.url).catch((err) => {
66
+ this.log('download failed for %s: %s', attachment.id, err && err.message ? err.message : err);
67
+ return null;
68
+ });
69
+ if (!response) return undefined;
70
+ const rawMime = response.headers['content-type'];
71
+ const mime = (0, utils_1.safeImageMime)(typeof rawMime === 'string' ? rawMime : '', 'image/png');
72
+ const arr = await response.body.arrayBuffer();
73
+ const buffer = Buffer.from(arr);
74
+ this.log('fetched %s (%d bytes, mime=%s)', attachment.id, buffer.length, mime);
75
+ if (this.compression) {
76
+ const sharp = this._sharpModule || (this._sharpModule = await __importStar(require('sharp')));
77
+ this.log('compressing %s with sharp', attachment.id);
78
+ const sharpbuf = await sharp
79
+ .default(buffer)
80
+ .webp(Object.assign({ quality: this.compression.quality, force: this.compression.convertToWebP, effort: 2 }, this.compression.options))
81
+ .toBuffer({ resolveWithObject: true });
82
+ return `data:image/${sharpbuf.info.format};base64,${sharpbuf.data.toString('base64')}`;
83
+ }
84
+ return `data:${mime};base64,${buffer.toString('base64')}`;
85
+ }
111
86
  }
112
87
  exports.TranscriptImageDownloader = TranscriptImageDownloader;
113
88
  TranscriptImageDownloader.log = (0, debug_1.default)('discord-html-transcripts:TranscriptImageDownloader');
114
- //# sourceMappingURL=images.js.map
89
+ //# sourceMappingURL=images.js.map
@@ -1,66 +1,227 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  var __rest = (this && this.__rest) || function (s, e) {
3
6
  var t = {};
4
- for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
5
- t[p] = s[p];
7
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p];
6
8
  if (s != null && typeof Object.getOwnPropertySymbols === "function")
7
9
  for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
8
- if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
9
- t[p[i]] = s[p[i]];
10
+ if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]];
10
11
  }
11
12
  return t;
12
13
  };
13
- var __importDefault = (this && this.__importDefault) || function (mod) {
14
- return (mod && mod.__esModule) ? mod : { "default": mod };
15
- };
16
14
  var _a;
17
15
  Object.defineProperty(exports, "__esModule", { value: true });
18
16
  exports.default = render;
17
+ exports.renderToStream = renderToStream;
19
18
  const jsx_runtime_1 = require("react/jsx-runtime");
20
19
  const static_1 = require("react-dom/static");
21
20
  const buildProfiles_1 = require("../utils/buildProfiles");
22
21
  const client_1 = require("../static/client");
23
22
  const fs_1 = require("fs");
24
23
  const path_1 = __importDefault(require("path"));
25
- //const ssr_1 = require("@lit-labs/ssr");
26
24
  const transcript_1 = __importDefault(require("./transcript"));
27
25
  const utils_1 = require("../utils/utils");
28
- //const render_result_1 = require("@lit-labs/ssr/lib/render-result");
29
26
  const styles_1 = require("./renderers/components/styles");
30
27
  const DiscordImage_1 = require("./renderers/components/DiscordImage");
31
28
  const DiscordHighlightedCode_1 = require("./renderers/components/DiscordHighlightedCode");
32
- // read the package.json file and get the @derockdev/discord-components-core version
33
- let discordComponentsVersion = '^4.0.2';
29
+
30
+ // Lock to an exact resolved version — semver ranges in src= aren't cacheable by CDNs.
31
+ let discordComponentsVersion = '4.0.2';
34
32
  try {
35
33
  const packagePath = path_1.default.join(__dirname, '..', '..', 'package.json');
36
34
  const packageJSON = JSON.parse((0, fs_1.readFileSync)(packagePath, 'utf8'));
37
- discordComponentsVersion = (_a = packageJSON.dependencies['@skyra/discord-components-core']) !== null && _a !== void 0 ? _a : discordComponentsVersion;
38
- // eslint-disable-next-line no-empty
35
+ const v = (_a = packageJSON.dependencies['@skyra/discord-components-core']) !== null && _a !== void 0 ? _a : discordComponentsVersion;
36
+ const cleaned = String(v).replace(/^[\^~]/, '');
37
+ if (/^\d+\.\d+\.\d+/.test(cleaned)) discordComponentsVersion = cleaned;
38
+ } catch (_b) { /* ignore */ }
39
+
40
+ function computeStats(messages) {
41
+ let images = 0;
42
+ let humans = new Set();
43
+ for (const m of messages) {
44
+ if (m.attachments && m.attachments.forEach) {
45
+ m.attachments.forEach((a) => {
46
+ if (typeof a.contentType === 'string' && a.contentType.startsWith('image/')) images++;
47
+ });
48
+ }
49
+ if (m.author && !m.author.bot) humans.add(m.author.id);
50
+ }
51
+ return { messageCount: messages.length, participantCount: humans.size, imageCount: images };
52
+ }
53
+
54
+ function snowflakeToDate(id) {
55
+ try {
56
+ const DISCORD_EPOCH = 1420070400000n;
57
+ const ms = (BigInt(id) >> 22n) + DISCORD_EPOCH;
58
+ return new Date(Number(ms));
59
+ } catch (_e) { return null; }
60
+ }
61
+
62
+ function buildDateSeparators(messages, lang) {
63
+ // Returns an array same length as messages, each entry: separator string or null
64
+ const seps = new Array(messages.length).fill(null);
65
+ let lastYmd = null;
66
+ const fmt = new Intl.DateTimeFormat(lang === 'de' ? 'de-DE' : 'en-US', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' });
67
+ for (let i = 0; i < messages.length; i++) {
68
+ const m = messages[i];
69
+ const d = m.createdAt instanceof Date ? m.createdAt : snowflakeToDate(m.id);
70
+ if (!d) continue;
71
+ const ymd = `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`;
72
+ if (ymd !== lastYmd) {
73
+ seps[i] = fmt.format(d);
74
+ lastYmd = ymd;
75
+ }
76
+ }
77
+ return seps;
39
78
  }
40
- catch (_b) { } // ignore errors
79
+
41
80
  async function render(_a) {
42
81
  var _b;
43
82
  var { messages, channel, callbacks } = _a, options = __rest(_a, ["messages", "channel", "callbacks"]);
44
- const profiles = (0, buildProfiles_1.buildProfiles)(messages);
45
- const { prelude } = await (0, static_1.prerenderToNodeStream)((0, jsx_runtime_1.jsxs)("html", { children: [(0, jsx_runtime_1.jsxs)("head", { children: [(0, jsx_runtime_1.jsx)("meta", { charSet: "utf-8" }), (0, jsx_runtime_1.jsx)("meta", { name: "viewport", content: "width=device-width, initial-scale=1" }), (0, jsx_runtime_1.jsx)("link", { rel: "preconnect", href: "https://cdn.jsdelivr.net/" }), (0, jsx_runtime_1.jsx)("link", { rel: "preconnect", href: "https://cdn.discordapp.com" }), (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.ggSansFont } }), (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: styles_1.globalStyles } }), (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: DiscordImage_1.DiscordAttachmentStyles } }), (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: DiscordHighlightedCode_1.DiscordHighlightStyles } }), (0, jsx_runtime_1.jsx)("link", { rel: "icon", type: "image/png", href: options.favicon === 'guild'
46
- ? channel.isDMBased()
47
- ? undefined
48
- : ((_b = channel.guild.iconURL({ size: 16, extension: 'png' })) !== null && _b !== void 0 ? _b : undefined)
49
- : options.favicon }), (0, jsx_runtime_1.jsx)("title", { children: channel.isDMBased() ? 'Direct Messages' : channel.name }), (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: {
50
- __html: client_1.scrollToMessage,
51
- } }), !options.hydrate && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: {
52
- __html: `globalThis.$discordMessage={profiles:${JSON.stringify(await profiles)}}`,
53
- } }), (0, jsx_runtime_1.jsx)("script", { type: "module", src: `https://cdn.jsdelivr.net/npm/@skyra/discord-components-core@${discordComponentsVersion}/+esm` })] }))] }), (0, jsx_runtime_1.jsx)("body", { style: {
54
- margin: 0,
55
- minHeight: '100vh',
56
- }, children: (0, jsx_runtime_1.jsx)(transcript_1.default, Object.assign({ messages: messages, channel: channel, callbacks: callbacks }, options)) }), options.hydrate && (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.revealSpoiler } })] }));
57
- const markup = await (0, utils_1.streamToString)(prelude);
58
- if (options.hydrate) {
59
- const { render: renderLit } = await import('@lit-labs/ssr');
60
- const result = renderLit(markup);
61
- const { collectResult } = await import('@lit-labs/ssr/lib/render-result.js');
62
- return await collectResult(result);
63
- }
64
- return markup;
83
+
84
+ const lang = options.language && client_1.defaultDicts[options.language] ? options.language : 'en';
85
+ const i18n = options.i18n
86
+ ? Object.fromEntries(Object.keys(client_1.defaultDicts).map((k) => [k, Object.assign({}, client_1.defaultDicts[k], options.i18n[k] || {})]))
87
+ : client_1.defaultDicts;
88
+
89
+ // Single-pass collector (profiles + extended in one walk)
90
+ const allCtx = await (0, buildProfiles_1.buildAllContext)(messages, channel).catch((e) => {
91
+ console.warn('[discord-html-transcripts-fix] buildAllContext failed:', e?.message || e);
92
+ return { profiles: {}, users: {}, roles: {}, channels: {} };
93
+ });
94
+
95
+ const stats = computeStats(messages);
96
+ const dateSeps = buildDateSeparators(messages, lang);
97
+
98
+ const globalData = {
99
+ profiles: allCtx.profiles,
100
+ users: allCtx.users,
101
+ roles: allCtx.roles,
102
+ channels: allCtx.channels,
103
+ lang,
104
+ i18n,
105
+ stats,
106
+ };
107
+
108
+ const renderContext = Object.assign({ lang, i18n, dateSeps, stats }, callbacks ? { callbacks } : {});
109
+
110
+ const docTree = (0, jsx_runtime_1.jsxs)("html", { lang, children: [
111
+ (0, jsx_runtime_1.jsxs)("head", { children: [
112
+ (0, jsx_runtime_1.jsx)("meta", { charSet: "utf-8" }),
113
+ (0, jsx_runtime_1.jsx)("meta", { name: "viewport", content: "width=device-width, initial-scale=1" }),
114
+ (0, jsx_runtime_1.jsx)("link", { rel: "preconnect", href: "https://cdn.jsdelivr.net/" }),
115
+ (0, jsx_runtime_1.jsx)("link", { rel: "preconnect", href: "https://cdn.discordapp.com" }),
116
+ (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.ggSansFont } }),
117
+ (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: styles_1.globalStyles } }),
118
+ (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.mentionPopupStyles } }),
119
+ (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.transcriptUtils } }),
120
+ (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.searchBarStyles } }),
121
+ (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.lightboxStyles } }),
122
+ (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.tocStyles } }),
123
+ (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.i18nStyles } }),
124
+ (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.dateSeparatorStyles } }),
125
+ (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: DiscordImage_1.DiscordAttachmentStyles } }),
126
+ (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: DiscordHighlightedCode_1.DiscordHighlightStyles } }),
127
+ (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: extraStyles } }),
128
+ (0, jsx_runtime_1.jsx)("link", { rel: "icon", type: "image/png", href: options.favicon === 'guild'
129
+ ? channel.isDMBased()
130
+ ? undefined
131
+ : ((_b = channel.guild.iconURL({ size: 16, extension: 'png' })) !== null && _b !== void 0 ? _b : undefined)
132
+ : options.favicon }),
133
+ (0, jsx_runtime_1.jsx)("title", { children: channel.isDMBased() ? 'Direct Messages' : channel.name }),
134
+ (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.scrollToMessage } }),
135
+ // Non-blocking inline JSON: type="application/json" so the parser doesn't pause
136
+ (0, jsx_runtime_1.jsx)("script", { id: "dht-data", type: "application/json", dangerouslySetInnerHTML: { __html: (0, utils_1.safeJsonForScript)(globalData) } }),
137
+ (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: 'try{globalThis.$discordMessage=JSON.parse(document.getElementById("dht-data").textContent);}catch(e){globalThis.$discordMessage={};}' } }),
138
+ !options.hydrate && (0, jsx_runtime_1.jsx)("script", { type: "module", src: `https://cdn.jsdelivr.net/npm/@skyra/discord-components-core@${discordComponentsVersion}/+esm`, crossOrigin: "anonymous" }),
139
+ ] }),
140
+ (0, jsx_runtime_1.jsxs)("body", { style: { margin: 0, minHeight: '100vh' }, children: [
141
+ (0, jsx_runtime_1.jsx)(transcript_1.default, Object.assign({ messages, channel, callbacks }, options, renderContext)),
142
+ (0, jsx_runtime_1.jsx)(StatsFooter, { stats, lang, i18n }),
143
+ (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.mentionPopup } }),
144
+ (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.backToTop } }),
145
+ (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.searchBar } }),
146
+ (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.lightbox } }),
147
+ (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.toc } }),
148
+ (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.i18nSwitcher } }),
149
+ options.hydrate && (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.revealSpoiler } }),
150
+ ] })
151
+ ] });
152
+
153
+ const { prelude } = await (0, static_1.prerenderToNodeStream)(docTree);
154
+
155
+ if (options.hydrate) {
156
+ const markup = await (0, utils_1.streamToString)(prelude);
157
+ const { render: renderLit } = await import('@lit-labs/ssr');
158
+ const result = renderLit(markup);
159
+ const { collectResult } = await import('@lit-labs/ssr/lib/render-result.js');
160
+ return await collectResult(result);
161
+ }
162
+
163
+ if (options.returnType === 'stream' || options.stream) {
164
+ // Return the underlying Node stream — caller is responsible for piping.
165
+ return prelude;
166
+ }
167
+ return await (0, utils_1.streamToString)(prelude);
168
+ }
169
+
170
+ async function renderToStream(args) {
171
+ return render(Object.assign({}, args, { returnType: 'stream' }));
172
+ }
173
+
174
+ function StatsFooter({ stats, lang, i18n }) {
175
+ const dict = (i18n && i18n[lang]) || (i18n && i18n.en) || {};
176
+ return (0, jsx_runtime_1.jsxs)("footer", { className: "dht-stats", children: [
177
+ (0, jsx_runtime_1.jsx)("span", { "data-i18n": "statsMessages", "data-i18n-params": JSON.stringify({ n: stats.messageCount }), children: (dict.statsMessages || '{n} messages').replace('{n}', stats.messageCount) }),
178
+ ' · ',
179
+ (0, jsx_runtime_1.jsx)("span", { "data-i18n": "statsParticipants", "data-i18n-params": JSON.stringify({ n: stats.participantCount }), children: (dict.statsParticipants || '{n} participants').replace('{n}', stats.participantCount) }),
180
+ ' · ',
181
+ (0, jsx_runtime_1.jsx)("span", { "data-i18n": "statsImages", "data-i18n-params": JSON.stringify({ n: stats.imageCount }), children: (dict.statsImages || '{n} images').replace('{n}', stats.imageCount) }),
182
+ ] });
65
183
  }
66
- //# sourceMappingURL=index.js.map
184
+
185
+ const extraStyles = `
186
+ .dht-pinned-indicator{position:absolute;top:6px;right:8px;font-size:12px;opacity:.6}
187
+ .dht-app-badge,.dht-popup-badge{background:#5865F2;color:#fff;font-size:10px;padding:1px 6px;border-radius:4px;margin-left:6px;vertical-align:middle;font-weight:600}
188
+ .dht-edit-marker{color:#949ba4;font-size:11px;margin-left:4px;cursor:help}
189
+ .dht-suppressed{color:#6e727a;font-size:11px;font-style:italic}
190
+ .dht-stickers{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}
191
+ .dht-sticker{width:160px;height:160px;border-radius:8px;object-fit:contain;background:rgba(255,255,255,.02)}
192
+ .dht-poll{border:1px solid #2b2d31;background:#1e1f22;border-radius:8px;padding:12px 14px;margin-top:6px;max-width:480px}
193
+ .dht-poll-q{font-weight:700;margin-bottom:8px;font-size:15px}
194
+ .dht-poll-answers{display:flex;flex-direction:column;gap:6px}
195
+ .dht-poll-a{background:#2b2d31;border-radius:6px;padding:6px 10px;position:relative;overflow:hidden}
196
+ .dht-poll-bar{position:absolute;top:0;left:0;height:100%;background:rgba(88,101,242,.25);z-index:0}
197
+ .dht-poll-line{position:relative;z-index:1;display:flex;justify-content:space-between;gap:8px}
198
+ .dht-poll-pct{color:#b5bac1;font-size:12px;white-space:nowrap}
199
+ .dht-poll-foot{margin-top:8px;font-size:11px;color:#949ba4}
200
+ .dht-voice{display:inline-flex;align-items:center;gap:8px;background:#2b2d31;padding:6px 12px;border-radius:8px;color:#dbdee1;font-size:13px;margin:4px 0}
201
+ .dht-voice-wave svg{display:block}
202
+ .dht-voice-dur{color:#b5bac1;font-size:11px}
203
+ .dht-forwarded-wrap{margin:4px 0}
204
+ .dht-forwarded{border-left:3px solid #5865F2;background:rgba(88,101,242,.06);padding:6px 10px;border-radius:0 6px 6px 0;margin-bottom:4px}
205
+ .dht-forwarded-head{font-size:12px;color:#949ba4;margin-bottom:2px}
206
+ .dht-forwarded-icon{margin-right:4px}
207
+ .dht-forwarded-body{color:#dbdee1}
208
+ .dht-forwarded-attachments{margin-top:4px;font-size:11px;color:#949ba4}
209
+ .dht-edit-history{font-size:12px;color:#b5bac1;margin-top:4px}
210
+ .dht-edit-history summary{cursor:pointer;color:#949ba4}
211
+ .dht-edit-history li{margin-top:4px}
212
+ .dht-edit-history time{display:block;color:#6e727a;font-size:10px;margin-bottom:2px}
213
+ .dht-thread-badge{display:inline-block;background:#3f4248;color:#dbdee1;font-size:10px;padding:1px 6px;border-radius:4px;margin-left:6px}
214
+ .dht-thread-badge--locked{background:#dc3545}
215
+ .dht-thread-badge--archived{background:#6c757d}
216
+ .dht-gifv{max-width:400px;max-height:300px;border-radius:6px}
217
+ .dht-stats{margin:24px auto;padding:12px;text-align:center;color:#b5bac1;font-size:13px;font-family:"gg sans",sans-serif;border-top:1px solid #2b2d31;max-width:800px}
218
+ .dht-mention--cmd{background:rgba(88,101,242,.22)!important}
219
+ .dht-embed-provider{color:#949ba4;font-size:11px;margin-bottom:2px}
220
+ .dht-embed-video{margin-top:6px;font-size:13px}
221
+ .dht-embed-video a{color:#8ab4ff;text-decoration:none}
222
+ .dht-embed-video a:hover{text-decoration:underline}
223
+ .dht-automod-rule{font-size:11px;color:#949ba4}
224
+ .dht-automod-content{margin:4px 0 0 12px;color:#dbdee1;border-left:3px solid #f23f42;padding-left:8px;font-size:13px}
225
+ .dht-reaction--burst{box-shadow:inset 0 0 0 1px gold}
226
+ `;
227
+ //# sourceMappingURL=index.js.map