import sanitizeHtml from "sanitize-html";
import { decodeHTML } from "entities";
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 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;
export interface RichTextStylePatch {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
color?: string | null;
}
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("'", "'");
}
export function sanitizeRichTextHtml(value: string): string {
return sanitizeHtml(value, {
allowedTags: [...ALLOWED_TAGS],
allowedAttributes: { a: ["href", "target", "rel"], span: ["style"] },
allowedSchemes: ["http", "https", "mailto"],
allowProtocolRelative: false,
nonTextTags: ["script", "style", "textarea", "option", "iframe", "object", "embed", "svg", "math", "template"],
transformTags: {
a: (_name, attributes): sanitizeHtml.Tag => {
const href = sanitizeRichTextUrl(attributes.href ?? "");
return { tagName: "a", attribs: href
? { href, target: "_blank", rel: "noopener noreferrer" } : {} };
},
span: (_name, attributes): sanitizeHtml.Tag => {
const color = normalizeRichTextColor(
(attributes.style ?? "").match(/(?:^|;)\s*color\s*:\s*([^;]+?)\s*(?:;|$)/i)?.[1] ?? "",
);
return { tagName: "span", attribs: color ? { style: `color: ${color}` } : {} };
},
},
// Colors have already been normalized against our strict hex/RGB parser.
parseStyleAttributes: false,
}).replace(/
/g, "
");
}
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", "
");
return segment.color && HEX_COLOR.test(segment.color)
? `${text}`
: text;
})
.join("");
return content ? `
${content}
` : ""; } /** Converts stored Extra Sheet rich text into editable plain text. */ export function extraRichTextToPlainText(value: string): string { const withLineBreaks = normalizeExtraRichText(value) .replace(/