buncord-transcript 1.0.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/.gitattributes ADDED
@@ -0,0 +1,2 @@
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luigi Colantuono
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # Buncord-Transcript
2
+
3
+ <div align="center">
4
+ <img src="https://github.com/user-attachments/assets/70e8758e-f363-478a-a013-fd46ca3cf3ec" alt="Buncord Logo" width="180"/>
5
+ <p><b>The fastest, lightest, and most faithful Discord HTML transcript generator.</b></p>
6
+ <p><i>Built exclusively for the Bun ecosystem.</i></p>
7
+ </div>
8
+
9
+ ---
10
+
11
+ Stop simulating browsers to generate simple text logs. **Buncord-Transcript** purges the bloat of JSDOM and React, replacing them with a high-performance, string-based rendering engine powered by Bun and a specialized fork of Mustache.
12
+
13
+ ## ⚡ Blazingly Fast
14
+
15
+ * **Zero Node Dependencies**: No `ws`, no `http` legacy, no `JSDOM`. Pure Bun-native execution.
16
+ * **Mustache Powered**: Generates complex transcripts in milliseconds using optimized string templates instead of heavy, recursive DOM manipulation.
17
+ * **Zero Memory Overhead**: While other libraries require hundreds of MBs to "render" a virtual DOM, Buncord processes messages through a stream-like logic that keeps your RAM footprint invisible.
18
+
19
+ ## 🎨 Absolute Cinema UI
20
+
21
+ * **Discord v2 Native**: First-class support for modern components: **Buttons**, **Select Menus**, and the new **Containers**.
22
+ * **1:1 Visual Fidelity**: Unlike libraries with hardcoded styles, Buncord uses a dynamic CSS variable system mirrored directly from the official Discord client.
23
+ * **Media-First**: Native support for **Multi-image Media Galleries**, high-res avatars, and custom emoji rendering.
24
+ * **Smart Mentions**: Intelligently resolves user mentions and relative timestamps within the transcript context.
25
+
26
+ ## 📦 Installation
27
+
28
+ ```bash
29
+ bun add github:LuigiColantuono/buncord-transcript
30
+ ```
31
+
32
+ ## 🚀 Quick Start
33
+
34
+ ```typescript
35
+ import { createTranscript } from 'buncord-transcript';
36
+
37
+ const messages = [...]; // Your Discord.js / Buncord messages
38
+ const channel = { name: 'ticket-001' };
39
+
40
+ const html = await createTranscript(messages, channel);
41
+ // Output is a high-performance HTML buffer/string ready to be served or saved.
42
+ ```
43
+
44
+ ## 🛠️ The Philosophy
45
+
46
+ Built out of frustration with outdated, bloated libraries that fail to render modern Discord components. Buncord-Transcript is a **"Performance Tier 1"** tool for developers who prioritize speed, code purity, and production stability.
47
+
48
+ ---
49
+ > This project was created using `bun init` in bun v1.3.6. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
package/bun.lock ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "buncord-transcript",
7
+ "dependencies": {
8
+ "mustache": "luigicolantuono/mustache",
9
+ },
10
+ "devDependencies": {
11
+ "@types/bun": "latest",
12
+ },
13
+ "peerDependencies": {
14
+ "typescript": "^5",
15
+ },
16
+ },
17
+ },
18
+ "packages": {
19
+ "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
20
+
21
+ "@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="],
22
+
23
+ "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
24
+
25
+ "mustache": ["mustache@github:luigicolantuono/mustache#9a14e96", { "bin": { "mustache": "./bin/mustache" } }, "LuigiColantuono-mustache-9a14e96"],
26
+
27
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
28
+
29
+ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
30
+ }
31
+ }
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ console.log("Hello via Bun!");
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "buncord-transcript",
3
+ "version": "1.0.0",
4
+ "description": "A high-performance Discord transcript generator built for Bun.",
5
+ "module": "src/index.ts",
6
+ "main": "src/index.ts",
7
+ "type": "module",
8
+ "author": {
9
+ "name": "Luigi Colantuono",
10
+ "url": "https://github.com/LuigiColantuono"
11
+ },
12
+ "homepage": "https://github.com/LuigiColantuono",
13
+ "license": "MIT",
14
+ "funding": {
15
+ "type": "individual",
16
+ "url": "https://paypal.me/l0g4n7"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/LuigiColantuono/Buncord-Transcript.git"
21
+ },
22
+ "keywords": [
23
+ "discord",
24
+ "transcript",
25
+ "bun",
26
+ "html",
27
+ "generator"
28
+ ],
29
+ "devDependencies": {
30
+ "@types/bun": "latest"
31
+ },
32
+ "peerDependencies": {
33
+ "typescript": "5.9.3"
34
+ },
35
+ "dependencies": {
36
+ "mustache": "github:LuigiColantuono/mustache"
37
+ }
38
+ }
@@ -0,0 +1,245 @@
1
+ import mustache from 'mustache/mustache.js';
2
+ import { htmlTemplate, css } from './template';
3
+ import type { Message, TranscriptOptions, ChannelInfo, Embed, Button, SelectMenu, AnyComponent, ContainerComponent, TextDisplayComponent, SeparatorComponent, ActionRow } from './types';
4
+ // Helper to format Date
5
+ function formatDate(dateString: string): string {
6
+ const date = new Date(dateString);
7
+ return date.toLocaleString('en-US', {
8
+ year: 'numeric',
9
+ month: '2-digit',
10
+ day: '2-digit',
11
+ hour: '2-digit',
12
+ minute: '2-digit',
13
+ hour12: true
14
+ });
15
+ }
16
+ // Simple Markdown Formatter (Zero-dependency)
17
+ function formatContent(content: string, userMap?: Map<string, string>): string {
18
+ if (!content) return '';
19
+ let html = content
20
+ // Escape HTML
21
+ .replace(/&/g, '&amp;')
22
+ .replace(/</g, '&lt;')
23
+ .replace(/>/g, '&gt;')
24
+
25
+ // Headers (must be at start of line or after newline)
26
+ .replace(/^### (.*$)/gm, '<h3>$1</h3>')
27
+ .replace(/^## (.*$)/gm, '<h2>$1</h2>')
28
+ .replace(/^# (.*$)/gm, '<h1>$1</h1>')
29
+
30
+ // Code Blocks (multiline)
31
+ .replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
32
+ // Code Blocks (simple)
33
+ .replace(/```([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
34
+
35
+ // Inline Code
36
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
37
+
38
+ // Links [text](url)
39
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
40
+ // Bold
41
+ .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
42
+
43
+ // Italic
44
+ .replace(/\*([^*]+)\*/g, '<i>$1</i>')
45
+ .replace(/_([^_]+)_/g, '<i>$1</i>')
46
+
47
+ // Underline
48
+ .replace(/__([^_]+)__/g, '<u>$1</u>')
49
+
50
+ // Strikethrough
51
+ .replace(/~~([^~]+)~~/g, '<s>$1</s>')
52
+
53
+ // Newlines
54
+ .replace(/\n/g, '<br>');
55
+ // Mentions (User) <@123456>
56
+ html = html.replace(/&lt;@!?(\d+)&gt;/g, (match, id) => {
57
+ const username = userMap?.get(id) || 'User';
58
+ return `<span class="mention">@${username}</span>`;
59
+ });
60
+ // Mentions (Channel) <#123456>
61
+ html = html.replace(/&lt;#(\d+)&gt;/g, '<span class="mention">#channel</span>');
62
+ // Mentions (Role) <@&123456>
63
+ html = html.replace(/&lt;@&(\d+)&gt;/g, '<span class="mention">@role</span>');
64
+ // Timestamps <t:123456:R>
65
+ html = html.replace(/&lt;t:(\d+):?([A-Z])?&gt;/g, (match, timestamp, style) => {
66
+ const date = new Date(parseInt(timestamp) * 1000);
67
+ return `<span class="timestamp">${date.toLocaleString()}</span>`;
68
+ });
69
+ return html;
70
+ }
71
+ // Helper to render V2 Components to HTML string
72
+ function renderComponent(component: AnyComponent, userMap?: Map<string, string>): string {
73
+ if (component.type === 17) { // Container
74
+ const container = component as ContainerComponent;
75
+ const children = container.components.map(c => renderComponent(c, userMap)).join('');
76
+ return children;
77
+ }
78
+ if (component.type === 10) { // Text Display
79
+ const text = component as TextDisplayComponent;
80
+ const content = formatContent(text.content, userMap);
81
+ return `<div class="discord-section"><div class="discord-section-content">${content}</div></div>`;
82
+ }
83
+ if (component.type === 14) { // Separator
84
+ const sep = component as SeparatorComponent;
85
+ const style = sep.divider ? 'height: 1px;' : 'height: 0px;';
86
+ const margin = sep.spacing === 3 ? 'margin: 8px 0;' : sep.spacing === 2 ? 'margin: 4px 0;' : 'margin: 0;';
87
+ return `<div class="discord-separator" style="${style} ${margin}"></div>`;
88
+ }
89
+ if (component.type === 1) { // Action Row
90
+ const row = component as ActionRow;
91
+ const children = row.components.map(c => renderComponent(c, userMap)).join('');
92
+ return `<div class="message-component-group">${children}</div>`;
93
+ }
94
+ if (component.type === 2) { // Button
95
+ const btn = component as Button;
96
+ const styleClass = btn.style === 1 ? 'primary' :
97
+ btn.style === 2 ? 'secondary' :
98
+ btn.style === 3 ? 'success' :
99
+ btn.style === 4 ? 'destructive' :
100
+ btn.style === 5 ? 'secondary' : 'primary';
101
+
102
+ let content = '';
103
+ if (btn.emoji) {
104
+ if (btn.emoji.id) {
105
+ content += `<span style="display: flex; align-items: center;"><img src="https://cdn.discordapp.com/emojis/${btn.emoji.id}.webp?size=44&quality=lossless" alt="${btn.emoji.name}" style="width: 16px; height: 16px; margin-right: 8px;"></span>`;
106
+ } else if (btn.emoji.name) {
107
+ content += `<span style="display: flex; align-items: center; margin-right: 8px;">${btn.emoji.name}</span>`;
108
+ }
109
+ }
110
+ if (btn.label) {
111
+ content += `<span style="display: flex; align-items: center;">${btn.label}</span>`;
112
+ }
113
+ if (btn.style === 5) {
114
+ content += `<span style="margin-left: 8px; display: flex; align-items: center;"><svg role="img" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M15 2a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V4.41l-4.3 4.3a1 1 0 1 1-1.4-1.42L19.58 3H16a1 1 0 0 1-1-1Z"/><path fill="currentColor" d="M5 2a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-6a1 1 0 1 0-2 0v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h6a1 1 0 1 0 0-2H5Z"/></svg></span>`;
115
+ }
116
+ const disabledAttr = btn.disabled ? 'disabled' : '';
117
+ if (btn.style === 5 && btn.url) {
118
+ return `<a class="discord-button discord-button-secondary" href="${btn.url}" target="_blank" ${disabledAttr}>${content}</a>`;
119
+ }
120
+ return `<button class="discord-button discord-button-${styleClass}" type="button" ${disabledAttr}>${content}</button>`;
121
+ }
122
+
123
+ if (component.type === 3 || component.type === 5 || component.type === 6 || component.type === 7 || component.type === 8) {
124
+ const menu = component as SelectMenu;
125
+ return `<div class="discord-select-menu">
126
+ <div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${menu.placeholder || 'Select...'}</div>
127
+ <div style="display: flex; align-items: center; margin-left: 8px;">
128
+ <svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M7 10L12 15L17 10H7Z" /></svg>
129
+ </div>
130
+ </div>`;
131
+ }
132
+ return '';
133
+ }
134
+ export async function generateTranscript(messages: Message[], channel: ChannelInfo, options: TranscriptOptions = {}) {
135
+ // Build user map
136
+ const userMap = new Map<string, string>();
137
+ for (const msg of messages) {
138
+ if (msg.author && msg.author.id && msg.author.username) {
139
+ userMap.set(msg.author.id, msg.author.username);
140
+ }
141
+ // Also check replyTo author
142
+ if (msg.replyTo?.author?.id && msg.replyTo?.author?.username) {
143
+ userMap.set(msg.replyTo.author.id, msg.replyTo.author.username);
144
+ }
145
+ }
146
+
147
+ const processedMessages = messages.map(msg => {
148
+ return {
149
+ ...msg,
150
+ timestamp: formatDate(msg.timestamp),
151
+ content: formatContent(msg.content, userMap),
152
+ embeds: msg.embeds?.map(embed => ({
153
+ ...embed,
154
+ description: embed.description ? formatContent(embed.description, userMap) : undefined,
155
+ fields: embed.fields?.map(field => ({
156
+ ...field,
157
+ value: formatContent(field.value, userMap)
158
+ })),
159
+ hexColor: embed.color ? '#' + embed.color.toString(16).padStart(6, '0') : undefined,
160
+ })),
161
+ containers: [
162
+ ...(msg.containers?.map(container => ({
163
+ ...container,
164
+ content: container.content
165
+ })) || []),
166
+ ...(msg.components?.filter(c => c.type === 17).map(container => ({
167
+ content: renderComponent(container, userMap)
168
+ })) || [])
169
+ ],
170
+ components: [
171
+ ...(msg.components?.filter(c => c.type === 1).map(c => {
172
+ const row = c as ActionRow;
173
+ return {
174
+ ...row,
175
+ components: row.components.map(component => {
176
+ if (component.type === 2) {
177
+ const btn = component as Button;
178
+ return {
179
+ ...btn,
180
+ isButton: true,
181
+ styleClass: btn.style === 1 ? 'primary' :
182
+ btn.style === 2 ? 'secondary' :
183
+ btn.style === 3 ? 'success' :
184
+ btn.style === 4 ? 'destructive' :
185
+ btn.style === 5 ? 'secondary' : 'primary',
186
+ isLink: btn.style === 5,
187
+ emoji: btn.emoji
188
+ };
189
+ } else {
190
+ return {
191
+ ...component,
192
+ isSelectMenu: true
193
+ };
194
+ }
195
+ })
196
+ };
197
+ }) || []),
198
+ ...(msg.components?.some(c => c.type !== 1 && c.type !== 17) ? [{
199
+ type: 1,
200
+ components: msg.components.filter(c => c.type !== 1 && c.type !== 17).map(component => {
201
+ if (component.type === 2) {
202
+ const btn = component as Button;
203
+ return {
204
+ ...btn,
205
+ isButton: true,
206
+ styleClass: btn.style === 1 ? 'primary' :
207
+ btn.style === 2 ? 'secondary' :
208
+ btn.style === 3 ? 'success' :
209
+ btn.style === 4 ? 'destructive' :
210
+ btn.style === 5 ? 'secondary' : 'primary',
211
+ isLink: btn.style === 5,
212
+ emoji: btn.emoji
213
+ };
214
+ } else if (component.type === 3 || component.type === 5 || component.type === 6 || component.type === 7 || component.type === 8) {
215
+ return {
216
+ ...component,
217
+ isSelectMenu: true
218
+ };
219
+ }
220
+ return component;
221
+ })
222
+ }] : [])
223
+ ],
224
+ mediaGalleries: msg.mediaGalleries,
225
+ separators: msg.separators?.map(sep => ({
226
+ ...sep,
227
+ isLarge: sep.spacing === 3
228
+ })),
229
+ replyTo: msg.replyTo ? {
230
+ ...msg.replyTo,
231
+ contentSnippet: msg.replyTo.content.substring(0, 50) + (msg.replyTo.content.length > 50 ? '...' : '')
232
+ } : undefined
233
+ };
234
+ });
235
+ const view = {
236
+ channel,
237
+ messages: processedMessages,
238
+ css
239
+ };
240
+ const output = (mustache as any).render(htmlTemplate, view);
241
+ if (options.returnType === 'buffer') {
242
+ return Buffer.from(output);
243
+ }
244
+ return output;
245
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+
2
+ import { generateTranscript } from './generator';
3
+ export * from './types';
4
+
5
+ // Facade for the user
6
+ export const createTranscript = generateTranscript;
@@ -0,0 +1,8 @@
1
+ declare module 'mustache/mustache.js' {
2
+ export interface MustacheStatic {
3
+ render(template: string, view: any, partials?: any, tags?: any): string;
4
+ escape(text: string): string;
5
+ }
6
+ const mustache: MustacheStatic;
7
+ export default mustache;
8
+ }