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(//gi, "\n") .replace(/<\/(?:blockquote|li|ol|p|pre|ul)>/gi, "\n"); let text = ""; sanitizeHtml(withLineBreaks, { allowedTags: [], allowedAttributes: {}, textFilter: (chunk) => { text += chunk; return ""; }, }); return decodeHTML(text).replace(/\n$/, ""); } const RICH_TEXT_MARK_TAGS = { bold: "strong", italic: "em", underline: "u", strike: "s", } as const; function removeRichTextTag(value: string, tag: string): string { return value.replace(new RegExp(`]*)?>`, "gi"), ""); } function wrapRichTextNodes(value: string, tag: string, attributes = ""): string { return value .split(/(<[^>]+>)/g) .map((part) => part && !part.startsWith("<") ? `<${tag}${attributes}>${part}` : part) .join(""); } /** Returns rich-text structure with visual marks removed for content comparisons. */ export function richTextContentSignature(value: string): string { let result = normalizeExtraRichText(value); Object.values(RICH_TEXT_MARK_TAGS).forEach((tag) => { result = removeRichTextTag(result, tag); }); return removeRichTextTag(result, "span"); } /** Applies uniform visual formatting without replacing a cell's own content. */ export function applyRichTextStylePatch( value: string, patch: RichTextStylePatch, ): string { let result = normalizeExtraRichText(value); (Object.entries(RICH_TEXT_MARK_TAGS) as Array< [keyof Omit, string] >).forEach(([format, tag]) => { if (patch[format] === undefined) return; result = removeRichTextTag(result, tag); if (patch[format]) result = wrapRichTextNodes(result, tag); }); if (patch.color !== undefined) { result = removeRichTextTag(result, "span"); const color = patch.color ? normalizeRichTextColor(patch.color) : null; if (color) { result = wrapRichTextNodes(result, "span", ` style="color: ${color}"`); } } return sanitizeRichTextHtml(result); } /** Removes visual marks from rich text while preserving its structure and links. */ export function clearRichTextFormatting(value: string): string { return applyRichTextStylePatch(value, { bold: false, italic: false, underline: false, strike: false, color: null, }); }