mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 07:12:18 +10:00
231 lines
7.5 KiB
TypeScript
231 lines
7.5 KiB
TypeScript
import type { HTMLElement, Node } from "node-html-parser";
|
||
import { NodeType, parse } from "node-html-parser";
|
||
import { isDarkColor } from "@reactive-resume/utils/color";
|
||
import { getRichTextSemanticKind, getRichTextSemanticNodeKey } from "../../semantic/rich-text-keys";
|
||
|
||
export const richTextMarkClassName = "rr-pdf-mark";
|
||
export const richTextSemanticNodeKeyAttribute = "data-resume-semantic-node-key";
|
||
|
||
const inlineTags = new Set([
|
||
"a",
|
||
"abbr",
|
||
"b",
|
||
"br",
|
||
"button",
|
||
"cite",
|
||
"code",
|
||
"dfn",
|
||
"em",
|
||
"i",
|
||
"label",
|
||
"q",
|
||
"s",
|
||
"span",
|
||
"strong",
|
||
"sub",
|
||
"sup",
|
||
"u",
|
||
]);
|
||
|
||
const getTagName = (node: Node) => node.rawTagName.toLowerCase();
|
||
|
||
const hasBlockDescendant = (node: Node): boolean =>
|
||
node.childNodes.some((child) => child.nodeType === NodeType.ELEMENT_NODE && !isInlineNode(child));
|
||
|
||
const mergeClassNames = (...classNames: (string | undefined)[]): string => {
|
||
const uniqueClassNames = new Set<string>();
|
||
|
||
for (const className of classNames) {
|
||
if (!className) continue;
|
||
|
||
for (const part of className.split(/\s+/)) {
|
||
if (part) uniqueClassNames.add(part);
|
||
}
|
||
}
|
||
|
||
return [...uniqueClassNames].join(" ");
|
||
};
|
||
|
||
const normalizeMarkElements = (root: ReturnType<typeof parse>) => {
|
||
for (const mark of root.querySelectorAll("mark")) {
|
||
const dataColor = mark.getAttribute("data-color");
|
||
|
||
mark.tagName = "span";
|
||
mark.setAttribute("class", mergeClassNames(mark.getAttribute("class"), richTextMarkClassName));
|
||
|
||
// Preserve custom highlight color as inline background-color for react-pdf-html.
|
||
// Legacy marks without data-color fall back to the .rr-pdf-mark stylesheet (yellow).
|
||
if (dataColor) {
|
||
const existingStyle = mark.getAttribute("style") ?? "";
|
||
let inlineStyle = `background-color: ${dataColor}`;
|
||
if (isDarkColor(dataColor)) inlineStyle += "; color: #ffffff";
|
||
mark.setAttribute("style", existingStyle ? `${existingStyle}; ${inlineStyle}` : inlineStyle);
|
||
}
|
||
}
|
||
};
|
||
|
||
const isMeaningfulNode = (node: Node): boolean =>
|
||
node.nodeType !== NodeType.TEXT_NODE || node.toString().trim().length > 0;
|
||
|
||
const isElement = (node: Node): node is HTMLElement => node.nodeType === NodeType.ELEMENT_NODE;
|
||
|
||
const unwrapSingleParagraphListItems = (root: ReturnType<typeof parse>) => {
|
||
for (const listItem of root.querySelectorAll("li")) {
|
||
const meaningfulChildren = listItem.childNodes.filter(isMeaningfulNode);
|
||
if (meaningfulChildren.length !== 1) continue;
|
||
|
||
const child = meaningfulChildren[0];
|
||
if (!child || !isElement(child) || getTagName(child) !== "p") continue;
|
||
|
||
listItem.innerHTML = child.innerHTML;
|
||
}
|
||
};
|
||
|
||
const isInlineNode = (node: Node): boolean => {
|
||
if (node.nodeType === NodeType.TEXT_NODE || node.nodeType === NodeType.COMMENT_NODE) return true;
|
||
if (node.nodeType !== NodeType.ELEMENT_NODE) return false;
|
||
|
||
return inlineTags.has(getTagName(node)) && !hasBlockDescendant(node);
|
||
};
|
||
|
||
// Allow optional leading whitespace + LRM/RLM marks before the bullet character.
|
||
const PSEUDO_BULLET_LEAD = /^[\s]*[-•*]\s+/;
|
||
|
||
const stripEmptyInlineWrappers = (html: string): string =>
|
||
html.replace(/<(strong|b|em|i|u|span)\b[^>]*>\s*<\/\1>/gi, "");
|
||
|
||
// Treat a bare <br> or one wrapped in an inline tag (e.g. `<strong><br></strong>` from
|
||
// the editor) as the segment separator.
|
||
const splitByBreaks = (html: string): string[] =>
|
||
html.split(/(?:<(?:strong|b|em|i|u|span)\b[^>]*>\s*<br\b[^>]*\/?>\s*<\/(?:strong|b|em|i|u|span)>)|<br\b[^>]*\/?>/gi);
|
||
|
||
const tryConvertPseudoBulletParagraph = (paragraphInnerHtml: string): string | null => {
|
||
const cleaned = stripEmptyInlineWrappers(paragraphInnerHtml);
|
||
if (!/<br\b/i.test(cleaned)) return null;
|
||
|
||
const segments: string[] = [];
|
||
for (const segment of splitByBreaks(cleaned)) {
|
||
const trimmed = segment.trim();
|
||
if (trimmed.length > 0) segments.push(trimmed);
|
||
}
|
||
|
||
if (segments.length < 2) return null;
|
||
if (!segments.every((segment) => PSEUDO_BULLET_LEAD.test(segment))) return null;
|
||
|
||
const items = segments.map((segment) => segment.replace(PSEUDO_BULLET_LEAD, ""));
|
||
|
||
return `<ul>${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
|
||
};
|
||
|
||
export const convertPseudoBulletParagraphs = (html: string): string =>
|
||
html.replace(/<p\b([^>]*)>([\s\S]*?)<\/p>/gi, (full, _attrs, inner) => {
|
||
const converted = tryConvertPseudoBulletParagraph(inner);
|
||
return converted ?? full;
|
||
});
|
||
|
||
type NormalizeRichTextHtmlOptions = {
|
||
direction?: "ltr" | "rtl";
|
||
};
|
||
|
||
export const normalizeRichTextHtml = (
|
||
html: string,
|
||
{ direction = "ltr" }: NormalizeRichTextHtmlOptions = {},
|
||
): string => {
|
||
const root = parse(html.trim(), { comment: false });
|
||
const normalized: string[] = [];
|
||
let inlineNodes: string[] = [];
|
||
|
||
normalizeMarkElements(root);
|
||
unwrapSingleParagraphListItems(root);
|
||
|
||
const flushInlineNodes = () => {
|
||
const inlineHtml = inlineNodes.join("").trim();
|
||
|
||
if (inlineHtml) normalized.push(`<p>${inlineHtml}</p>`);
|
||
|
||
inlineNodes = [];
|
||
};
|
||
|
||
for (const node of root.childNodes) {
|
||
const nodeHtml = node.toString();
|
||
|
||
if (isInlineNode(node)) {
|
||
inlineNodes.push(nodeHtml);
|
||
continue;
|
||
}
|
||
|
||
flushInlineNodes();
|
||
normalized.push(nodeHtml);
|
||
}
|
||
|
||
flushInlineNodes();
|
||
|
||
const normalizedHtml = normalized.join("");
|
||
if (direction !== "rtl") return normalizedHtml;
|
||
|
||
// RTL pseudo-bullets must become real list items before both the semantic
|
||
// descriptor and renderer traverse the HTML. RLM anchors each independent
|
||
// react-pdf-html text frame without changing element ancestry or indices.
|
||
return convertPseudoBulletParagraphs(normalizedHtml).replace(
|
||
/<(p|li)\b([^>]*)>/gi,
|
||
(_match, tag, rest) => `<${tag}${rest}>`,
|
||
);
|
||
};
|
||
|
||
export const parseNormalizedRichTextHtml = (html: string, options?: NormalizeRichTextHtmlOptions) =>
|
||
parse(normalizeRichTextHtml(html, options), { comment: false });
|
||
|
||
export const projectNormalizedRichTextHtml = (
|
||
html: string,
|
||
rootNodeKey: string,
|
||
renderedChildKeysFor: (nodeKey: string) => readonly string[] | undefined,
|
||
): string => {
|
||
const root = parse(html, { comment: false });
|
||
const elements = root.querySelectorAll("*");
|
||
|
||
for (const element of elements) {
|
||
if (!getRichTextSemanticKind(element, richTextMarkClassName)) continue;
|
||
element.setAttribute(
|
||
richTextSemanticNodeKeyAttribute,
|
||
getRichTextSemanticNodeKey(rootNodeKey, element, richTextMarkClassName),
|
||
);
|
||
}
|
||
|
||
const visit = (parent: HTMLElement, parentNodeKey: string) => {
|
||
for (const child of parent.childNodes) {
|
||
if (!isElement(child)) continue;
|
||
const childNodeKey = child.getAttribute(richTextSemanticNodeKeyAttribute);
|
||
const childContentNodeKey =
|
||
childNodeKey && getTagName(child) === "li"
|
||
? `${childNodeKey}/list-item-content-0`
|
||
: (childNodeKey ?? parentNodeKey);
|
||
visit(child, childContentNodeKey);
|
||
}
|
||
|
||
const keyedChildren = parent.childNodes.flatMap((child) => {
|
||
if (!isElement(child)) return [];
|
||
const nodeKey = child.getAttribute(richTextSemanticNodeKeyAttribute);
|
||
return nodeKey ? [{ nodeKey, child }] : [];
|
||
});
|
||
if (keyedChildren.length === 0) return;
|
||
const renderedChildKeys = renderedChildKeysFor(parentNodeKey);
|
||
if (!renderedChildKeys) return;
|
||
|
||
const childByNodeKey = new Map(keyedChildren.map(({ nodeKey, child }) => [nodeKey, child]));
|
||
const projected = renderedChildKeys.flatMap((nodeKey) => {
|
||
const child = childByNodeKey.get(nodeKey);
|
||
return child ? [child] : [];
|
||
});
|
||
let projectedIndex = 0;
|
||
const nextChildren = parent.childNodes.flatMap((child) => {
|
||
if (!isElement(child) || !child.getAttribute(richTextSemanticNodeKeyAttribute)) return [child];
|
||
const projectedChild = projected[projectedIndex++];
|
||
return projectedChild ? [projectedChild] : [];
|
||
});
|
||
parent.set_content(nextChildren);
|
||
};
|
||
|
||
visit(root, rootNodeKey);
|
||
return root.toString();
|
||
};
|