// 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`; }