discord-html-transcripts-fix
Version:
A nicely formatted html transcript generator for discord.js. Bugfix fork with support for the latest discord.js and Components v2.
303 lines (281 loc) • 17.4 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
var __rest = (this && this.__rest) || function (s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]];
}
return t;
};
var _a;
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = render;
exports.renderToStream = renderToStream;
const jsx_runtime_1 = require("react/jsx-runtime");
const static_1 = require("react-dom/static");
const buildProfiles_1 = require("../utils/buildProfiles");
const client_1 = require("../static/client");
const fs_1 = require("fs");
const path_1 = __importDefault(require("path"));
const transcript_1 = __importDefault(require("./transcript"));
const utils_1 = require("../utils/utils");
const styles_1 = require("./renderers/components/styles");
const DiscordImage_1 = require("./renderers/components/DiscordImage");
const DiscordHighlightedCode_1 = require("./renderers/components/DiscordHighlightedCode");
// Lock to an exact resolved version — semver ranges in src= aren't cacheable by CDNs.
let discordComponentsVersion = '4.0.2';
try {
const packagePath = path_1.default.join(__dirname, '..', '..', 'package.json');
const packageJSON = JSON.parse((0, fs_1.readFileSync)(packagePath, 'utf8'));
const v = (_a = packageJSON.dependencies['@skyra/discord-components-core']) !== null && _a !== void 0 ? _a : discordComponentsVersion;
const cleaned = String(v).replace(/^[\^~]/, '');
if (/^\d+\.\d+\.\d+/.test(cleaned)) discordComponentsVersion = cleaned;
} catch (_b) { /* ignore */ }
function computeStats(messages) {
let images = 0;
let humans = new Set();
let firstTs = null, lastTs = null;
for (const m of messages) {
if (m.attachments && m.attachments.forEach) {
m.attachments.forEach((a) => {
if (typeof a.contentType === 'string' && a.contentType.startsWith('image/')) images++;
});
}
if (m.author && !m.author.bot) humans.add(m.author.id);
const ts = m.createdAt instanceof Date ? m.createdAt.getTime() : (snowflakeToDate(m.id)?.getTime() || null);
if (ts) {
if (firstTs === null || ts < firstTs) firstTs = ts;
if (lastTs === null || ts > lastTs) lastTs = ts;
}
}
return {
messageCount: messages.length,
participantCount: humans.size,
imageCount: images,
firstTs,
lastTs,
spanMs: firstTs && lastTs ? lastTs - firstTs : null,
};
}
function snowflakeToDate(id) {
try {
const DISCORD_EPOCH = 1420070400000n;
const ms = (BigInt(id) >> 22n) + DISCORD_EPOCH;
return new Date(Number(ms));
} catch (_e) { return null; }
}
function buildDateSeparators(messages, lang) {
const seps = new Array(messages.length).fill(null);
let lastYmd = null;
// FIX: use local date (matches what the formatter shows). UTC keys were mismatched with the
// local-time formatter output, causing the wrong day label near midnight UTC.
const fmt = new Intl.DateTimeFormat(lang === 'de' ? 'de-DE' : 'en-US', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' });
for (let i = 0; i < messages.length; i++) {
const m = messages[i];
const d = m.createdAt instanceof Date ? m.createdAt : snowflakeToDate(m.id);
if (!d) continue;
const ymd = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
if (ymd !== lastYmd) {
seps[i] = fmt.format(d);
lastYmd = ymd;
}
}
return seps;
}
async function render(_a) {
var _b;
var { messages, channel, callbacks } = _a, options = __rest(_a, ["messages", "channel", "callbacks"]);
const lang = options.language && client_1.defaultDicts[options.language] ? options.language : 'en';
const i18n = options.i18n
? Object.fromEntries(Object.keys(client_1.defaultDicts).map((k) => [k, Object.assign({}, client_1.defaultDicts[k], options.i18n[k] || {})]))
: client_1.defaultDicts;
// Single-pass collector (profiles + extended in one walk)
const allCtx = await (0, buildProfiles_1.buildAllContext)(messages, channel).catch((e) => {
console.warn('[discord-html-transcripts-fix] buildAllContext failed:', e?.message || e);
return { profiles: {}, users: {}, roles: {}, channels: {} };
});
const stats = computeStats(messages);
const dateSeps = buildDateSeparators(messages, lang);
const globalData = {
profiles: allCtx.profiles,
users: allCtx.users,
roles: allCtx.roles,
channels: allCtx.channels,
lang,
i18n,
stats,
};
const renderContext = Object.assign({ lang, i18n, dateSeps, stats }, callbacks ? { callbacks } : {});
const docTree = (0, jsx_runtime_1.jsxs)("html", { lang, 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: client_1.mentionPopupStyles } }),
(0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.transcriptUtils } }),
(0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.searchBarStyles } }),
(0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.lightboxStyles } }),
(0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.tocStyles } }),
(0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.dateSeparatorStyles } }),
(0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.markdownStyles } }),
(0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.filterPanelStyles } }),
(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)("style", { dangerouslySetInnerHTML: { __html: extraStyles } }),
(0, jsx_runtime_1.jsx)("link", { rel: "icon", type: "image/png", href: options.favicon === 'guild'
? channel.isDMBased()
? undefined
: ((_b = channel.guild.iconURL({ size: 16, extension: 'png' })) !== null && _b !== void 0 ? _b : undefined)
: options.favicon }),
(0, jsx_runtime_1.jsx)("title", { children: channel.isDMBased() ? 'Direct Messages' : channel.name }),
(0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.scrollToMessage } }),
// Non-blocking inline JSON: type="application/json" so the parser doesn't pause
(0, jsx_runtime_1.jsx)("script", { id: "dht-data", type: "application/json", dangerouslySetInnerHTML: { __html: (0, utils_1.safeJsonForScript)(globalData) } }),
(0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: 'try{globalThis.$discordMessage=JSON.parse(document.getElementById("dht-data").textContent);}catch(e){globalThis.$discordMessage={};}' } }),
// Always load the @skyra/discord-components-core ES module, even
// when `hydrate: true` is requested. Lit SSR does not reliably
// expand every <discord-*> custom element to plain HTML, and the
// browser-side definitions are also needed to hydrate the SSR'd
// markup (re-render with state, click handlers, etc.). Without
// this tag the page is just static unstyled custom elements.
(0, jsx_runtime_1.jsx)("script", { type: "module", src: `https://cdn.jsdelivr.net/npm/@skyra/discord-components-core@${discordComponentsVersion}/+esm`, crossOrigin: "anonymous" }),
] }),
(0, jsx_runtime_1.jsxs)("body", { style: { margin: 0, minHeight: '100vh' }, children: [
(0, jsx_runtime_1.jsx)(transcript_1.default, Object.assign({ messages, channel, callbacks }, options, renderContext)),
renderStatsFooter(stats, lang, i18n, options.statsFooter),
(0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.mentionPopup } }),
(0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.searchBar } }),
(0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.lightbox } }),
(0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.toc } }),
(0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.filterPanel } }),
(0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.codeCopyButton } }),
options.hydrate && (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.revealSpoiler } }),
] })
] });
const { prelude } = await (0, static_1.prerenderToNodeStream)(docTree);
if (options.hydrate) {
const markup = await (0, utils_1.streamToString)(prelude);
const { render: renderLit } = await import('@lit-labs/ssr');
const result = renderLit(markup);
const { collectResult } = await import('@lit-labs/ssr/lib/render-result.js');
return await collectResult(result);
}
if (options.returnType === 'stream' || options.stream) {
// Return the underlying Node stream — caller is responsible for piping.
return prelude;
}
return await (0, utils_1.streamToString)(prelude);
}
async function renderToStream(args) {
return render(Object.assign({}, args, { returnType: 'stream' }));
}
function formatSpan(ms, lang) {
if (!ms || ms < 0) return '';
const days = Math.floor(ms / 86400000);
const hours = Math.floor((ms % 86400000) / 3600000);
const mins = Math.floor((ms % 3600000) / 60000);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${mins}m`;
if (mins > 0) return `${mins}m`;
return '<1m';
}
// Renders the bottom stats footer. Caller-controlled via options.statsFooter:
// false → no footer
// { enabled: false } → no footer
// { template: '{messages} ...' } → render with custom template
// Placeholders: {messages} {participants}
// {images} {from} {to} {span}
// Default: en template "X messages · Y participants · Z images · from → to · span"
function renderStatsFooter(stats, lang, i18n, opts) {
if (opts === false) return null;
const config = (opts && typeof opts === 'object') ? opts : {};
if (config.enabled === false) return null;
const locale = lang === 'de' ? 'de-DE' : 'en-US';
const fmtDate = (ms) => ms ? new Date(ms).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }) : '';
const span = stats.spanMs ? formatSpan(stats.spanMs) : '';
const values = {
messages: String(stats.messageCount),
participants: String(stats.participantCount),
images: String(stats.imageCount),
from: fmtDate(stats.firstTs),
to: fmtDate(stats.lastTs),
span,
};
if (typeof config.template === 'string' && config.template.length > 0) {
const rendered = config.template.replace(/\{(messages|participants|images|from|to|span)\}/g, (_, k) => values[k] || '');
return (0, jsx_runtime_1.jsx)("footer", { className: "dht-stats", children: rendered });
}
const dict = (i18n && i18n[lang]) || (i18n && i18n.en) || {};
const parts = [
(dict.statsMessages || '{n} messages').replace('{n}', values.messages),
(dict.statsParticipants || '{n} participants').replace('{n}', values.participants),
(dict.statsImages || '{n} images').replace('{n}', values.images),
];
if (values.from && values.to) parts.push(values.from + ' → ' + values.to);
if (values.span) parts.push(values.span);
return (0, jsx_runtime_1.jsx)("footer", { className: "dht-stats", children: parts.join(' · ') });
}
const extraStyles = `
/* Ensure the host establishes a positioning context for the absolute pin indicator,
and clamp it inside the message box on narrow viewports. */
/* Action-row buttons — Discord-like horizontal spacing */
discord-action-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:8px 0}
.dht-v2-container discord-action-row,.dht-section-content discord-action-row{margin:6px 0}
/* V2 container — Discord-like spacing inside block flow */
.dht-v2-container .dht-heading,.dht-section-content .dht-heading{margin:8px 0 4px}
.dht-v2-container .dht-heading:first-child,.dht-section-content .dht-heading:first-child{margin-top:0}
.dht-v2-container .dht-v2-separator,.dht-section-content .dht-v2-separator{margin:8px 0}
.dht-v2-container strong,.dht-section-content strong{display:inline;font-weight:700;color:#fff}
.dht-v2-container .dht-mention,.dht-section-content .dht-mention{display:inline-block;vertical-align:baseline}
/* System-message author — clickable like a user mention */
.dht-sys-author{cursor:pointer;font-style:normal;font-weight:500}
.dht-sys-author:hover{text-decoration:underline}
.dht-msg-pinned{position:relative}
.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}
.dht-edit-marker{color:#949ba4;font-size:11px;margin-left:4px;cursor:help}
.dht-suppressed{color:#6e727a;font-size:11px;font-style:italic}
.dht-stickers{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}
.dht-sticker{width:160px;height:160px;border-radius:8px;object-fit:contain;background:rgba(255,255,255,.02)}
.dht-poll{border:1px solid #2b2d31;background:#1e1f22;border-radius:8px;padding:12px 14px;margin-top:6px;max-width:480px}
.dht-poll-q{font-weight:700;margin-bottom:8px;font-size:15px}
.dht-poll-answers{display:flex;flex-direction:column;gap:6px}
.dht-poll-a{background:#2b2d31;border-radius:6px;padding:6px 10px;position:relative;overflow:hidden}
.dht-poll-bar{position:absolute;top:0;left:0;height:100%;background:rgba(88,101,242,.25);z-index:0}
.dht-poll-line{position:relative;z-index:1;display:flex;justify-content:space-between;gap:8px}
.dht-poll-pct{color:#b5bac1;font-size:12px;white-space:nowrap}
.dht-poll-foot{margin-top:8px;font-size:11px;color:#949ba4}
.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}
.dht-voice-wave svg{display:block}
.dht-voice-dur{color:#b5bac1;font-size:11px}
.dht-forwarded-wrap{margin:4px 0}
.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}
.dht-forwarded-head{font-size:12px;color:#949ba4;margin-bottom:2px}
.dht-forwarded-icon{margin-right:4px}
.dht-forwarded-body{color:#dbdee1}
.dht-forwarded-attachments{margin-top:4px;font-size:11px;color:#949ba4}
.dht-edit-history{font-size:12px;color:#b5bac1;margin-top:4px}
.dht-edit-history summary{cursor:pointer;color:#949ba4}
.dht-edit-history li{margin-top:4px}
.dht-edit-history time{display:block;color:#6e727a;font-size:10px;margin-bottom:2px}
.dht-thread-badge{display:inline-block;background:#3f4248;color:#dbdee1;font-size:10px;padding:1px 6px;border-radius:4px;margin-left:6px}
.dht-thread-badge--locked{background:#ED4245}
.dht-thread-badge--archived{background:#6c757d}
.dht-gifv{max-width:400px;max-height:300px;border-radius:6px}
.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}
.dht-mention--cmd{background:rgba(88,101,242,.22)!important}
.dht-embed-provider{color:#949ba4;font-size:11px;margin-bottom:2px}
.dht-embed-video{margin-top:6px;font-size:13px}
.dht-embed-video a{color:#8ab4ff;text-decoration:none}
.dht-embed-video a:hover{text-decoration:underline}
.dht-automod-rule{font-size:11px;color:#949ba4}
.dht-automod-content{margin:4px 0 0 12px;color:#dbdee1;border-left:3px solid #f23f42;padding-left:8px;font-size:13px}
.dht-reaction--burst{box-shadow:inset 0 0 0 1px gold}
`;
//# sourceMappingURL=index.js.map