b7f3061be3
- Tauri 2 + Svelte 5 hub in ODIAN dark: patch notes and roadmap, install and update progress, local stack status, settings with DHP and MCP addresses. - crates/odian-packs: Rust installer for pack format v1 (signed manifests, resumable verified downloads from a DHP or an ODIAN drive), byte-compatible with ODIAN_MCP_Server's Python odian_packs. - Local stack on 127.0.0.1: Postgres -> Engine API -> tile server -> Odian.exe, one Job Object, clean shutdown; database snapshot restore on a fresh PC only. - Keycloak sign-in through the DHP's MCP, remembered until Sign out with a launcher session that renews the Engine token silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
85 lines
2.7 KiB
TypeScript
85 lines
2.7 KiB
TypeScript
// Patch notes come from the site (or its cached copy) and are untrusted:
|
|
// Markdown is rendered with marked, then every byte of HTML goes through
|
|
// DOMPurify with a small allowlist before it reaches {@html}.
|
|
import { Marked, type Token, type TokensList } from "marked";
|
|
import DOMPurify from "dompurify";
|
|
|
|
const marked = new Marked({ gfm: true, breaks: false, async: false });
|
|
|
|
const ALLOWED_TAGS = [
|
|
"h1", "h2", "h3", "h4", "h5", "h6",
|
|
"p", "br", "hr", "blockquote",
|
|
"ul", "ol", "li",
|
|
"strong", "em", "b", "i", "del", "s", "code", "pre", "kbd", "sub", "sup",
|
|
"a",
|
|
"table", "thead", "tbody", "tr", "th", "td",
|
|
"input", // GFM task-list checkboxes (forced disabled below)
|
|
];
|
|
|
|
const ALLOWED_ATTR = ["href", "title", "align", "start", "type", "checked", "disabled"];
|
|
|
|
const SAFE_HREF = /^(https?:|mailto:|#)/i;
|
|
|
|
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
|
if (node.tagName === "A") {
|
|
const href = node.getAttribute("href") ?? "";
|
|
if (!SAFE_HREF.test(href.trim())) node.removeAttribute("href");
|
|
node.removeAttribute("target");
|
|
node.setAttribute("rel", "noopener noreferrer");
|
|
if (href && !node.getAttribute("title")) node.setAttribute("title", href);
|
|
}
|
|
if (node.tagName === "INPUT") {
|
|
if (node.getAttribute("type") !== "checkbox") node.remove();
|
|
else node.setAttribute("disabled", "");
|
|
}
|
|
});
|
|
|
|
function sanitize(html: string): string {
|
|
return DOMPurify.sanitize(html, {
|
|
ALLOWED_TAGS,
|
|
ALLOWED_ATTR,
|
|
ALLOW_DATA_ATTR: false,
|
|
ALLOW_ARIA_ATTR: false,
|
|
});
|
|
}
|
|
|
|
function render(tokens: Token[], links: TokensList["links"]): string {
|
|
const list = Object.assign(tokens, { links }) as TokensList;
|
|
return sanitize(marked.parser(list));
|
|
}
|
|
|
|
function isRoadmapHeading(token: Token): boolean {
|
|
return token.type === "heading" && token.depth === 2 && /^roadmap$/i.test(token.text.trim());
|
|
}
|
|
|
|
export interface RenderedNotes {
|
|
/** Everything except the Roadmap section. */
|
|
notes: string;
|
|
/** Contents under `## Roadmap` (heading excluded), or null when absent/empty. */
|
|
roadmap: string | null;
|
|
}
|
|
|
|
export function renderNotes(markdown: string): RenderedNotes {
|
|
const tokens = marked.lexer(markdown);
|
|
const notes: Token[] = [];
|
|
const roadmap: Token[] = [];
|
|
let inRoadmap = false;
|
|
let sawRoadmap = false;
|
|
|
|
for (const token of tokens) {
|
|
if (isRoadmapHeading(token)) {
|
|
inRoadmap = true;
|
|
sawRoadmap = true;
|
|
continue;
|
|
}
|
|
if (inRoadmap && token.type === "heading" && token.depth <= 2) inRoadmap = false;
|
|
(inRoadmap ? roadmap : notes).push(token);
|
|
}
|
|
|
|
const hasRoadmapContent = roadmap.some((t) => t.type !== "space");
|
|
return {
|
|
notes: render(notes, tokens.links),
|
|
roadmap: sawRoadmap && hasRoadmapContent ? render(roadmap, tokens.links) : null,
|
|
};
|
|
}
|