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/.
175 lines
6.5 KiB
TypeScript
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);
|
|
}
|