86 lines
2.6 KiB
TypeScript
86 lines
2.6 KiB
TypeScript
export type ChatMarkdownProps = {
|
|
children: string;
|
|
streaming?: boolean;
|
|
};
|
|
|
|
const protocolPattern = /^([a-z][a-z\d+.-]*):/i;
|
|
const safeProtocols = new Set(["http", "https", "mailto", "tel"]);
|
|
const hexColorPattern = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
const numericCitationPattern = /^\[?(\d{1,3})\]?$/;
|
|
|
|
export function sanitizeMarkdownUrl(url: string, allowRelative = false): string | undefined {
|
|
const value = url.trim();
|
|
const protocol = value.match(protocolPattern)?.[1]?.toLowerCase();
|
|
|
|
if (protocol) return safeProtocols.has(protocol) ? value : undefined;
|
|
if (
|
|
allowRelative &&
|
|
(value.startsWith("/") ||
|
|
value.startsWith("./") ||
|
|
value.startsWith("../") ||
|
|
value.startsWith("#"))
|
|
) {
|
|
return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function isExternalMarkdownUrl(url: string | undefined): boolean {
|
|
if (!url) return false;
|
|
const protocol = url.trim().match(protocolPattern)?.[1]?.toLowerCase();
|
|
return Boolean(protocol && safeProtocols.has(protocol));
|
|
}
|
|
|
|
export function parseCitationBadgeLabel(
|
|
label: string | undefined,
|
|
href: string | undefined,
|
|
): string | undefined {
|
|
if (!label || !href || !isExternalMarkdownUrl(href)) return undefined;
|
|
const trimmed = label.trim();
|
|
const numeric = trimmed.match(numericCitationPattern);
|
|
if (numeric?.[1]) return numeric[1];
|
|
return undefined;
|
|
}
|
|
|
|
export function citationDomainLabel(href: string | undefined): string | undefined {
|
|
if (!href) return undefined;
|
|
try {
|
|
const parsed = new URL(href.trim());
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return undefined;
|
|
return parsed.hostname.toLowerCase().replace(/^www\./, "");
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function parseHexColorToken(value: string | undefined): string | undefined {
|
|
if (!value) return undefined;
|
|
const trimmed = value.trim();
|
|
return hexColorPattern.test(trimmed) ? trimmed : undefined;
|
|
}
|
|
|
|
export function closeUnterminatedFence(markdown: string): string {
|
|
let openFence: { marker: "`" | "~"; length: number } | undefined;
|
|
|
|
for (const line of markdown.split("\n")) {
|
|
const match = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
|
|
if (!match?.[1]) continue;
|
|
|
|
const marker = match[1][0] as "`" | "~";
|
|
if (!openFence) {
|
|
openFence = { marker, length: match[1].length };
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
marker === openFence.marker &&
|
|
match[1].length >= openFence.length &&
|
|
(match[2] ?? "").trim() === ""
|
|
) {
|
|
openFence = undefined;
|
|
}
|
|
}
|
|
|
|
return openFence ? `${markdown}\n${openFence.marker.repeat(openFence.length)}` : markdown;
|
|
}
|