// Renders a comment's stored plain text to HTML, on the server.
//
// Comments are stored as PLAIN TEXT, not HTML - a deliberate difference from
// wilhelm-scream-db, which stores sanitized rich-text HTML from a contentEditable
// editor. On a site about mathematics the thing people need in a comment is
// math, not bold and underline, so comments go through the same KaTeX path as
// entry statements and support `$inline$` / `$$display$$`.
//
// The security model follows from that: nothing the user writes is ever treated
// as markup. Every non-math segment is HTML-escaped, and KaTeX runs with its
// default `trust: false`, which disables \href and \includegraphics. So there is
// no sanitizer to get wrong - the text simply never reaches the DOM as HTML.
import katex from "katex";
import { linkifyEscaped } from "@/lib/linkify";
import {
TEX_TOKENS,
isDisplayMath,
isInlineMath,
mathBody,
unescapeDollars,
} from "@/lib/tex-tokens";
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
function renderMath(tex: string, display: boolean): string {
return katex.renderToString(tex, {
throwOnError: false,
displayMode: display,
});
}
/// Plain text in, safe HTML out. Blank lines separate paragraphs; single
/// newlines become line breaks.
export function renderCommentHtml(text: string): string {
return text
.split(/\n{2,}/)
.map((paragraph) => {
const parts = paragraph.split(TEX_TOKENS);
const inner = parts
.map((part) => {
if (isDisplayMath(part)) return renderMath(mathBody(part), true);
if (isInlineMath(part)) return renderMath(mathBody(part), false);
const linked = linkifyEscaped(escapeHtml(part));
return unescapeDollars(linked).replace(/\n/g, "
");
})
.join("");
return inner ? `
${inner}
` : ""; }) .join(""); } /// Stable, locale-independent date for a comment. Formatted on the server and /// shipped as a string so it cannot cause a hydration mismatch. export function formatCommentDate(d: Date): string { const day = String(d.getUTCDate()).padStart(2, "0"); const months = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", ]; return `${day} ${months[d.getUTCMonth()]} ${d.getUTCFullYear()}`; } /// The same date with the time on it, for anything that reads as a /// conversation. /// /// A date alone is enough to place an entry in the record, but it is not /// enough to follow an exchange: several comments a day is normal on an active /// thread, and "04 Aug 2026" three times in a row says nothing about what /// answered what. The admin queues had already worked this out and were each /// hand-assembling this string; they call this now instead. /// /// UTC, and labelled as such, for the same reason the date is: the value is /// formatted on the server and shipped as a string, so it cannot depend on the /// reader's timezone without risking a hydration mismatch. An explicit "UTC" /// beats a time that is silently somebody else's. export function formatCommentDateTime(d: Date): string { const hh = String(d.getUTCHours()).padStart(2, "0"); const mm = String(d.getUTCMinutes()).padStart(2, "0"); return `${formatCommentDate(d)}, ${hh}:${mm} UTC`; }