123 lines
3.6 KiB
TypeScript
123 lines
3.6 KiB
TypeScript
import { highlightTextSegments, type TextHighlight } from "@/lib/guides/highlights";
|
|
|
|
const ALLOWED_TAGS = new Set([
|
|
"a",
|
|
"blockquote",
|
|
"br",
|
|
"code",
|
|
"em",
|
|
"li",
|
|
"ol",
|
|
"p",
|
|
"pre",
|
|
"s",
|
|
"span",
|
|
"strong",
|
|
"u",
|
|
"ul",
|
|
]);
|
|
|
|
const BLOCKED_CONTENT = /<(script|style|iframe|object|embed|svg|math|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
|
|
const HTML_COMMENT = /<!--[\s\S]*?-->/g;
|
|
const TAG = /<\/?([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>/g;
|
|
const ATTRIBUTE = /([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+)/g;
|
|
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
const RGB_COLOR = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i;
|
|
|
|
function normalizeRichTextColor(value: string): string | null {
|
|
const color = value.trim();
|
|
if (HEX_COLOR.test(color)) return color.toUpperCase();
|
|
|
|
const rgb = color.match(RGB_COLOR);
|
|
if (!rgb) return null;
|
|
const channels = rgb.slice(1).map(Number);
|
|
if (channels.some((channel) => channel > 255)) return null;
|
|
return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`.toUpperCase();
|
|
}
|
|
|
|
export function sanitizeRichTextUrl(value: string): string {
|
|
const url = value.trim();
|
|
if (!url) return "";
|
|
|
|
try {
|
|
const parsed = new URL(url, "https://example.invalid");
|
|
return ["http:", "https:", "mailto:"].includes(parsed.protocol) ? url : "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function escapeAttribute(value: string): string {
|
|
return value
|
|
.replaceAll("&", "&")
|
|
.replaceAll('"', """)
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">");
|
|
}
|
|
|
|
function escapeHtml(value: string): string {
|
|
return escapeAttribute(value).replaceAll("'", "'");
|
|
}
|
|
|
|
function attributeValue(rawAttributes: string, name: string): string {
|
|
for (const match of rawAttributes.matchAll(ATTRIBUTE)) {
|
|
if (match[1].toLowerCase() === name) {
|
|
return match[2].replace(/^['"]|['"]$/g, "");
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
export function sanitizeRichTextHtml(value: string): string {
|
|
return value
|
|
.replace(BLOCKED_CONTENT, "")
|
|
.replace(HTML_COMMENT, "")
|
|
.replace(TAG, (tag, rawName: string, rawAttributes: string) => {
|
|
const name = rawName.toLowerCase();
|
|
if (!ALLOWED_TAGS.has(name)) return "";
|
|
if (tag.startsWith("</")) return name === "br" ? "" : `</${name}>`;
|
|
if (name === "br") return "<br>";
|
|
|
|
if (name === "a") {
|
|
const href = sanitizeRichTextUrl(attributeValue(rawAttributes, "href"));
|
|
return href
|
|
? `<a href="${escapeAttribute(href)}" target="_blank" rel="noopener noreferrer">`
|
|
: "<a>";
|
|
}
|
|
|
|
if (name === "span") {
|
|
const style = attributeValue(rawAttributes, "style");
|
|
const color = normalizeRichTextColor(
|
|
style.match(/(?:^|;)\s*color\s*:\s*([^;]+?)\s*(?:;|$)/i)?.[1] ?? "",
|
|
);
|
|
return color
|
|
? `<span style="color: ${color}">`
|
|
: "<span>";
|
|
}
|
|
|
|
return `<${name}>`;
|
|
});
|
|
}
|
|
|
|
export function isRichTextHtml(value: string): boolean {
|
|
return /<(?:p|br|strong|em|u|s|a|span|ul|ol|li|blockquote|pre|code)\b/i.test(value);
|
|
}
|
|
|
|
export function normalizeExtraRichText(
|
|
body: string,
|
|
highlights: readonly TextHighlight[] = [],
|
|
): string {
|
|
if (isRichTextHtml(body)) return sanitizeRichTextHtml(body);
|
|
|
|
const content = highlightTextSegments(body, highlights)
|
|
.map((segment) => {
|
|
const text = escapeHtml(segment.text).replaceAll("\n", "<br>");
|
|
return segment.color && HEX_COLOR.test(segment.color)
|
|
? `<span style="color: ${segment.color.toUpperCase()}">${text}</span>`
|
|
: text;
|
|
})
|
|
.join("");
|
|
|
|
return content ? `<p>${content}</p>` : "";
|
|
}
|