187 lines
5.5 KiB
TypeScript
187 lines
5.5 KiB
TypeScript
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(/<br\s*\/>/g, "<br>");
|
|
}
|
|
|
|
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>` : "";
|
|
}
|
|
|
|
/** Converts stored Extra Sheet rich text into editable plain text. */
|
|
export function extraRichTextToPlainText(value: string): string {
|
|
const withLineBreaks = normalizeExtraRichText(value)
|
|
.replace(/<br\s*\/?>/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(`</?${tag}(?:\\s[^>]*)?>`, "gi"), "");
|
|
}
|
|
|
|
function wrapRichTextNodes(value: string, tag: string, attributes = ""): string {
|
|
return value
|
|
.split(/(<[^>]+>)/g)
|
|
.map((part) => part && !part.startsWith("<")
|
|
? `<${tag}${attributes}>${part}</${tag}>`
|
|
: 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<RichTextStylePatch, "color">, 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);
|
|
}
|