- web/: token-login, sessionslista, ANSI-skärmvy (SSE+poll), promptfält, frågekort, tangentrad, delningsflik (bild/HTML-sandbox/markdown), inställningar - go:embed via webfs.go — servern förblir en enda binär, strikt CSP - browser-verifierad mot riktig agy (fråga -> kort -> svar -> kört) - Dockerfile (alpine + tmux/bash) + helmd-image.yaml -> localhost:5000/helmd - integrationstest utökat till 29 gröna Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
79 lines
3.1 KiB
JavaScript
79 lines
3.1 KiB
JavaScript
// Minimal ANSI(SGR) -> HTML. Stödjer 16/256-färg, truecolor, bold/dim/
|
|
// invers/understruket. Allt annat (cursorstyrning m.m.) strippas —
|
|
// helmd skickar capture-pane-text, så bara färgkoder förekommer.
|
|
|
|
const BASE = [
|
|
"#1c1f26", "#e5605c", "#3fca7c", "#e0a63f",
|
|
"#4f9cf9", "#b877d9", "#3fc4c4", "#cfd6e4",
|
|
"#5b6472", "#ff7b76", "#5fe09a", "#ffc65c",
|
|
"#7bc4ff", "#d49df0", "#6ee0e0", "#ffffff",
|
|
];
|
|
|
|
function color256(n) {
|
|
n = Number(n);
|
|
if (n < 16) return BASE[n];
|
|
if (n < 232) {
|
|
const c = n - 16, v = [0, 95, 135, 175, 215, 255];
|
|
return `rgb(${v[(c / 36) | 0]},${v[((c / 6) | 0) % 6]},${v[c % 6]})`;
|
|
}
|
|
const g = 8 + (n - 232) * 10;
|
|
return `rgb(${g},${g},${g})`;
|
|
}
|
|
|
|
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
|
|
export function ansiToHtml(text) {
|
|
let out = "", open = false;
|
|
const st = { fg: null, bg: null, bold: false, dim: false, ul: false, inv: false };
|
|
|
|
const flushSpan = () => { if (open) { out += "</span>"; open = false; } };
|
|
const openSpan = () => {
|
|
const css = [];
|
|
let fg = st.fg, bg = st.bg;
|
|
if (st.inv) [fg, bg] = [bg || "#cfd6e4", fg || "#05070b"];
|
|
if (fg) css.push(`color:${fg}`);
|
|
if (bg) css.push(`background:${bg}`);
|
|
if (st.bold) css.push("font-weight:700");
|
|
if (st.dim) css.push("opacity:.6");
|
|
if (st.ul) css.push("text-decoration:underline");
|
|
if (css.length) { out += `<span style="${css.join(";")}">`; open = true; }
|
|
};
|
|
|
|
// dela på escape-sekvenser; behåll SGR, släng övriga
|
|
const parts = text.split(/(\x1b\[[0-9;:]*[A-Za-z])/);
|
|
for (const p of parts) {
|
|
if (!p) continue;
|
|
if (p.startsWith("\x1b[")) {
|
|
if (!p.endsWith("m")) continue; // icke-SGR: ignorera
|
|
const codes = p.slice(2, -1).split(/[;:]/).map((x) => (x === "" ? 0 : Number(x)));
|
|
for (let i = 0; i < codes.length; i++) {
|
|
const c = codes[i];
|
|
if (c === 0) { st.fg = st.bg = null; st.bold = st.dim = st.ul = st.inv = false; }
|
|
else if (c === 1) st.bold = true;
|
|
else if (c === 2) st.dim = true;
|
|
else if (c === 4) st.ul = true;
|
|
else if (c === 7) st.inv = true;
|
|
else if (c === 22) { st.bold = st.dim = false; }
|
|
else if (c === 24) st.ul = false;
|
|
else if (c === 27) st.inv = false;
|
|
else if (c >= 30 && c <= 37) st.fg = BASE[c - 30];
|
|
else if (c === 38 && codes[i + 1] === 5) { st.fg = color256(codes[i + 2]); i += 2; }
|
|
else if (c === 38 && codes[i + 1] === 2) { st.fg = `rgb(${codes[i + 2]},${codes[i + 3]},${codes[i + 4]})`; i += 4; }
|
|
else if (c === 39) st.fg = null;
|
|
else if (c >= 40 && c <= 47) st.bg = BASE[c - 40];
|
|
else if (c === 48 && codes[i + 1] === 5) { st.bg = color256(codes[i + 2]); i += 2; }
|
|
else if (c === 48 && codes[i + 1] === 2) { st.bg = `rgb(${codes[i + 2]},${codes[i + 3]},${codes[i + 4]})`; i += 4; }
|
|
else if (c === 49) st.bg = null;
|
|
else if (c >= 90 && c <= 97) st.fg = BASE[c - 90 + 8];
|
|
else if (c >= 100 && c <= 107) st.bg = BASE[c - 100 + 8];
|
|
}
|
|
flushSpan();
|
|
openSpan();
|
|
} else {
|
|
out += esc(p.replace(/\x1b./g, ""));
|
|
}
|
|
}
|
|
flushSpan();
|
|
return out;
|
|
}
|