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

44 lines
1.8 KiB
TypeScript

import type { RichTextDocument } from "./ast.js";
import { DocBuilder, type ParseOptions } from "./builder.js";
// Single-pass, backtracking-safe token scan: URL | @mention | #hashtag.
const TOKENS =
/(https?:\/\/[^\s<>"'`\u0000-\u001F]{1,2048})|(?<![\p{L}\p{N}_@])@([\p{L}\p{N}_.-]{1,64}(?:@[\p{L}\p{N}-]{1,63}(?:\.[\p{L}\p{N}-]{1,63}){0,8})?)|(?<![\p{L}\p{N}_#&])#([\p{L}\p{N}\p{M}_]{1,100})/gu;
const TRAILING_PUNCT = /[.,;:!?)\]}'"»”’]+$/;
function lineToNodes(builder: DocBuilder, line: string): void {
let last = 0;
for (const match of line.matchAll(TOKENS)) {
const [whole, url, handle, tag] = match;
let consumed = whole;
if (match.index > last) builder.text(line.slice(last, match.index));
if (url !== undefined) {
const trimmed = url.replace(TRAILING_PUNCT, "");
consumed = trimmed;
builder.link(trimmed, builder.inlineText(trimmed.replace(/^https?:\/\//i, "")));
} else if (handle !== undefined) {
const clean = handle.replace(/[.-]+$/, "");
consumed = "@" + clean;
builder.mention(clean);
} else if (tag !== undefined) {
builder.hashtag(tag);
}
last = match.index + consumed.length;
}
if (last < line.length) builder.text(line.slice(last));
}
/** Plain provider text (Bluesky, Telegram, bios, …): newlines, URLs, mentions, hashtags. */
export function parsePlainText(input: unknown, options: ParseOptions = {}): RichTextDocument {
const builder = new DocBuilder(options);
if (typeof input !== "string") return builder.finish();
const text = builder.clip(input).replace(/\r\n?/g, "\n");
for (const paragraph of text.split(/\n{2,}/)) {
paragraph.split("\n").forEach((line, i) => {
if (i > 0) builder.lineBreak();
lineToNodes(builder, line);
});
builder.endParagraph();
}
return builder.finish();
}