@cartiqo/discord-transcript
v0.2.0
Published
Generate beautiful, self-contained HTML transcripts of Discord channels, rendered with Discord's own message components.
Maintainers
Readme
@cartiqo/discord-transcript
Generate beautiful, self-contained HTML transcripts of Discord channels —
rendered with Discord's own message components via
@skyra/discord-components-core.
Give it plain message data (author, content, timestamp, attachments, embeds,
reactions) and get back a single HTML string you can save to a file, upload as an
attachment, or serve. No headless browser, no runtime dependencies to bundle —
the output is one portable .html document.
Install
npm install @cartiqo/discord-transcriptUsage
import { renderTranscript } from "@cartiqo/discord-transcript";
const html = renderTranscript({
guild: { name: "My Server", iconURL: "https://…/icon.png" },
channel: { name: "ticket-0042", topic: "Support ticket for @ada" },
messages: [
{
author: { name: "Ada", avatarURL: "https://…/ada.png", color: "#7c5cff" },
content: "Hey, I need help with **billing** — here's a screenshot.",
timestamp: new Date(),
attachments: [{ url: "https://…/shot.png", contentType: "image/png" }],
},
{
author: { name: "Support", bot: true, verified: true },
content: "On it! Give me a moment.",
timestamp: new Date(),
reply: { author: "Ada", content: "I need help with billing" },
},
],
});
// html is a complete <!doctype html> document.From discord.js
The package is framework-agnostic — it takes plain data — so you map your messages once:
import { renderTranscript, type TranscriptMessage } from "@cartiqo/discord-transcript";
const collection = await channel.messages.fetch({ limit: 100 });
const messages: TranscriptMessage[] = [...collection.values()]
.reverse()
.map((m) => ({
id: m.id,
author: {
id: m.author.id,
name: m.member?.displayName ?? m.author.username,
avatarURL: m.author.displayAvatarURL(),
bot: m.author.bot,
color: m.member?.displayHexColor,
},
content: m.content,
timestamp: m.createdAt,
edited: Boolean(m.editedAt),
attachments: [...m.attachments.values()].map((a) => ({
url: a.url,
name: a.name ?? undefined,
contentType: a.contentType ?? undefined,
width: a.width ?? undefined,
height: a.height ?? undefined,
size: a.size,
})),
embeds: m.embeds.map((e) => ({
title: e.title ?? undefined,
description: e.description ?? undefined,
url: e.url ?? undefined,
color: e.hexColor ?? undefined,
authorName: e.author?.name,
imageURL: e.image?.url,
thumbnailURL: e.thumbnail?.url,
footerText: e.footer?.text,
fields: e.fields.map((f) => ({ name: f.name, value: f.value, inline: f.inline })),
timestamp: e.timestamp ?? undefined,
})),
reactions: m.reactions.cache.map((r) => ({
emoji: r.emoji.name ?? "❓",
imageURL: r.emoji.imageURL() ?? undefined,
count: r.count,
})),
reply: m.reference
? (() => {
const ref = collection.get(m.reference!.messageId!);
return ref ? { author: ref.author.username, content: ref.content } : undefined;
})()
: undefined,
}));
const html = renderTranscript({ channel: { name: channel.name }, messages });Options
renderTranscript(options) accepts:
| Option | Type | Default | Description |
| ------------------- | ------------------- | ------------------ | -------------------------------------------------------- |
| messages | TranscriptMessage[] | — | The messages, oldest first. |
| channel | TranscriptChannel | — | Channel name / topic shown in the header. |
| guild | TranscriptGuild | — | Server name / icon shown in the header. |
| generatedAt | Date | new Date() | Export timestamp. |
| title | string | channel name | The document <title>. |
| light | boolean | false | Use the light theme. |
| footerCredit | boolean | true | Show a small footer credit. |
| componentsVersion | string | current | Pin the components version loaded from the CDN. |
| offlineFallback | boolean | true | Also write a plain-HTML copy, shown only if the components fail to load. |
Markdown
Message content and embed descriptions support a safe subset of Discord markdown: fenced and inline code, bold, italics, underline, ~~strikethrough~~, spoilers, block quotes, and autolinked URLs. All input is HTML-escaped first, so raw message text can never inject markup.
Rendering
The output loads @skyra/discord-components-core from a CDN (jsDelivr) to render
the message components, so the full Discord look needs a network connection the
first time. The surrounding page chrome (header, code blocks, links) is styled
inline and works offline.
The transcript stays readable when that request fails. The Discord components
carry the author, timestamp and role colour as attributes, so an element that
never upgrades loses all three — leaving unattributed text in one block, which is
worth very little as a record of a conversation. renderTranscript therefore
also writes a plain-HTML copy of the same messages, hidden by CSS as soon as the
components define themselves:
discord-messages:not(:defined) { display: none; } /* wait for the upgrade */
discord-messages:defined + .dt-fallback { display: none; } /* then drop the copy */Pure CSS, so it also covers JavaScript being disabled entirely, and the two are
never visible at once. It roughly doubles the file size — pass
offlineFallback: false if you are serving the components yourself and can
guarantee they load.
