Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 61 additions & 70 deletions apps/web/src/components/rich-text.tsx
Original file line number Diff line number Diff line change
@@ -1,98 +1,89 @@
import { useState } from "react";
import { type ReactNode, useState } from "react";
import { parseRichTextMarkup, parseTextSegments, type RichTextNode } from "../lib/rich-text";
import { ExternalLinkModal } from "./external-link-modal";

type Segment =
| { id: string; type: "text"; value: string }
| { id: string; type: "url"; value: string }
| { id: string; type: "timecode"; value: string; seconds: number };

function parseTimestampToSeconds(value: string): number | null {
const parts = value.split(":").map((part) => Number(part));
if (parts.some((part) => !Number.isFinite(part))) return null;
if (parts.length === 2) {
const [minutes, seconds] = parts;
return minutes * 60 + seconds;
}
if (parts.length === 3) {
const [hours, minutes, seconds] = parts;
return hours * 3600 + minutes * 60 + seconds;
}
return null;
}

function parseSegments(text: string): Segment[] {
const regex = /https?:\/\/[^\s\])"',;:!>]+|\b(?:\d{1,2}:)?[0-5]?\d:[0-5]\d\b/g;
const segments: Segment[] = [];
let lastIndex = 0;
let counter = 0;
let match = regex.exec(text);
while (match !== null) {
if (match.index > lastIndex) {
segments.push({
id: `t${counter++}`,
type: "text",
value: text.slice(lastIndex, match.index),
});
}
const value = match[0];
if (value.startsWith("http://") || value.startsWith("https://")) {
segments.push({ id: `u${counter++}`, type: "url", value });
} else {
const seconds = parseTimestampToSeconds(value);
if (seconds === null) {
segments.push({ id: `t${counter++}`, type: "text", value });
} else {
segments.push({ id: `c${counter++}`, type: "timecode", value, seconds });
}
}
lastIndex = match.index + match[0].length;
match = regex.exec(text);
}
if (lastIndex < text.length) {
segments.push({ id: `t${counter}`, type: "text", value: text.slice(lastIndex) });
}
return segments;
}

type RichTextProps = {
text: string;
onSeekTimestamp?: (seconds: number) => void;
};

export function RichText({ text, onSeekTimestamp }: RichTextProps) {
const [pendingUrl, setPendingUrl] = useState<string | null>(null);
const segments = parseSegments(text);
const nodes = parseRichTextMarkup(text);

return (
<span>
{segments.map((seg) =>
seg.type === "text" ? (
<span key={seg.id}>{seg.value}</span>
) : seg.type === "url" ? (
function renderNodes(children: RichTextNode[], keyPrefix: string): ReactNode[] {
return children.flatMap((node, index) => renderNode(node, `${keyPrefix}-${index}`));
}

function renderNode(node: RichTextNode, key: string): ReactNode[] {
if (node.type === "text") {
return parseTextSegments(node.value, `${key}-`).map((segment) =>
segment.type === "text" ? (
<span key={segment.id}>{segment.value}</span>
) : segment.type === "url" ? (
<a
key={seg.id}
href={seg.value}
key={segment.id}
href={segment.value}
onClick={(event) => {
event.preventDefault();
setPendingUrl(seg.value);
setPendingUrl(segment.value);
}}
className="text-accent hover:text-accent-strong underline underline-offset-2 transition-colors break-all text-left align-baseline"
>
{seg.value}
{segment.value}
</a>
) : onSeekTimestamp ? (
<button
key={seg.id}
key={segment.id}
type="button"
onClick={() => onSeekTimestamp(seg.seconds)}
onClick={() => onSeekTimestamp(segment.seconds)}
className="text-accent hover:text-accent-strong underline underline-offset-2 transition-colors"
>
{seg.value}
{segment.value}
</button>
) : (
<span key={seg.id}>{seg.value}</span>
<span key={segment.id}>{segment.value}</span>
),
)}
);
}
if (node.type === "break") return [<br key={key} />];
if (node.type === "link") {
return [
<a
key={key}
href={node.href}
onClick={(event) => {
event.preventDefault();
setPendingUrl(node.href);
}}
className="text-accent hover:text-accent-strong underline underline-offset-2 transition-colors break-all text-left align-baseline"
>
{renderNodes(node.children, key)}
</a>,
];
}
const children = renderNodes(node.children, key);
switch (node.tag) {
case "strong":
return [<strong key={key}>{children}</strong>];
case "em":
return [<em key={key}>{children}</em>];
case "u":
return [<u key={key}>{children}</u>];
case "s":
return [<s key={key}>{children}</s>];
case "code":
return [<code key={key}>{children}</code>];
case "kbd":
return [<kbd key={key}>{children}</kbd>];
case "mark":
return [<mark key={key}>{children}</mark>];
}
}

return (
<span>
{renderNodes(nodes, "rich-text")}
{pendingUrl && (
<ExternalLinkModal
url={pendingUrl}
Expand Down
168 changes: 168 additions & 0 deletions apps/web/src/lib/rich-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
export type RichTextSegment =
| { id: string; type: "text"; value: string }
| { id: string; type: "url"; value: string }
| { id: string; type: "timecode"; value: string; seconds: number };

type RichTextFormat = "strong" | "em" | "u" | "s" | "code" | "kbd" | "mark";

export type RichTextNode =
| { type: "text"; value: string }
| { type: "break" }
| { type: "link"; href: string; children: RichTextNode[] }
| { type: "format"; tag: RichTextFormat; children: RichTextNode[] };

const FORMAT_TAGS = new Set([
"b",
"strong",
"em",
"i",
"u",
"s",
"strike",
"del",
"code",
"kbd",
"mark",
]);
const BLOCK_TAGS = new Set(["address", "article", "aside", "blockquote", "div", "li", "p", "pre"]);
const OMIT_TAGS = new Set([
"audio",
"base",
"embed",
"form",
"iframe",
"img",
"link",
"meta",
"object",
"script",
"style",
"svg",
"template",
"video",
]);

function isSafeHttpUrl(value: string): boolean {
try {
const protocol = new URL(value).protocol;
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
}

function appendBreak(nodes: RichTextNode[]): void {
if (nodes.length > 0 && nodes.at(-1)?.type !== "break") nodes.push({ type: "break" });
}

function formatTag(tag: string): RichTextFormat | null {
if (tag === "b") return "strong";
if (tag === "i") return "em";
if (tag === "strike" || tag === "del") return "s";
return FORMAT_TAGS.has(tag) ? (tag as RichTextFormat) : null;
}

function parseChildNodes(nodes: NodeListOf<ChildNode>): RichTextNode[] {
const result: RichTextNode[] = [];

for (const node of nodes) {
if (node.nodeType === 3) {
if (node.nodeValue) result.push({ type: "text", value: node.nodeValue });
continue;
}
if (node.nodeType !== 1) continue;

const element = node as Element;
const tag = element.tagName.toLowerCase();
if (OMIT_TAGS.has(tag)) continue;
if (tag === "br") {
result.push({ type: "break" });
continue;
}

const children = parseChildNodes(element.childNodes);
if (tag === "a") {
const href = element.getAttribute("href");
if (href && isSafeHttpUrl(href)) result.push({ type: "link", href, children });
else result.push(...children);
continue;
}
const formattedTag = formatTag(tag);
if (formattedTag) {
result.push({ type: "format", tag: formattedTag, children });
continue;
}
if (BLOCK_TAGS.has(tag)) {
appendBreak(result);
result.push(...children);
appendBreak(result);
continue;
}
result.push(...children);
}

return result;
}

function parseDocument(source: string): HTMLElement | null {
if (typeof DOMParser === "undefined") return null;
return new DOMParser().parseFromString(source, "text/html").body;
}

export function parseRichTextMarkup(text: string): RichTextNode[] {
const body = parseDocument(text);
if (!body) return [{ type: "text", value: text }];

let nodes = parseChildNodes(body.childNodes);
if (body.children.length === 0 && /&lt;\s*(?:a|br|b|strong|em|i|u|s|p|div)\b/i.test(text)) {
const decoded = body.textContent ?? text;
const decodedBody = decoded === text ? null : parseDocument(decoded);
if (decodedBody) nodes = parseChildNodes(decodedBody.childNodes);
}
return nodes;
}

function parseTimestampToSeconds(value: string): number | null {
const parts = value.split(":").map((part) => Number(part));
if (parts.some((part) => !Number.isFinite(part))) return null;
if (parts.length === 2) {
const [minutes, seconds] = parts;
return minutes * 60 + seconds;
}
if (parts.length === 3) {
const [hours, minutes, seconds] = parts;
return hours * 3600 + minutes * 60 + seconds;
}
return null;
}

export function parseTextSegments(text: string, idPrefix = ""): RichTextSegment[] {
const regex = /https?:\/\/[^\s\])"',;:!>]+|\b(?:\d{1,2}:)?[0-5]?\d:[0-5]\d\b/g;
const segments: RichTextSegment[] = [];
let lastIndex = 0;
let counter = 0;
let match = regex.exec(text);
while (match !== null) {
if (match.index > lastIndex) {
segments.push({
id: `${idPrefix}t${counter++}`,
type: "text",
value: text.slice(lastIndex, match.index),
});
}
const value = match[0];
if (value.startsWith("http://") || value.startsWith("https://")) {
segments.push({ id: `${idPrefix}u${counter++}`, type: "url", value });
} else {
const seconds = parseTimestampToSeconds(value);
if (seconds === null) segments.push({ id: `${idPrefix}t${counter++}`, type: "text", value });
else segments.push({ id: `${idPrefix}c${counter++}`, type: "timecode", value, seconds });
}
lastIndex = match.index + match[0].length;
match = regex.exec(text);
}
if (lastIndex < text.length) {
segments.push({ id: `${idPrefix}t${counter}`, type: "text", value: text.slice(lastIndex) });
}
return segments;
}
20 changes: 20 additions & 0 deletions apps/web/tests/rich-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test";
import { parseTextSegments } from "../src/lib/rich-text";

describe("rich text segments", () => {
test("keeps links, timecodes, and surrounding text interactive", () => {
expect(parseTextSegments("Watch 1:23 https://example.com/video")).toEqual([
{ id: "t0", type: "text", value: "Watch " },
{ id: "c1", type: "timecode", value: "1:23", seconds: 83 },
{ id: "t2", type: "text", value: " " },
{ id: "u3", type: "url", value: "https://example.com/video" },
]);
});

test("does not create empty segments", () => {
expect(parseTextSegments("")).toEqual([]);
expect(parseTextSegments("1:23")).toEqual([
{ id: "c0", type: "timecode", value: "1:23", seconds: 83 },
]);
});
});