m's dogfood findings on the inline drawer: 1. Assistant responses (markdown headings, bold, lists, [chip:nav:…] tokens, [#deadline-OPEN:<id>] tokens) showed as raw text. The widget was setting body.textContent and skipping the renderer. Extracted the standalone /paliadin page's pipeline into client/paliadin-render.ts (renderResponseHTML + chip helpers + block markdown parser) so both surfaces share one source of truth. The widget now feeds assistant bubbles through innerHTML; user bubbles still go through textContent (no point parsing the user's typed markup). 2. Floating trigger button rendered the sparkle glyph in white-on-lime in dark mode — color: var(--color-text) inherits the dark-mode light foreground and washes out completely on a lime background. Lime is inherently a light-background colour, so the trigger pins its foreground to --hlc-midnight in both themes. Bubble CSS additions: assistant bubbles get white-space: normal (the base pre-wrap rule was forcing every source newline to a literal break and breaking <p>/<h2>/<ul> spacing) plus tight h2/h3/p/ul margins so the rendered markdown reads as a chat bubble, not a doc page.
135 lines
3.9 KiB
TypeScript
135 lines
3.9 KiB
TypeScript
// Shared Paliadin response renderer — used by both the standalone
|
|
// /paliadin page (client/paliadin.ts) and the inline drawer widget
|
|
// (client/paliadin-widget.ts). Extracted from paliadin.ts so the
|
|
// widget renders the same markdown + chips as the dedicated page
|
|
// without re-implementing the pipeline.
|
|
|
|
const CHIP_RE = /\[(?:#([a-z]+)-OPEN:([A-Za-z0-9\-_]+)|chip:([a-z]+):([^\]]+))\]/g;
|
|
const MD_LINK_RE = /\[([^\]\n]+)\]\(((?:https?:\/\/|\/)[^\s)]+)\)/g;
|
|
const BARE_URL_RE = /(^|[^"=>])(https?:\/\/[^\s<>"']+)/g;
|
|
|
|
function chipURL(kind: string, id: string): string {
|
|
switch (kind) {
|
|
case "deadline":
|
|
case "frist":
|
|
return "/deadlines/" + id;
|
|
case "projekt":
|
|
case "project":
|
|
return "/projects/" + id;
|
|
case "termin":
|
|
case "appointment":
|
|
return "/appointments/" + id;
|
|
default:
|
|
return "#";
|
|
}
|
|
}
|
|
|
|
function chipLabel(kind: string): string {
|
|
switch (kind) {
|
|
case "deadline":
|
|
case "frist":
|
|
return "Frist öffnen";
|
|
case "projekt":
|
|
case "project":
|
|
return "Akte ansehen";
|
|
case "termin":
|
|
case "appointment":
|
|
return "Termin öffnen";
|
|
default:
|
|
return "öffnen";
|
|
}
|
|
}
|
|
|
|
function renderBlocks(escapedHtml: string): string {
|
|
const out: string[] = [];
|
|
let listItems: string[] = [];
|
|
let paraLines: string[] = [];
|
|
|
|
const flushList = () => {
|
|
if (listItems.length === 0) return;
|
|
out.push(`<ul class="paliadin-list">${listItems.map((li) => `<li>${li}</li>`).join("")}</ul>`);
|
|
listItems = [];
|
|
};
|
|
const flushPara = () => {
|
|
if (paraLines.length === 0) return;
|
|
out.push(`<p>${paraLines.join("<br>")}</p>`);
|
|
paraLines = [];
|
|
};
|
|
|
|
for (const rawLine of escapedHtml.split("\n")) {
|
|
const line = rawLine.trim();
|
|
if (line === "") {
|
|
flushList();
|
|
flushPara();
|
|
continue;
|
|
}
|
|
let m: RegExpMatchArray | null;
|
|
if ((m = line.match(/^###\s+(.+)$/))) {
|
|
flushList();
|
|
flushPara();
|
|
out.push(`<h3>${m[1]}</h3>`);
|
|
} else if ((m = line.match(/^##\s+(.+)$/))) {
|
|
flushList();
|
|
flushPara();
|
|
out.push(`<h2>${m[1]}</h2>`);
|
|
} else if ((m = line.match(/^[-*]\s+(.+)$/))) {
|
|
flushPara();
|
|
listItems.push(m[1]);
|
|
} else if (line.match(/^---+$/)) {
|
|
flushList();
|
|
flushPara();
|
|
out.push(`<hr>`);
|
|
} else {
|
|
flushList();
|
|
paraLines.push(line);
|
|
}
|
|
}
|
|
flushList();
|
|
flushPara();
|
|
return out.join("");
|
|
}
|
|
|
|
export function renderResponseHTML(raw: string): string {
|
|
let html = raw
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
|
|
const chipHTML: string[] = [];
|
|
html = html.replace(CHIP_RE, (_match, kind, id, chipKind, chipArg) => {
|
|
let rendered = "";
|
|
if (kind && id) {
|
|
const url = chipURL(kind, id);
|
|
const label = chipLabel(kind);
|
|
rendered = `<a class="paliadin-chip" href="${url}">${label}</a>`;
|
|
} else if (chipKind === "nav") {
|
|
rendered = `<a class="paliadin-chip" href="${chipArg}">öffnen</a>`;
|
|
} else if (chipKind === "filter") {
|
|
rendered = `<a class="paliadin-chip" href="/inbox?${chipArg}">Filter anwenden</a>`;
|
|
}
|
|
if (!rendered) return "";
|
|
chipHTML.push(rendered);
|
|
return `CHIP${chipHTML.length - 1}`;
|
|
});
|
|
|
|
html = renderBlocks(html);
|
|
|
|
html = html.replace(MD_LINK_RE, (_m, text, url) => {
|
|
const ext = url.startsWith("http");
|
|
const attrs = ext ? ` target="_blank" rel="noopener noreferrer"` : "";
|
|
return `<a href="${url}" class="paliadin-link"${attrs}>${text}</a>`;
|
|
});
|
|
|
|
html = html.replace(BARE_URL_RE, (_m, prefix, url) => {
|
|
return `${prefix}<a href="${url}" class="paliadin-link" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
|
});
|
|
|
|
html = html.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
|
|
html = html.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
|
|
|
|
html = html.replace(/CHIP(\d+)/g, (_m, idx) => chipHTML[Number(idx)] || "");
|
|
|
|
return html;
|
|
}
|