/**
* Build-time helpers for CodeBlock: a JS/TS/JSON tokenizer and a unified
* line diff with word-level pieces.
*
* Adapted from Beautiful UI CodeBlock (MIT, © 2026 Shane Levine).
*/
export type Language = "ts" | "js" | "json" | "text";
export type TokenKind = "keyword" | "string" | "number" | "fn" | "key" | "comment";
export type Token = { text: string; kind?: TokenKind };
/** A run of code within a diff row; `change` marks a word-level add/del. */
export type CodePiece = { text: string; change?: "add" | "del" };
/** One row of a unified diff: old/new line numbers, its kind, and its pieces. */
export type DiffRow = {
old: number | null;
cur: number | null;
type: "ctx" | "add" | "del";
pieces: CodePiece[];
};
const JS_KEYWORDS = new Set([
"import", "from", "export", "default", "async", "function", "const", "let", "var", "await",
"return", "if", "else", "for", "while", "new", "throw", "try", "catch", "null", "true", "false",
"undefined", "type", "interface", "as", "of", "in", "class", "extends", "switch", "case", "break",
"continue", "this", "typeof", "satisfies",
]);
const JS_TOKEN =
/(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)|(\b\d+(?:\.\d+)?\b)|([A-Za-z_$][\w$]*)/g;
const JSON_TOKEN = /("(?:\\.|[^"\\\n])*")|(-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|\b(true|false|null)\b/g;
/** Infer a language from a filename extension. */
export function languageFor(filename = ""): Language {
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
if (ext === "json" || ext === "jsonc") return "json";
if (["js", "mjs", "cjs", "jsx"].includes(ext)) return "js";
return "ts";
}
/** Tokenize source text. Unmatched text becomes plain (kind-less) tokens. */
export function tokenize(text: string, language: Language = "ts"): Token[] {
if (language === "text") return [{ text }];
const out: Token[] = [];
let last = 0;
const push = (t: string, kind?: TokenKind) => {
if (!t) return;
const prev = out[out.length - 1];
if (!kind && prev && !prev.kind) prev.text += t;
else out.push({ text: t, kind });
};
const re = new RegExp(language === "json" ? JSON_TOKEN : JS_TOKEN);
for (const m of text.matchAll(re)) {
const idx = m.index ?? 0;
const rest = text.slice(idx + m[0].length);
push(text.slice(last, idx));
if (language === "json") {
if (m[1]) push(m[1], /^\s*:/.test(rest) ? "key" : "string");
else if (m[2]) push(m[2], "number");
else push(m[0], "keyword");
} else if (m[1]) push(m[1], "comment");
else if (m[2]) push(m[2], "string");
else if (m[3]) push(m[3], "number");
else if (m[4] && JS_KEYWORDS.has(m[4])) push(m[4], "keyword");
else if (m[4] && /^\s*\(/.test(rest)) push(m[4], "fn");
else push(m[0]);
last = idx + m[0].length;
}
push(text.slice(last));
return out;
}
/** Tokenize a whole listing, then split it into per-line token arrays
* (so block comments and template strings may span lines). */
export function tokenizeLines(text: string, language: Language = "ts"): Token[][] {
const lines: Token[][] = [[]];
for (const tok of tokenize(text, language)) {
tok.text.split("\n").forEach((part, i) => {
if (i > 0) lines.push([]);
if (part) lines[lines.length - 1].push({ text: part, kind: tok.kind });
});
}
return lines;
}
/* ── diff ── */
function lcsTable<T>(a: T[], b: T[]): number[][] {
const t = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0));
for (let i = a.length - 1; i >= 0; i--)
for (let j = b.length - 1; j >= 0; j--)
t[i][j] = a[i] === b[j] ? t[i + 1][j + 1] + 1 : Math.max(t[i + 1][j], t[i][j + 1]);
return t;
}
type Op<T> = { type: "ctx" | "add" | "del"; value: T; ai: number; bi: number };
function diffSeq<T>(a: T[], b: T[]): Op<T>[] {
const t = lcsTable(a, b);
const ops: Op<T>[] = [];
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) ops.push({ type: "ctx", value: a[i], ai: i++, bi: j++ });
else if (t[i + 1][j] >= t[i][j + 1]) ops.push({ type: "del", value: a[i], ai: i++, bi: -1 });
else ops.push({ type: "add", value: b[j], ai: -1, bi: j++ });
}
while (i < a.length) ops.push({ type: "del", value: a[i], ai: i++, bi: -1 });
while (j < b.length) ops.push({ type: "add", value: b[j], ai: -1, bi: j++ });
return ops;
}
const WORD = /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`[^`]*`|[\w$]+|\s+|[^\w$\s]/g;
/** Word-level pieces for a removed/added line pair. Returns null when the
* lines share too little to make a highlight meaningful. */
function wordPieces(before: string, after: string): [CodePiece[], CodePiece[]] | null {
const a = before.match(WORD) ?? [];
const b = after.match(WORD) ?? [];
const ops = diffSeq(a, b);
const common = ops.filter((o) => o.type === "ctx").reduce((n, o) => n + o.value.trim().length, 0);
const longest = Math.max(before.trim().length, after.trim().length, 1);
if (common / longest < 0.4) return null;
const build = (side: "del" | "add") => {
const pieces: CodePiece[] = [];
const kept = ops.filter((o) => o.type === "ctx" || o.type === side);
kept.forEach((o, k) => {
let changed = o.type === side;
// whitespace sandwiched between two changes joins the highlight
if (!changed && /^\s+$/.test(o.value) && kept[k - 1]?.type === side && kept[k + 1]?.type === side) changed = true;
const prev = pieces[pieces.length - 1];
const change = changed ? side : undefined;
if (prev && prev.change === change) prev.text += o.value;
else pieces.push({ text: o.value, change });
});
return pieces;
};
return [build("del"), build("add")];
}
/** Unified diff of two texts, with word-level highlights on paired lines. */
export function diffLines(before: string, after: string): DiffRow[] {
const ops = diffSeq(before.split("\n"), after.split("\n"));
const rows: DiffRow[] = [];
let k = 0;
while (k < ops.length) {
const op = ops[k];
if (op.type === "ctx") {
rows.push({ old: op.ai + 1, cur: op.bi + 1, type: "ctx", pieces: [{ text: op.value }] });
k++;
continue;
}
// a hunk: every consecutive del/add, rendered dels-then-adds
const dels: Op<string>[] = [];
const adds: Op<string>[] = [];
while (k < ops.length && ops[k].type !== "ctx") (ops[k].type === "del" ? dels : adds).push(ops[k++]);
const pairs = dels.map((d, i) => (adds[i] ? wordPieces(d.value, adds[i].value) : null));
dels.forEach((d, i) =>
rows.push({ old: d.ai + 1, cur: null, type: "del", pieces: pairs[i]?.[0] ?? [{ text: d.value }] }),
);
adds.forEach((a, i) =>
rows.push({ old: null, cur: a.bi + 1, type: "add", pieces: pairs[i]?.[1] ?? [{ text: a.value }] }),
);
}
return rows;
}