T-HUB/packages/safe-content/src/html.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

269 lines
9.6 KiB
TypeScript

import type { EmojiNode, RichTextDocument, TextNode } from "./ast.js";
import { DocBuilder, type ParseOptions } from "./builder.js";
import { decodeEntities } from "./entities.js";
/*
* Converts the Mastodon/PeerTube HTML subset into the rich-text AST. This is
* a converter, not an HTML renderer: no tag or attribute survives as markup.
* Recognised structure (p, br, a, Mastodon mention/hashtag/invisible/ellipsis
* spans) maps to AST nodes; dangerous containers are dropped with their
* content; every other tag is unwrapped to its text. The tokenizer is a single
* forward scan (no regex backtracking), so huge or malformed input is linear.
*/
/** Element content parsed as raw text by browsers; skipped up to the matching end tag. */
const RAW_TEXT = new Set(["script", "style", "textarea", "title", "xmp", "iframe", "noembed", "noframes", "noscript"]);
/** Elements whose entire content is discarded. */
const DROP_CONTENT = new Set([
"svg", "math", "template", "object", "embed", "applet", "frame", "frameset", "head", "select", "option",
"form", "button", "audio", "video", "canvas", "map", "picture", "source", "track", "dialog", "portal",
]);
const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
const PARAGRAPH = new Set([
"p", "div", "blockquote", "pre", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6",
"section", "article", "header", "footer", "aside", "table", "tr", "dl", "dt", "dd", "figure", "figcaption", "hr",
]);
type Token =
| { kind: "text"; text: string }
| { kind: "start"; name: string; attrs: Map<string, string>; selfClosing: boolean }
| { kind: "end"; name: string };
const MAX_ATTRS = 16;
function isAlpha(c: number): boolean {
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
}
function isSpace(c: number): boolean {
return c === 32 || c === 9 || c === 10 || c === 12 || c === 13;
}
function* tokenize(src: string): Generator<Token> {
const lower = src.toLowerCase();
const n = src.length;
let i = 0;
while (i < n) {
const lt = src.indexOf("<", i);
if (lt === -1) {
yield { kind: "text", text: src.slice(i) };
return;
}
if (lt > i) yield { kind: "text", text: src.slice(i, lt) };
i = lt;
const next = src.charCodeAt(i + 1);
if (src.startsWith("<!--", i)) {
const close = src.indexOf("-->", i + 4);
i = close === -1 ? n : close + 3;
continue;
}
if (next === 33 /* ! */ || next === 63 /* ? */ || src.startsWith("</>", i)) {
// Bogus comment / doctype / processing instruction / "</>".
const close = src.indexOf(">", i + 1);
i = close === -1 ? n : close + 1;
continue;
}
const isEnd = next === 47;
const nameStart = isEnd ? i + 2 : i + 1;
if (!isAlpha(src.charCodeAt(nameStart))) {
yield { kind: "text", text: "<" };
i++;
continue;
}
let j = nameStart;
while (j < n && !isSpace(src.charCodeAt(j)) && src[j] !== "/" && src[j] !== ">") j++;
const name = lower.slice(nameStart, j);
// Attributes: name[=value], value quoted or unquoted.
const attrs = new Map<string, string>();
let selfClosing = false;
while (j < n && src[j] !== ">") {
const c = src.charCodeAt(j);
if (isSpace(c)) { j++; continue; }
if (src[j] === "/") { selfClosing = true; j++; continue; }
selfClosing = false;
const an = j;
while (j < n && !isSpace(src.charCodeAt(j)) && src[j] !== "/" && src[j] !== ">" && src[j] !== "=") j++;
if (j === an) j++; // stray "=": consume to guarantee progress
const attrName = lower.slice(an, j);
let value = "";
while (j < n && isSpace(src.charCodeAt(j))) j++;
if (src[j] === "=") {
j++;
while (j < n && isSpace(src.charCodeAt(j))) j++;
const q = src[j];
if (q === '"' || q === "'") {
const close = src.indexOf(q, j + 1);
const end = close === -1 ? n : close;
value = src.slice(j + 1, end);
j = end + 1;
} else {
const vs = j;
while (j < n && !isSpace(src.charCodeAt(j)) && src[j] !== ">") j++;
value = src.slice(vs, j);
}
}
if (attrName && !attrs.has(attrName) && attrs.size < MAX_ATTRS) attrs.set(attrName, decodeEntities(value));
}
i = j + 1;
if (isEnd) {
yield { kind: "end", name };
continue;
}
if (name === "plaintext") return;
if (RAW_TEXT.has(name)) {
const close = lower.indexOf("</" + name, i);
i = close === -1 ? n : (() => {
const gt = src.indexOf(">", close);
return gt === -1 ? n : gt + 1;
})();
continue;
}
yield { kind: "start", name, attrs, selfClosing };
}
}
type Frame = { name: string; role: "drop" | "link" | "invisible" | "ellipsis" | "other" };
type OpenLink = { href: string; classes: Set<string>; rel: Set<string>; label: (TextNode | EmojiNode)[] };
function words(value: string | undefined): Set<string> {
return new Set((value ?? "").toLowerCase().split(/\s+/).filter(Boolean));
}
/** Mastodon/PeerTube-style HTML → rich-text AST. */
export function parseProviderHtml(input: unknown, options: ParseOptions = {}): RichTextDocument {
const builder = new DocBuilder(options);
if (typeof input !== "string") return builder.finish();
const src = builder.clip(input);
const stack: Frame[] = [];
let dropDepth = 0;
let invisibleDepth = 0;
let link: OpenLink | null = null;
const flushLink = () => {
if (!link) return;
const current = link;
link = null;
const labelText = current.label.map((n) => (n.type === "text" ? n.text : `:${n.shortcode}:`)).join("").trim();
if (current.classes.has("mention") && (current.classes.has("hashtag") || current.rel.has("tag"))) {
builder.hashtag(labelText, current.href);
} else if (current.classes.has("mention")) {
builder.mention(handleFor(labelText, current.href), current.href);
} else {
builder.link(current.href, current.label);
}
};
const emitText = (raw: string) => {
if (dropDepth > 0) return;
if (link && invisibleDepth > 0) return;
const text = decodeEntities(raw).replace(/[\t\n\f\r ]+/g, " ");
if (link) link.label.push(...builder.inlineText(text));
else builder.text(text);
};
const close = (index: number) => {
while (stack.length > index) {
const frame = stack.pop() as Frame;
if (frame.role === "drop") dropDepth--;
else if (frame.role === "invisible") invisibleDepth--;
else if (frame.role === "ellipsis" && link && dropDepth === 0) link.label.push({ type: "text", text: "…" });
else if (frame.role === "link") flushLink();
if (PARAGRAPH.has(frame.name) && dropDepth === 0) {
flushLink();
builder.endParagraph();
}
}
};
for (const token of tokenize(src)) {
if (token.kind === "text") {
emitText(token.text);
continue;
}
if (token.kind === "end") {
if (token.name === "br" && dropDepth === 0) builder.lineBreak(); // "</br>" acts as <br> in browsers
for (let k = stack.length - 1; k >= 0; k--) {
const frame = stack[k] as Frame;
// Inside dropped content only the dropped elements' own end tags count.
if (frame.name === token.name && (dropDepth === 0 || frame.role === "drop")) {
close(k);
break;
}
}
continue;
}
const { name, attrs } = token;
if (dropDepth > 0) {
if (DROP_CONTENT.has(name) && !VOID.has(name) && !token.selfClosing && stack.length < builder.limits.maxDepth) {
stack.push({ name, role: "drop" });
dropDepth++;
}
continue;
}
if (name === "br") {
if (link) link.label.push({ type: "text", text: " " });
else builder.lineBreak();
continue;
}
if (name === "img") {
const alt = attrs.get("alt") ?? "";
if (alt) emitText(alt);
continue;
}
if (PARAGRAPH.has(name)) {
flushLink();
builder.endParagraph();
}
if (VOID.has(name) || token.selfClosing && !DROP_CONTENT.has(name) && name !== "a" && name !== "span") continue;
if (stack.length >= builder.limits.maxDepth) {
// Too deep: stop tracking structure, keep text (inert) and flag it.
builder.truncated = true;
if (DROP_CONTENT.has(name)) {
// Can't track the matching end tag any more; drop the rest of the input.
break;
}
continue;
}
if (DROP_CONTENT.has(name)) {
if (token.selfClosing) continue;
stack.push({ name, role: "drop" });
dropDepth++;
continue;
}
if (name === "a") {
flushLink();
link = { href: attrs.get("href") ?? "", classes: words(attrs.get("class")), rel: words(attrs.get("rel")), label: [] };
stack.push({ name, role: "link" });
continue;
}
if (name === "span" && link) {
const classes = words(attrs.get("class"));
if (classes.has("invisible")) {
invisibleDepth++;
stack.push({ name, role: "invisible" });
continue;
}
if (classes.has("ellipsis")) {
stack.push({ name, role: "ellipsis" });
continue;
}
}
stack.push({ name, role: "other" });
}
close(0);
flushLink();
return builder.finish();
}
/** Mastodon renders only the local username in mention text; qualify it with the href host. */
function handleFor(label: string, href: string): string {
const handle = label.replace(/^@+/, "");
if (handle.includes("@")) return handle;
try {
const url = new URL(href);
if (url.protocol === "http:" || url.protocol === "https:") return `${handle}@${url.hostname}`;
} catch {
// fall through: unqualified handle
}
return handle;
}