export type CommentMessage = { addressee?: string; text: string; author?: string; date?: string; }; export type ParsedComment = { quote?: string; thread: CommentMessage[]; addressees: string[]; waitingOn?: string; lastDate?: string; }; const anchorRe = /^re:\s*["“”](.*)["“”]\s*$/; const messageRe = /^@([A-Za-z0-9_-]+):\s*(.*)$/; const signatureRe = /^(.*)\s*(?`, ``, and future // markers in the same family). Those are never comments, conforming or not. const directiveRe = /^\s*[#/][A-Za-z]/; export function parseCommentBlock(raw: string): ParsedComment | null { const m = raw.match(/^\s*$/); if (!m) { return null; } if (directiveRe.test(m[1])) { return null; } const lines = m[1].split("\n").map((l) => l.trim()).filter((l) => l.length > 0 ); if (lines.length === 0) { return null; } let quote: string | undefined; const thread: CommentMessage[] = []; // The message currently being accumulated, if any: its addressee (if // addressed), its raw joined text so far, and whether a signature has // already closed it (see hasSignature above). let current: { addressee?: string; raw: string; closed: boolean } | null = null; const flush = () => { if (current !== null) { thread.push({ ...(current.addressee !== undefined ? { addressee: current.addressee } : {}), ...extractSig(current.raw), }); current = null; } }; for (const [i, line] of lines.entries()) { if (i === 0) { const anchor = line.match(anchorRe); if (anchor) { quote = anchor[1]; continue; } } const msg = line.match(messageRe); if (msg) { flush(); current = { addressee: msg[1], raw: msg[2], closed: hasSignature(msg[2]) }; } else if (current !== null && !current.closed) { // Continuation of the message currently being accumulated current.raw = current.raw.length > 0 ? `${current.raw} ${line}` : line; current.closed = hasSignature(current.raw); } else { // A bare line with nothing open to continue -- either nothing has // started yet, or the last message was already closed by a // signature. Either way, this starts a new unaddressed message. flush(); current = { raw: line, closed: hasSignature(line) }; } } flush(); if (thread.length === 0) { return null; } const addressees = [ ...new Set( thread.map((t) => t.addressee).filter((a): a is string => a !== undefined), ), ]; const dates = thread.map((t) => t.date).filter((d): d is string => !!d); const waitingOn = thread[thread.length - 1].addressee; return { ...(quote !== undefined ? { quote } : {}), thread, addressees, ...(waitingOn !== undefined ? { waitingOn } : {}), ...(dates.length > 0 ? { lastDate: dates[dates.length - 1] } : {}), }; } export function sanitizeQuote(s: string): string { return s.replace(/\s+/g, " ").replace(/"/g, "'").replace(/--/g, "–") .trim().slice(0, 80); } export function buildCommentScaffold( opts: { quote?: string; author?: string; date: string }, ): { text: string; cursorOffset: number } { // A signature is always present, even without a configured author, so a // scaffold with no quote still carries a recognizable marker. const sig = opts.author ? ` — ${opts.author}, ${opts.date}` : ` — ${opts.date}`; // Generated comments are left-aligned (no indentation), and once a block // spans multiple lines the closing `-->` gets its own line. A quoted // scaffold is multi-line (the `re:` line plus the message line), so its // closer moves to a third line; a quote-less scaffold stays a single line // with an inline closer. if (opts.quote) { const prefix = ``, cursorOffset: prefix.length }; } const prefix = ``, cursorOffset: prefix.length }; } export function computeCommentInsertion( docText: string, selFrom: number, selTo: number, opts: { author?: string; date: string }, ): { insertAt: number; text: string; cursorPos: number } { // End of the block containing selTo: scan forward line by line until a // blank line or EOF; insert at the end of the last non-blank line. let insertAt = selTo; while (insertAt < docText.length) { const nl = docText.indexOf("\n", insertAt); const lineEnd = nl === -1 ? docText.length : nl; const nextLineStart = nl === -1 ? docText.length : nl + 1; const nextNl = docText.indexOf("\n", nextLineStart); const nextLineEnd = nextNl === -1 ? docText.length : nextNl; insertAt = lineEnd; const nextLine = docText.slice(nextLineStart, nextLineEnd); if (nextLineStart >= docText.length || nextLine.trim() === "") { break; } insertAt = nextLineStart; } const quote = sanitizeQuote(docText.slice(selFrom, selTo)); const scaffold = buildCommentScaffold({ ...(quote.length > 0 ? { quote } : {}), ...(opts.author ? { author: opts.author } : {}), date: opts.date, }); const text = `\n${scaffold.text}`; return { insertAt, text, cursorPos: insertAt + 1 + scaffold.cursorOffset, }; }