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/.
44 lines
1.8 KiB
TypeScript
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();
|
|
}
|