T-HUB/packages/safe-content/src/builder.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

175 lines
6.5 KiB
TypeScript

import type { EmojiNode, InlineNode, LinkNode, ParagraphNode, RichTextDocument, TextNode } from "./ast.js";
import { RICH_TEXT_VERSION } from "./ast.js";
import { DEFAULT_LIMITS, type SafeContentLimits } from "./limits.js";
import { neutralizeText } from "./text.js";
import { DEFAULT_MEDIA_PROXY, parseSafeHttpUrl, toProxiedMediaUrl, type MediaProxyPolicy } from "./url.js";
/** Provider-supplied custom emoji (Mastodon `emojis[]`, Twitch emote sets, …). */
export type CustomEmoji = { readonly shortcode: string; readonly url: string };
export type ParseOptions = {
readonly limits?: Partial<SafeContentLimits>;
readonly mediaProxy?: MediaProxyPolicy;
readonly emojis?: readonly CustomEmoji[];
};
const SHORTCODE = /^[A-Za-z0-9_+-]{1,64}$/;
const SHORTCODE_IN_TEXT = /:([A-Za-z0-9_+-]{1,64}):/g;
const MAX_EMOJIS = 500;
export function normalizeHandle(raw: string): string | null {
const handle = neutralizeText(raw).trim().replace(/^@+/, "");
return /^[\p{L}\p{N}_.-]{1,64}(?:@[\p{L}\p{N}.-]{1,253})?$/u.test(handle) ? handle : null;
}
export function normalizeTag(raw: string): string | null {
const tag = neutralizeText(raw).trim().replace(/^#+/, "");
return /^[\p{L}\p{N}\p{M}_]{1,100}$/u.test(tag) ? tag : null;
}
/** Accumulates a document while enforcing node limits; every write is sanitised. */
export class DocBuilder {
readonly limits: SafeContentLimits;
readonly mediaProxy: MediaProxyPolicy;
private readonly emojis = new Map<string, string>();
private readonly paragraphs: ParagraphNode[] = [];
private current: InlineNode[] = [];
private nodes = 0;
private chars = 0;
truncated = false;
constructor(options: ParseOptions = {}) {
this.limits = { ...DEFAULT_LIMITS, ...options.limits };
this.mediaProxy = options.mediaProxy ?? DEFAULT_MEDIA_PROXY;
for (const emoji of (options.emojis ?? []).slice(0, MAX_EMOJIS)) {
if (typeof emoji?.shortcode !== "string" || !SHORTCODE.test(emoji.shortcode)) continue;
const src = toProxiedMediaUrl(emoji.url, this.mediaProxy);
if (src) this.emojis.set(emoji.shortcode, src);
}
}
/** Clips raw input to the configured length, flagging truncation. */
clip(input: string): string {
if (input.length <= this.limits.maxInputLength) return input;
this.truncated = true;
return input.slice(0, this.limits.maxInputLength);
}
private take(): boolean {
if (this.nodes >= this.limits.maxNodes) {
this.truncated = true;
return false;
}
this.nodes++;
return true;
}
/** Splits text into text + emoji nodes. */
inlineText(raw: string): (TextNode | EmojiNode)[] {
let text = neutralizeText(raw);
// Total text budget; parse paths are already clipped, this bounds forged ASTs.
const room = this.limits.maxInputLength - this.chars;
if (text.length > room) {
this.truncated = true;
text = text.slice(0, Math.max(room, 0));
}
this.chars += text.length;
if (text === "") return [];
const out: (TextNode | EmojiNode)[] = [];
if (this.emojis.size === 0) return [{ type: "text", text }];
let last = 0;
for (const match of text.matchAll(SHORTCODE_IN_TEXT)) {
const shortcode = match[1] as string;
const src = this.emojis.get(shortcode);
if (!src) continue;
if (match.index > last) out.push({ type: "text", text: text.slice(last, match.index) });
out.push({ type: "emoji", shortcode, src });
last = match.index + match[0].length;
}
if (last < text.length) out.push({ type: "text", text: text.slice(last) });
return out;
}
private push(node: InlineNode): void {
const prev = this.current[this.current.length - 1];
if (node.type === "text" && prev?.type === "text") {
this.current[this.current.length - 1] = { type: "text", text: prev.text + node.text };
return;
}
if (this.take()) this.current.push(node);
}
/** Appends an already-sanitised text/emoji node. */
pushLeaf(node: TextNode | EmojiNode): void {
this.push(node);
}
text(raw: string): void {
for (const node of this.inlineText(raw)) this.push(node);
}
lineBreak(): void {
this.push({ type: "linebreak" });
}
/** Emits a link if `href` is safe, otherwise the label as inert text. */
link(href: string, label: readonly (TextNode | EmojiNode)[]): void {
const safe = parseSafeHttpUrl(href, this.limits.maxUrlLength);
if (!safe) {
for (const node of label) this.push(node);
return;
}
const children = label.length > 0 ? mergeText(label) : [{ type: "text" as const, text: safe.host }];
if (this.nodes + children.length > this.limits.maxNodes) {
this.truncated = true;
return;
}
this.nodes += children.length;
this.push({ type: "link", href: safe.href, host: safe.host, children } satisfies LinkNode);
}
mention(rawHandle: string, href?: string): void {
const handle = normalizeHandle(rawHandle);
if (!handle) return this.text(rawHandle);
const safe = href === undefined ? null : parseSafeHttpUrl(href, this.limits.maxUrlLength);
this.push(safe ? { type: "mention", handle, href: safe.href } : { type: "mention", handle });
}
hashtag(rawTag: string, href?: string): void {
const tag = normalizeTag(rawTag);
if (!tag) return this.text(rawTag);
const safe = href === undefined ? null : parseSafeHttpUrl(href, this.limits.maxUrlLength);
this.push(safe ? { type: "hashtag", tag, href: safe.href } : { type: "hashtag", tag });
}
endParagraph(): void {
const children = trimParagraph(this.current);
this.current = [];
// Not budgeted: each non-empty paragraph already holds >= 1 budgeted inline node.
if (children.length > 0) this.paragraphs.push({ type: "paragraph", children });
}
finish(): RichTextDocument {
this.endParagraph();
return { type: "doc", version: RICH_TEXT_VERSION, children: this.paragraphs, truncated: this.truncated };
}
}
function mergeText(nodes: readonly (TextNode | EmojiNode)[]): (TextNode | EmojiNode)[] {
const out: (TextNode | EmojiNode)[] = [];
for (const node of nodes) {
const prev = out[out.length - 1];
if (node.type === "text" && prev?.type === "text") out[out.length - 1] = { type: "text", text: prev.text + node.text };
else out.push(node);
}
return out;
}
function trimParagraph(nodes: InlineNode[]): InlineNode[] {
let start = 0;
let end = nodes.length;
const blank = (n: InlineNode | undefined) => n?.type === "linebreak" || (n?.type === "text" && n.text.trim() === "");
while (start < end && blank(nodes[start])) start++;
while (end > start && blank(nodes[end - 1])) end--;
return nodes.slice(start, end);
}