import type { ReactNode } from "react";
import type { EmojiNode, InlineNode, LinkPreview, RichTextDocument, TextNode } from "./ast.js";
import { neutralizeDisplayName, hasMixedScriptConfusables } from "./text.js";
import { DEFAULT_MEDIA_PROXY, isProxiedMediaUrl, parseSafeHttpUrl, type MediaProxyPolicy } from "./url.js";
import { coerceRichTextDocument, parseLinkPreview } from "./validate.js";
/*
* The single reviewed renderer for provider content (proposal ยง4.5). Every
* provider string reaches the DOM as a React text node; the only attributes
* set from provider data are re-validated http(s) `href`s and media-proxy
* `src`s. No dangerouslySetInnerHTML, no style, no event handlers.
*/
export const EXTERNAL_LINK_REL = "noopener noreferrer nofollow ugc";
type CommonProps = { readonly className?: string; readonly mediaProxy?: MediaProxyPolicy };
export function SafeLink(props: { readonly href: string; readonly children?: ReactNode; readonly className?: string }): ReactNode {
const safe = parseSafeHttpUrl(props.href);
if (!safe) return {props.children};
return (
{props.children ?? safe.host}
);
}
function Emoji({ node, mediaProxy }: { node: EmojiNode; mediaProxy: MediaProxyPolicy }): ReactNode {
const label = `:${node.shortcode}:`;
if (!isProxiedMediaUrl(node.src, mediaProxy)) return label;
return (
);
}
function leaf(node: TextNode | EmojiNode, key: number, mediaProxy: MediaProxyPolicy): ReactNode {
return node.type === "text" ? node.text : ;
}
function labelStartsWithHost(children: readonly (TextNode | EmojiNode)[], host: string): boolean {
const label = children
.map((c) => (c.type === "text" ? c.text : ""))
.join("")
.trim()
.toLowerCase()
.replace(/^https?:\/\//, "")
.replace(/^www\./, "");
const h = host.toLowerCase().replace(/^www\./, "");
return label === h || label.startsWith(h + "/") || label.startsWith(h + "?") || label.startsWith(h + "#") || label.startsWith(h + ":");
}
function inline(node: InlineNode, key: number, mediaProxy: MediaProxyPolicy): ReactNode {
switch (node.type) {
case "text":
return node.text;
case "linebreak":
return
;
case "emoji":
return ;
case "link":
return (
{node.children.map((c, i) => leaf(c, i, mediaProxy))}
{/* The real destination host is always visible when the label could mislead. */}
{labelStartsWithHost(node.children, node.host) ? null : ({node.host})}
);
case "mention":
return node.href ? (
{`@${node.handle}`}
) : (
{`@${node.handle}`}
);
case "hashtag":
return node.href ? (
{`#${node.tag}`}
) : (
{`#${node.tag}`}
);
default:
return null;
}
}
/** Renders a rich-text document. `doc` may be untrusted JSON; it is re-validated first. */
export function RichText(props: CommonProps & { readonly doc: RichTextDocument | unknown }): ReactNode {
const mediaProxy = props.mediaProxy ?? DEFAULT_MEDIA_PROXY;
const doc = coerceRichTextDocument(props.doc, { mediaProxy });
return (
{doc.children.map((p, i) => (
{p.children.map((n, j) => inline(n, j, mediaProxy))}
))}
);
}
/** Link-preview card. `preview` may be raw provider card data or a built `LinkPreview`. */
export function LinkPreviewCard(props: CommonProps & { readonly preview: LinkPreview | unknown }): ReactNode {
const mediaProxy = props.mediaProxy ?? DEFAULT_MEDIA_PROXY;
const preview = parseLinkPreview(props.preview, mediaProxy);
if (!preview) return null;
return (
{preview.image && isProxiedMediaUrl(preview.image, mediaProxy) ? (
) : null}
{preview.siteName ? {preview.siteName} : null}
{preview.title ? {preview.title} : null}
{preview.description ? {preview.description} : null}
{preview.host}
);
}
/** Display names / usernames: bidi + zero-width neutralised, isolated, confusables flagged. */
export function DisplayName(props: { readonly name: unknown; readonly maxLength?: number; readonly className?: string }): ReactNode {
const name = typeof props.name === "string" ? neutralizeDisplayName(props.name, props.maxLength) : "";
return (
{name}
);
}