discord-html-transcripts-fix 2.3.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -275,6 +275,9 @@ In addition to plain text, replies, embeds, and attachments, the viewer supports
275
275
  - **Fix** link previews, GIFs (`gifv`) and video embeds get their own layout — discord.js keeps the embed type only in `embed.data`, so every embed rendered as `rich` and an image preview showed up as an empty card with a small thumbnail
276
276
  - **Fix** super reactions and voice-message durations show — the renderer read the raw API names (`count_details`, `burst_colors`, `duration_secs`), which discord.js renames
277
277
  - **Fix** reply authors get their role color, and embed footer icons and audio file sizes show — the values were passed as camelCase attributes, which HTML lowercases, so the components never received them. Members without a colored role are no longer painted black
278
+ - **Fix** text outside the Basic Multilingual Plane showed as pairs of `�` — fancy-font ("math italic") server and channel names, rare ideographs, symbols — in messages, embeds and Components V2 containers. The markdown parser cuts its text at every non-ASCII character and counts UTF-16 code units, so such a character arrived as two lone halves that could not be encoded. Text is merged back (and renders as one piece per run instead of one node per non-ASCII character, which also shrinks the HTML). Reply and thread previews, the search text, activity party ids and the server's initial letters no longer cut through a character either, and a server name with a double space no longer reads "Aundefined"
279
+ - **Fix** a fenced code block in a language highlight.js does not know — ` ```ansi `, ` ```log `, a typo such as `js2` — made the whole export fail, because the error was raised while rendering, outside the per-message safety net. Such blocks now show as plain text, like in Discord; `ansi` blocks lose their color escape codes
280
+ - **Fix** a role-subscription tier name containing `$&`, `$'`, `` $` `` or `$$` was altered when the message was composed; it is shown exactly as written
278
281
  - **Fix** reaction counts of 1,000 and more showed `NaN` — the abbreviated "1.5K" went to a component that only reads numbers; the full count is shown now
279
282
  - **Fix** system messages of types without their own wording — Server Discovery notices, invite reminders, premium upsells, purchase notifications, and any type Discord adds later — were silently dropped from the transcript. They appear as a neutral "System message" line with Discord's text now (new i18n key `systemMessage`)
280
283
  - **Fix** the stats footer counts one of something in the singular — "1 message · 1 participant · 1 image" instead of "1 messages", in German too (new i18n keys `statsMessage`, `statsParticipant`, `statsImage`)
@@ -7,6 +7,7 @@ exports.DiscordHighlightStyles = void 0;
7
7
  exports.DiscordHighlightedCode = DiscordHighlightedCode;
8
8
  const jsx_runtime_1 = require("react/jsx-runtime");
9
9
  const highlight_js_1 = __importDefault(require("highlight.js"));
10
+ const utils_1 = require("../../../utils/utils");
10
11
  // styles from https://github.com/cherryblossom000/discord-syntax-highlighting/tree/main
11
12
  exports.DiscordHighlightStyles = `
12
13
  .hljs-ansi-control-sequence {
@@ -190,10 +191,35 @@ exports.DiscordHighlightStyles = `
190
191
  background-color: #67060c;
191
192
  }
192
193
  `;
194
+ const ESC = String.fromCharCode(27);
195
+ // Discord colors ANSI blocks; the transcript shows their text without the control sequences.
196
+ const ANSI_SGR = new RegExp(ESC + '\\[[0-9;]*m', 'g');
197
+
198
+ function escapeHtml(text) {
199
+ return String(text)
200
+ .replace(/&/g, '&')
201
+ .replace(/</g, '&lt;')
202
+ .replace(/>/g, '&gt;')
203
+ .replace(/"/g, '&quot;')
204
+ .replace(/'/g, '&#x27;');
205
+ }
206
+
207
+ // The block is rendered while React runs, outside every per-message safety net, so a
208
+ // throw here used to reject the whole export: highlight.js raises an error for a language
209
+ // it does not know ("ansi", "log", a typo such as "js2"), and users write those all the time.
210
+ // Those blocks show as plain text, which is what Discord does for them.
211
+ function highlightCode(content, language) {
212
+ const name = typeof language === 'string' ? language.trim() : '';
213
+ try {
214
+ if (!name) return highlight_js_1.default.highlightAuto(content).value;
215
+ if (highlight_js_1.default.getLanguage(name)) return highlight_js_1.default.highlight(content, { language: name }).value;
216
+ } catch (err) {
217
+ console.warn('[discord-html-transcripts-fix] could not highlight a code block:', (0, utils_1.describeError)(err));
218
+ }
219
+ return escapeHtml(name.toLowerCase() === 'ansi' ? content.replace(ANSI_SGR, '') : content);
220
+ }
221
+
193
222
  function DiscordHighlightedCode(props) {
194
- const highlighted = props.language
195
- ? highlight_js_1.default.highlight(props.content, { language: props.language })
196
- : highlight_js_1.default.highlightAuto(props.content);
197
- return (0, jsx_runtime_1.jsx)("discord-code", { multiline: true, className: "theme-dark hljs", dangerouslySetInnerHTML: { __html: highlighted.value } });
223
+ return (0, jsx_runtime_1.jsx)("discord-code", { multiline: true, className: "theme-dark hljs", dangerouslySetInnerHTML: { __html: highlightCode(props.content, props.language) } });
198
224
  }
199
225
  //# sourceMappingURL=DiscordHighlightedCode.js.map
@@ -2,12 +2,32 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TranscriptHeader = TranscriptHeader;
4
4
  const jsx_runtime_1 = require("react/jsx-runtime");
5
+
6
+ // The first user-perceived character of a word. word[0] is one UTF-16 code unit —
7
+ // half of a character outside the Basic Multilingual Plane (fancy-font letters, emoji),
8
+ // which cannot be encoded — and would also cut an accent off its letter or an emoji
9
+ // sequence apart.
10
+ const segmenter = typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
11
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
12
+ : null;
13
+ function firstCharacter(word) {
14
+ if (segmenter) {
15
+ for (const { segment } of segmenter.segment(word)) return segment;
16
+ return '';
17
+ }
18
+ const codePoint = word.codePointAt(0);
19
+ return codePoint === undefined ? '' : String.fromCodePoint(codePoint);
20
+ }
21
+
5
22
  function TranscriptHeader(props) {
6
23
  // If guild has no icon, we take first letter of guild name words
7
24
  // i.e. Guild A -> GA
8
25
  // and OneWordGuildName -> O
9
- const split = props.guildName.split(' ');
10
- const placeholder = split.length > 1 ? split[0][0] + split[1][0] : split[0][0];
26
+ if (typeof props.guildName !== 'string') {
27
+ console.warn('[discord-html-transcripts-fix] the server has no name (an unavailable guild?); the header shows none');
28
+ }
29
+ const words = String(props.guildName ?? '').split(/\s+/).filter(Boolean);
30
+ const placeholder = words.length > 1 ? firstCharacter(words[0]) + firstCharacter(words[1]) : firstCharacter(words[0] ?? '');
11
31
  return ((0, jsx_runtime_1.jsxs)("div", { className: "discord-header", children: [(0, jsx_runtime_1.jsx)("div", { className: "discord-header-icon", children: props.guildIcon ? ((0, jsx_runtime_1.jsx)("img", { src: props.guildIcon, alt: "guild icon" })) : ((0, jsx_runtime_1.jsx)("div", { children: (0, jsx_runtime_1.jsx)("span", { children: placeholder }) })) }), (0, jsx_runtime_1.jsxs)("div", { className: "discord-header-text", children: [(0, jsx_runtime_1.jsx)("div", { className: "discord-header-text-guild", children: props.guildName }), (0, jsx_runtime_1.jsxs)("div", { className: "discord-header-text-channel", children: ["#", props.channelName] }), props.children] })] }));
12
32
  }
13
33
  //# sourceMappingURL=TranscriptHeader.js.map
@@ -32,6 +32,44 @@ function hexToRgba(hex, alpha) {
32
32
  return `rgba(${r},${g},${b},${alpha})`;
33
33
  }
34
34
 
35
+ // discord-markdown-parser ends a text node before every character outside
36
+ // [0-9A-Za-z] and whitespace, and it counts UTF-16 code units. A character outside
37
+ // the Basic Multilingual Plane — fancy-font letters, rare CJK, symbols — therefore
38
+ // arrives as two text nodes holding one lone surrogate half each, and encoding the
39
+ // page turned every half into U+FFFD. Adjacent text nodes are joined again, which
40
+ // also stops non-ASCII text from becoming one node (and one markup separator) per
41
+ // character. Plain text content is all that is merged; nothing else changes.
42
+ function mergeTextNodes(nodes) {
43
+ if (!Array.isArray(nodes)) return nodes;
44
+ const out = [];
45
+ for (const node of nodes) {
46
+ if (Array.isArray(node)) {
47
+ out.push(mergeTextNodes(node));
48
+ continue;
49
+ }
50
+ if (!node || typeof node !== 'object') {
51
+ out.push(node);
52
+ continue;
53
+ }
54
+ const previous = out[out.length - 1];
55
+ if (node.type === 'text' && typeof node.content === 'string'
56
+ && previous && !Array.isArray(previous) && previous.type === 'text' && typeof previous.content === 'string') {
57
+ out[out.length - 1] = Object.assign({}, previous, { content: previous.content + node.content });
58
+ continue;
59
+ }
60
+ const merged = Object.assign({}, node);
61
+ if (Array.isArray(node.content)) merged.content = mergeTextNodes(node.content);
62
+ if (Array.isArray(node.items)) merged.items = mergeTextNodes(node.items);
63
+ out.push(merged);
64
+ }
65
+ return out;
66
+ }
67
+
68
+ function parseMarkdown(source, mode) {
69
+ const parsed = discord_markdown_parser_1.default(source, mode);
70
+ return mergeTextNodes(Array.isArray(parsed) ? parsed : [parsed]);
71
+ }
72
+
35
73
  // Discord-specific block-level markdown that discord-markdown-parser misses in inline mode.
36
74
  // Run BEFORE the parser, extract heading/subtext lines, parse the rest, splice back together.
37
75
  const HEADING_RE = /^(#{1,3})\s+(.+?)\s*$/;
@@ -47,9 +85,7 @@ function preParseDiscord(content, mode) {
47
85
 
48
86
  const flushInline = () => {
49
87
  if (!inlineBuffer) return;
50
- const parsed = discord_markdown_parser_1.default(inlineBuffer, mode);
51
- const arr = Array.isArray(parsed) ? parsed : [parsed];
52
- nodes.push(...arr);
88
+ nodes.push(...parseMarkdown(inlineBuffer, mode));
53
89
  inlineBuffer = '';
54
90
  };
55
91
 
@@ -77,16 +113,14 @@ function preParseDiscord(content, mode) {
77
113
  if (h) {
78
114
  flushInline();
79
115
  const level = h[1].length;
80
- const headingInner = discord_markdown_parser_1.default(h[2], mode);
81
- nodes.push({ type: 'heading', level, content: Array.isArray(headingInner) ? headingInner : [headingInner] });
116
+ nodes.push({ type: 'heading', level, content: parseMarkdown(h[2], mode) });
82
117
  continue;
83
118
  }
84
119
 
85
120
  const s = line.match(SUBTEXT_RE);
86
121
  if (s) {
87
122
  flushInline();
88
- const innerNodes = discord_markdown_parser_1.default(s[1], mode);
89
- nodes.push({ type: 'subtext', content: Array.isArray(innerNodes) ? innerNodes : [innerNodes] });
123
+ nodes.push({ type: 'subtext', content: parseMarkdown(s[1], mode) });
90
124
  continue;
91
125
  }
92
126
 
@@ -103,14 +137,15 @@ function preParseDiscord(content, mode) {
103
137
 
104
138
  async function MessageContent({ content, context }) {
105
139
  if (context.type === RenderType.REPLY && content.length > 180) {
106
- content = content.slice(0, 180) + '...';
140
+ // Not through the middle of a surrogate pair: half of one cannot be encoded.
141
+ content = (0, utils_1.truncateText)(content, 180) + '...';
107
142
  }
108
143
  const mode = context.type === RenderType.EMBED || context.type === RenderType.WEBHOOK ? 'extended' : 'normal';
109
144
  // REPLY mode is always inline-only (heading inside a reply preview makes no sense).
110
145
  // For all other modes (NORMAL/EMBED/WEBHOOK) we pre-process headings/subtext/code-fences
111
146
  // so they render correctly even though discord-markdown-parser is inline-only.
112
147
  const parsed = context.type === RenderType.REPLY
113
- ? discord_markdown_parser_1.default(content, mode)
148
+ ? parseMarkdown(content, mode)
114
149
  : preParseDiscord(content, mode);
115
150
 
116
151
  const nodes = Array.isArray(parsed) ? parsed : [parsed];
@@ -74,7 +74,7 @@ async function DiscordMessage({ message, context }) {
74
74
  : '';
75
75
  const authorName = (message.member?.nickname || message.author?.displayName || message.author?.username || '').toLowerCase();
76
76
  const ts = message.createdAt instanceof Date ? message.createdAt.getTime() : 0;
77
- const lowerText = (message.content || '').toLowerCase().slice(0, 4096);
77
+ const lowerText = (0, utils_1.truncateText)((message.content || '').toLowerCase(), 4096);
78
78
  const mediaFlags = detectMediaFlags(message);
79
79
  const hasImage = mediaFlags.hasImage;
80
80
  const hasEmbed = mediaFlags.hasEmbed;
@@ -198,7 +198,7 @@ async function DiscordMessage({ message, context }) {
198
198
  isSilent && (0, jsx_runtime_1.jsx)("span", { className: "dht-badge dht-badge-silent", title: "Silent message (no notification)", children: '🔕 Silent' }),
199
199
  activity && (0, jsx_runtime_1.jsxs)("div", { className: "dht-activity", children: [
200
200
  (0, jsx_runtime_1.jsx)("span", { className: "dht-activity-icon", children: '🎮' }),
201
- (0, jsx_runtime_1.jsxs)("span", { children: [activity.type, ' invite', activity.partyId ? ` · Party ${activity.partyId.slice(-6)}` : ''] })
201
+ (0, jsx_runtime_1.jsxs)("span", { children: [activity.type, ' invite', activity.partyId ? ` · Party ${Array.from(activity.partyId).slice(-6).join('')}` : ''] })
202
202
  ] }),
203
203
  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') + ')' }),
204
204
  Array.isArray(message.editHistory) && message.editHistory.length > 0 && renderEditHistory(message.editHistory, context),
@@ -270,7 +270,7 @@ function renderThread(thread, context) {
270
270
  return (0, jsx_runtime_1.jsxs)("discord-thread", { slot: "thread", name: thread.name, cta, children: [
271
271
  badges,
272
272
  thread.lastMessage
273
- ? (0, jsx_runtime_1.jsx)("discord-thread-message", { profile: thread.lastMessage.author.id, children: (0, jsx_runtime_1.jsx)(content_1.default, { content: thread.lastMessage.content.length > 128 ? thread.lastMessage.content.substring(0, 125) + '...' : thread.lastMessage.content, context: Object.assign({}, context, { type: content_1.RenderType.REPLY }) }) })
273
+ ? (0, jsx_runtime_1.jsx)("discord-thread-message", { profile: thread.lastMessage.author.id, children: (0, jsx_runtime_1.jsx)(content_1.default, { content: thread.lastMessage.content.length > 128 ? (0, utils_1.truncateText)(thread.lastMessage.content, 125) + '...' : thread.lastMessage.content, context: Object.assign({}, context, { type: content_1.RenderType.REPLY }) }) })
274
274
  : 'Thread messages not saved.'
275
275
  ] });
276
276
  }
@@ -201,7 +201,7 @@ async function SystemMessage({ message, context }) {
201
201
  return ((0, jsx_runtime_1.jsxs)("discord-system-message", { id: `m-${message.id}`, timestamp: ts, type: "boost", children: [
202
202
  (0, jsx_runtime_1.jsx)(Highlight, { color, userId: authorId, children: authorName }),
203
203
  ' ',
204
- t(ctx, 'roleSubPurchased', 'subscribed to {role}!').replace('{role}', roleName),
204
+ t(ctx, 'roleSubPurchased', 'subscribed to {role}!').replace('{role}', () => roleName),
205
205
  ] }, message.id));
206
206
  }
207
207
 
@@ -12,6 +12,7 @@ exports.safeHref = safeHref;
12
12
  exports.safeImageSrc = safeImageSrc;
13
13
  exports.safeLinkHref = safeLinkHref;
14
14
  exports.describeError = describeError;
15
+ exports.truncateText = truncateText;
15
16
  exports.safeColor = safeColor;
16
17
  exports.safeImageMime = safeImageMime;
17
18
  exports.escapeHtml = escapeHtml;
@@ -228,6 +229,16 @@ function safeLinkHref(url) {
228
229
  return safeHref(url);
229
230
  }
230
231
 
232
+ // Cuts `text` to at most `max` UTF-16 code units without cutting through a surrogate
233
+ // pair. Half of a pair cannot be encoded: it ends up as U+FFFD in the page.
234
+ function truncateText(text, max) {
235
+ if (typeof text !== 'string' || text.length <= max) return text;
236
+ let end = max;
237
+ const last = text.charCodeAt(end - 1);
238
+ if (last >= 0xd800 && last <= 0xdbff) end--; // a first half: leave it out with its second half
239
+ return text.slice(0, end);
240
+ }
241
+
231
242
  // An error for a log line: its message and stack only. A whole error object can
232
243
  // carry what a caller's HTTP client attached to it, request headers included.
233
244
  function describeError(err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discord-html-transcripts-fix",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
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",