97 lines · 3.4 KB · TypeScript
RawDownload
// 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, "&amp;")    .replace(/</g, "&lt;")    .replace(/>/g, "&gt;")    .replace(/"/g, "&quot;");} 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, "<br />");        })        .join("");      return inner ? `<p>${inner}</p>` : "";    })    .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`;}