Files
plainleaf/plug-api/lib/comments.ts
T

183 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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*(?<!-)(?:—|–|-{1,2})\s+(?:([^,]+?),\s+)?(\d{4}-\d{2}-\d{2})\s*$/;
function extractSig(
raw: string,
): { text: string; author?: string; date?: string } {
const sig = raw.match(signatureRe);
if (!sig) {
return { text: raw.trim() };
}
return {
text: sig[1].trim(),
...(sig[2] !== undefined ? { author: sig[2].trim() } : {}),
date: sig[3],
};
}
function hasSignature(raw: string): boolean {
return signatureRe.test(raw);
}
// A machine directive: its first non-whitespace token starts with `#` or
// `/` followed by a letter (`<!--#lua ...-->`, `<!--/lua-->`, 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\S]*?)-->\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 = `<!-- re: "${opts.quote}"\n`;
return { text: `${prefix}${sig}\n-->`, cursorOffset: prefix.length };
}
const prefix = `<!-- `;
return { text: `${prefix}${sig} -->`, 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,
};
}