feat: label code fences so Vicinae can colour them (#10)

This commit was merged in pull request #10.
This commit is contained in:
2026-10-03 21:37:11 +00:00
parent c49ffeb274
commit ac2da73669
6 changed files with 358 additions and 1 deletions
+197
View File
@@ -0,0 +1,197 @@
/**
* Labels fenced code blocks the model left unlabelled.
*
* 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 this stays deliberately conservative: it only labels a block when there is
* a clear signal, and leaves anything ambiguous alone.
*/
/** 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",
]);
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;
}
const OPENING_FENCE = /^(\s*)(`{3,}|~{3,})\s*$/;
/**
* 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, fence] = opener;
const body: string[] = [];
index += 1;
while (index < lines.length && !lines[index].trimStart().startsWith(fence)) {
body.push(lines[index]);
index += 1;
}
const closing = index < lines.length ? lines[index] : "";
if (closing) index += 1;
const language = inferLanguage(body.join("\n"));
out.push(language ? `${indent}${fence}${language}` : openingLine);
out.push(...body);
if (closing) out.push(closing);
}
return out.join("\n");
}