471 lines
14 KiB
TypeScript
471 lines
14 KiB
TypeScript
/**
|
|
* Fixups for code the model wrote as markdown.
|
|
*
|
|
* Vicinae renders fences through `MdCodeBlock.qml`, which gives a dark panel, a
|
|
* header showing the language, a Copy button, and syntax colouring resolved by
|
|
* KSyntaxHighlighting. That last part is why the label matters beyond
|
|
* cosmetics: `syntax::highlight` looks the language up with
|
|
* `definitionForName(language)` and then `definitionForFileName("file." + language)`,
|
|
* so `ts` resolves through `file.ts` and `bash` through its definition name,
|
|
* while an unrecognised string such as `terminal` still renders as a label but
|
|
* produces no highlighting at all. A wrong label is therefore worse than none,
|
|
* and inference stays deliberately conservative.
|
|
*/
|
|
|
|
/** First words that mark a block as shell rather than a programming language. */
|
|
const SHELL_COMMANDS = new Set([
|
|
"apt",
|
|
"brew",
|
|
"bun",
|
|
"cat",
|
|
"cd",
|
|
"chmod",
|
|
"chown",
|
|
"code",
|
|
"cp",
|
|
"curl",
|
|
"df",
|
|
"docker",
|
|
"du",
|
|
"echo",
|
|
"export",
|
|
"find",
|
|
"gh",
|
|
"git",
|
|
"grep",
|
|
"htop",
|
|
"jq",
|
|
"kill",
|
|
"kubectl",
|
|
"ln",
|
|
"ls",
|
|
"make",
|
|
"man",
|
|
"mkdir",
|
|
"mv",
|
|
"nano",
|
|
"node",
|
|
"npm",
|
|
"npx",
|
|
"open",
|
|
"pacman",
|
|
"pip",
|
|
"pnpm",
|
|
"ps",
|
|
"psql",
|
|
"pwd",
|
|
"python",
|
|
"rm",
|
|
"rsync",
|
|
"scp",
|
|
"sed",
|
|
"source",
|
|
"ssh",
|
|
"sudo",
|
|
"systemctl",
|
|
"tar",
|
|
"top",
|
|
"touch",
|
|
"unzip",
|
|
"vim",
|
|
"wget",
|
|
"which",
|
|
"yarn",
|
|
"zip",
|
|
]);
|
|
|
|
const FENCE = "```";
|
|
/**
|
|
* An opening fence, with an optional language.
|
|
*
|
|
* The language has to be part of this pattern. A closing ``` and an opening
|
|
* bare ``` are the same two lines apart from context, so a pattern that only
|
|
* matched bare fences treated every closing fence as a new opening one and
|
|
* swallowed the rest of the answer.
|
|
*/
|
|
const OPENING_FENCE = /^(\s*)(`{3,}|~{3,})\s*([^\s`~]*)\s*$/;
|
|
/** A closing fence is the marker and nothing else. */
|
|
const CLOSING_FENCE = /^\s*(`{3,}|~{3,})\s*$/;
|
|
const STANDALONE_CODE_SPAN = /^\s*`([^`\n]+)`\s*[.!]?\s*$/;
|
|
|
|
/**
|
|
* A line that is nothing but a bare http(s) URL, give or take a bullet.
|
|
*
|
|
* Vicinae only makes a URL clickable when markdown presents it as a link. Its
|
|
* parser attaches the `table` and `strikethrough` GFM extensions and nothing
|
|
* else, so the `autolink` extension is off: a bare URL renders as plain dead
|
|
* text. `MarkdownModel` turns `CMARK_NODE_LINK` into `<a href=...>`, and the
|
|
* selection controller opens that anchor on release, so `[label](url)` is the
|
|
* one form the user can actually click.
|
|
*/
|
|
const BARE_URL = /^https?:\/\/\S+$/;
|
|
/** Sentence punctuation a model appends to a URL that is not part of it. */
|
|
const TRAILING_SENTENCE_PUNCTUATION = /[.,;:!?]+$/;
|
|
/** One inline code span that holds nothing but a URL. */
|
|
const STANDALONE_URL_SPAN = /^`([^`\n]+)`[.!]?$/;
|
|
/** A bullet or numbered-list marker, kept so a list of links stays a list. */
|
|
const LIST_MARKER = /^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)(.*)$/;
|
|
|
|
/** A GFM table row: it opens and closes with a pipe. */
|
|
const TABLE_ROW = /^\s*\|.*\|\s*$/;
|
|
/**
|
|
* The delimiter row under a header.
|
|
*
|
|
* The test is not how many dashes there are, because GFM wants only one per
|
|
* column. It is that the line could be nothing else: pipes, dashes, colons and
|
|
* spaces, with at least one dash to tell it apart from a row of empty cells.
|
|
*/
|
|
const TABLE_DELIMITER_ROW = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
|
|
|
|
function significantLines(code: string): string[] {
|
|
return code
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.length > 0 && !line.startsWith("#") && !line.startsWith("//"));
|
|
}
|
|
|
|
function looksLikeShell(code: string): boolean {
|
|
const lines = significantLines(code);
|
|
if (lines.length === 0) return false;
|
|
|
|
// An explicit prompt marker, or a leading `~` path, is about as clear as it gets.
|
|
if (lines.some((line) => /^[$>] /.test(line) || line.startsWith("~/"))) return true;
|
|
|
|
// A shell command as the first word of the first meaningful line.
|
|
const [firstWord] = lines[0].split(/\s+/);
|
|
if (SHELL_COMMANDS.has(firstWord)) return true;
|
|
|
|
// Failing that, a pipeline or chained command between two known commands.
|
|
if (/[|&]{1,2}/.test(code)) {
|
|
return lines.some((line) => {
|
|
const [word] = line.split(/\s+/);
|
|
return SHELL_COMMANDS.has(word.replace(/[|&].*$/, ""));
|
|
});
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function looksLikeJson(code: string): boolean {
|
|
const trimmed = code.trim();
|
|
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false;
|
|
|
|
try {
|
|
const parsed: unknown = JSON.parse(trimmed);
|
|
return typeof parsed === "object" && parsed !== null;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** TypeScript-specific syntax. Plain JS is left unlabelled rather than guessed at. */
|
|
function looksLikeTypeScript(code: string): boolean {
|
|
return (
|
|
/\binterface\s+\w+\s*\{/.test(code) ||
|
|
/\btype\s+\w+\s*=/.test(code) ||
|
|
/\bas\s+const\b/.test(code) ||
|
|
/:\s*(string|number|boolean|void|unknown|never)\b/.test(code) ||
|
|
/<\w+>(\s*\(|\s*=|\s*=>)/.test(code) ||
|
|
/\b(implements|readonly)\s+/.test(code)
|
|
);
|
|
}
|
|
|
|
function looksLikePython(code: string): boolean {
|
|
return (
|
|
/^\s*def\s+\w+\s*\(/m.test(code) ||
|
|
/^\s*from\s+[\w.]+\s+import\s+/m.test(code) ||
|
|
/^\s*import\s+\w+$/m.test(code) ||
|
|
/\bself\./.test(code) ||
|
|
/^\s*(async\s+)?def\s+/m.test(code)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Best-effort language tag, or `undefined` when nothing is clear enough.
|
|
* Order matters: a shell pipeline can contain what look like code fragments.
|
|
*/
|
|
export function inferLanguage(code: string): string | undefined {
|
|
if (code.trim().length === 0) return undefined;
|
|
|
|
if (looksLikeShell(code)) return "bash";
|
|
if (looksLikeJson(code)) return "json";
|
|
if (looksLikeTypeScript(code)) return "ts";
|
|
if (looksLikePython(code)) return "python";
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/** Copies a fenced block through verbatim, returning the index just past it. */
|
|
function copyFence(lines: string[], out: string[], start: number): number {
|
|
let index = start;
|
|
|
|
out.push(lines[index]);
|
|
index += 1;
|
|
|
|
while (index < lines.length && !CLOSING_FENCE.test(lines[index])) {
|
|
out.push(lines[index]);
|
|
index += 1;
|
|
}
|
|
|
|
if (index < lines.length) {
|
|
out.push(lines[index]);
|
|
index += 1;
|
|
}
|
|
|
|
return index;
|
|
}
|
|
|
|
/**
|
|
* Turns a paragraph that is nothing but one inline code span into a fence.
|
|
*
|
|
* Models reliably fence multi-line code and just as reliably answer "what is the
|
|
* command for X" with a lone `` `pwd` ``. A single inline chip is the worst
|
|
* presentation for that answer: no Copy button, no language, no colouring. The
|
|
* rule is narrow on purpose, a whole line that is exactly one span, so
|
|
* ``run `pwd` to check`` is left alone while a standalone command is promoted.
|
|
*
|
|
* Existing fences are copied through untouched, so a span-like line inside a
|
|
* code block is never rewritten.
|
|
*/
|
|
export function promoteStandaloneCodeSpans(markdown: string): string {
|
|
const lines = markdown.split("\n");
|
|
const out: string[] = [];
|
|
let index = 0;
|
|
|
|
while (index < lines.length) {
|
|
const fence = OPENING_FENCE.exec(lines[index]);
|
|
|
|
if (fence) {
|
|
index = copyFence(lines, out, index);
|
|
continue;
|
|
}
|
|
|
|
const span = STANDALONE_CODE_SPAN.exec(lines[index]);
|
|
if (span) {
|
|
const code = span[1].trim();
|
|
out.push(FENCE + (inferLanguage(code) ?? ""), code, FENCE);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
out.push(lines[index]);
|
|
index += 1;
|
|
}
|
|
|
|
return out.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Adds a language to fences that have none, leaving labelled fences untouched.
|
|
*
|
|
* Only bare opening fences are rewritten, so the model's own choice always wins.
|
|
*/
|
|
export function labelCodeFences(markdown: string): string {
|
|
const lines = markdown.split("\n");
|
|
const out: string[] = [];
|
|
let index = 0;
|
|
|
|
while (index < lines.length) {
|
|
const openingLine = lines[index];
|
|
const opener = OPENING_FENCE.exec(openingLine);
|
|
|
|
if (!opener) {
|
|
out.push(openingLine);
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
const [, indent, marker, existing] = opener;
|
|
const body: string[] = [];
|
|
index += 1;
|
|
|
|
while (index < lines.length && !CLOSING_FENCE.test(lines[index])) {
|
|
body.push(lines[index]);
|
|
index += 1;
|
|
}
|
|
|
|
const closing = index < lines.length ? lines[index] : "";
|
|
if (closing) index += 1;
|
|
|
|
// A fence the model already labelled is left exactly as written.
|
|
if (existing) out.push(openingLine);
|
|
else {
|
|
const language = inferLanguage(body.join("\n"));
|
|
out.push(language ? `${indent}${marker}${language}` : openingLine);
|
|
}
|
|
|
|
out.push(...body);
|
|
if (closing) out.push(closing);
|
|
}
|
|
|
|
return out.join("\n");
|
|
}
|
|
|
|
/** A URL with the sentence punctuation after it removed. */
|
|
function asBareUrl(candidate: string): string | undefined {
|
|
const trimmed = candidate.trim();
|
|
if (!BARE_URL.test(trimmed)) return undefined;
|
|
return trimmed.replace(TRAILING_SENTENCE_PUNCTUATION, "");
|
|
}
|
|
|
|
/**
|
|
* Wraps a URL as a markdown link, using the address itself as the label so the
|
|
* destination stays readable rather than turning into a generic word.
|
|
*
|
|
* A destination containing parentheses (plenty of Wikipedia articles) has to
|
|
* be wrapped in pointy brackets or the parser reads the first `)` as the end of
|
|
* the destination and leaves the rest as trailing prose.
|
|
*/
|
|
function asLink(url: string): string {
|
|
// Square brackets in the *label* would end the link text early, which is why
|
|
// an IPv6 literal such as `http://[::1]/` needs them escaped. The destination
|
|
// tolerates brackets, but not parentheses, which is what the pointy form is
|
|
// for.
|
|
const label = url.replace(/[[\]]/g, "\\$&");
|
|
const destination = /[()]/.test(url) ? `<${url}>` : url;
|
|
return `[${label}](${destination})`;
|
|
}
|
|
|
|
/**
|
|
* The URLs of a fence whose every non-empty line is one, or `undefined` when
|
|
* the block is real code.
|
|
*
|
|
* Deliberately narrow: one line that is not a URL disqualifies the whole block,
|
|
* so a `curl` invocation that happens to contain an address is never touched.
|
|
*/
|
|
function urlOnlyBlock(lines: string[]): string[] | undefined {
|
|
const urls: string[] = [];
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (trimmed.length === 0) continue;
|
|
|
|
const url = asBareUrl(trimmed);
|
|
if (!url) return undefined;
|
|
urls.push(url);
|
|
}
|
|
|
|
return urls.length > 0 ? urls : undefined;
|
|
}
|
|
|
|
/** Rewrites a line that is only a URL, or only a span holding one, into a link. */
|
|
function linkifyLine(line: string): string {
|
|
const marker = LIST_MARKER.exec(line);
|
|
const prefix = marker ? marker[1] : (line.match(/^[ \t]*/)?.[0] ?? "");
|
|
const rest = marker ? marker[2] : line.slice(prefix.length);
|
|
|
|
const span = STANDALONE_URL_SPAN.exec(rest.trim());
|
|
const url = asBareUrl(span ? span[1] : rest);
|
|
|
|
return url ? `${prefix}${asLink(url)}` : line;
|
|
}
|
|
|
|
/**
|
|
* Turns URLs the model wrote as code back into links.
|
|
*
|
|
* This is the reported bug: asked for a few GitHub projects, the model answers
|
|
* with a fenced block of bare addresses. A fence renders through
|
|
* `MdCodeBlock.qml` as a dark panel with a Copy button and no link handling at
|
|
* all, so the one thing the user wants to do with a URL is the one thing they
|
|
* cannot. The same applies to a lone `` `https://...` `` span.
|
|
*
|
|
* Three shapes are recovered, and only these:
|
|
*
|
|
* 1. A fence whose entire body is URLs.
|
|
* 2. A line that is exactly one inline span holding a URL.
|
|
* 3. A line that is exactly a URL, bulleted or not.
|
|
*
|
|
* Existing fences holding anything else are copied through untouched, so real
|
|
* code is never rewritten.
|
|
*/
|
|
export function restoreUrlLinks(markdown: string): string {
|
|
const lines = markdown.split("\n");
|
|
const out: string[] = [];
|
|
let index = 0;
|
|
|
|
while (index < lines.length) {
|
|
if (!OPENING_FENCE.test(lines[index])) {
|
|
out.push(linkifyLine(lines[index]));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
const body: string[] = [];
|
|
let cursor = index + 1;
|
|
while (cursor < lines.length && !CLOSING_FENCE.test(lines[cursor])) {
|
|
body.push(lines[cursor]);
|
|
cursor += 1;
|
|
}
|
|
|
|
const closing = cursor < lines.length;
|
|
const urls = urlOnlyBlock(body);
|
|
|
|
if (urls) out.push(...urls.map(asLink));
|
|
else {
|
|
out.push(lines[index], ...body);
|
|
if (closing) out.push(lines[cursor]);
|
|
}
|
|
|
|
index = cursor + (closing ? 1 : 0);
|
|
}
|
|
|
|
return out.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Inserts the blank line a table needs to start at all.
|
|
*
|
|
* A GFM table cannot interrupt a paragraph, so a table written straight after a
|
|
* line of prose is not a table: it renders as a paragraph of pipes and dashes,
|
|
* which is how a perfectly good answer turns into noise. The blank line looks
|
|
* unnecessary, so models leave it out.
|
|
*
|
|
* Only a real table start is touched, which is a row followed by a delimiter
|
|
* row, so prose that happens to contain a pipe is left alone. Fences are copied
|
|
* through untouched for the same reason, since a table shown as example code
|
|
* must stay exactly as written.
|
|
*/
|
|
export function separateTables(markdown: string): string {
|
|
const lines = markdown.split("\n");
|
|
const out: string[] = [];
|
|
let index = 0;
|
|
|
|
while (index < lines.length) {
|
|
if (OPENING_FENCE.test(lines[index])) {
|
|
index = copyFence(lines, out, index);
|
|
continue;
|
|
}
|
|
|
|
const startsTable =
|
|
TABLE_ROW.test(lines[index]) && index + 1 < lines.length && TABLE_DELIMITER_ROW.test(lines[index + 1]);
|
|
|
|
// Only when something would otherwise swallow it. A table already at a block
|
|
// boundary, after a blank line or after a fenced block, needs nothing: the
|
|
// fence has already ended the preceding block.
|
|
const previous = out[out.length - 1];
|
|
const atBlockBoundary =
|
|
previous === undefined || previous.trim().length === 0 || CLOSING_FENCE.test(previous);
|
|
|
|
if (startsTable && !atBlockBoundary) out.push("");
|
|
|
|
out.push(lines[index]);
|
|
index += 1;
|
|
}
|
|
|
|
return out.join("\n");
|
|
}
|
|
|
|
/**
|
|
* The full pre-render pass, in order.
|
|
*
|
|
* Tables are separated first because they change block boundaries: a blank line
|
|
* has to exist before anything else looks at the line. Links are restored next
|
|
* so a recovered URL leaves no span for the promoter to fence again. Promotion
|
|
* then runs before labelling, so a promoted block is seen as an ordinary fence
|
|
* by the labeller, which is also harmless when promotion already inferred a
|
|
* language.
|
|
*/
|
|
export function repairCodeBlocks(markdown: string): string {
|
|
return labelCodeFences(promoteStandaloneCodeSpans(restoreUrlLinks(separateTables(markdown))));
|
|
}
|