Files
buzz-sheet/lib/rich-text.ts
T
gunshiz 9896e8a4ad
CI / Verify (push) Successful in 1m35s
CI / Build immutable images and deploy (push) Successful in 5m27s
feat(guides) : add safer publishing and discovery
2026-09-19 13:32:57 +07:00

186 lines
5.3 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;
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("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function escapeHtml(value: string): string {
return escapeAttribute(value).replaceAll("'", "&#39;");
}
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}>`;
});
}
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>` : "";
}
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);
}