feat: label code fences so Vicinae can colour them (#10)
This commit was merged in pull request #10.
This commit is contained in:
+197
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user