104 lines
3.4 KiB
TypeScript
104 lines
3.4 KiB
TypeScript
import { Marked, type Tokens } from "marked";
|
|
|
|
const EXTERNAL_LINK_PROTOCOLS = new Set(["http:", "https:"]);
|
|
|
|
export interface AiMarkdownLinkClickEvent {
|
|
target: unknown;
|
|
currentTarget: unknown;
|
|
preventDefault: () => void;
|
|
stopPropagation: () => void;
|
|
}
|
|
|
|
export type AiMarkdownLinkOpener = (url: string) => void | Promise<void>;
|
|
|
|
const markedInstance = new Marked({
|
|
breaks: true,
|
|
gfm: true,
|
|
renderer: {
|
|
codespan({ text }: Tokens.Codespan) {
|
|
return `<code class="rounded bg-muted px-1.5 py-0.5 text-[11px] font-mono">${escapeHtml(text)}</code>`;
|
|
},
|
|
html({ text }: Tokens.HTML | Tokens.Tag) {
|
|
return escapeHtml(text);
|
|
},
|
|
link({ href, title, tokens }: Tokens.Link) {
|
|
const label = this.parser.parseInline(tokens);
|
|
const safeHref = normalizeAiMarkdownLink(href);
|
|
if (!safeHref) return label;
|
|
|
|
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
|
return `<a href="${escapeHtml(safeHref)}"${titleAttr} target="_blank" rel="noopener noreferrer">${label}</a>`;
|
|
},
|
|
},
|
|
});
|
|
|
|
export function formatAiInlineMarkdown(text: string): string {
|
|
try {
|
|
return markedInstance.parse(text) as string;
|
|
} catch {
|
|
return escapeHtml(text);
|
|
}
|
|
}
|
|
|
|
export function normalizeAiMarkdownLink(href: string): string | null {
|
|
try {
|
|
const url = new URL(href);
|
|
return EXTERNAL_LINK_PROTOCOLS.has(url.protocol) ? url.toString() : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function aiMarkdownLinkUrlFromClick(target: unknown, currentTarget: unknown): string | null {
|
|
const anchor = closestAnchor(target);
|
|
if (!anchor) return null;
|
|
if (hasContains(currentTarget) && !currentTarget.contains(anchor)) return null;
|
|
|
|
const href = anchor.getAttribute("href");
|
|
return href ? normalizeAiMarkdownLink(href) : null;
|
|
}
|
|
|
|
export function handleAiMarkdownLinkClick(event: AiMarkdownLinkClickEvent, openUrl: AiMarkdownLinkOpener): boolean {
|
|
const url = aiMarkdownLinkUrlFromClick(event.target, event.currentTarget);
|
|
if (!url) return false;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
void openUrl(url);
|
|
return true;
|
|
}
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
interface AnchorLike {
|
|
getAttribute: (name: string) => string | null;
|
|
}
|
|
|
|
function closestAnchor(target: unknown): AnchorLike | null {
|
|
const closestSource = hasClosest(target) ? target : parentWithClosest(target);
|
|
if (!closestSource) return null;
|
|
|
|
const anchor = closestSource.closest("a[href]");
|
|
return hasGetAttribute(anchor) ? anchor : null;
|
|
}
|
|
|
|
function parentWithClosest(target: unknown): { closest: (selector: string) => unknown } | null {
|
|
if (!target || typeof target !== "object" || !("parentElement" in target)) return null;
|
|
const parentElement = target.parentElement;
|
|
return hasClosest(parentElement) ? parentElement : null;
|
|
}
|
|
|
|
function hasClosest(value: unknown): value is { closest: (selector: string) => unknown } {
|
|
return !!value && typeof value === "object" && "closest" in value && typeof value.closest === "function";
|
|
}
|
|
|
|
function hasGetAttribute(value: unknown): value is AnchorLike {
|
|
return !!value && typeof value === "object" && "getAttribute" in value && typeof value.getAttribute === "function";
|
|
}
|
|
|
|
function hasContains(value: unknown): value is { contains: (node: unknown) => boolean } {
|
|
return !!value && typeof value === "object" && "contains" in value && typeof value.contains === "function";
|
|
}
|