Merge pull request #455 from OHTU-German-learning-website/Hotfix-tag-replacement-logic

Hotfix for tag replacement logic
This commit is contained in:
LosEvons
2025-12-03 14:37:06 +02:00
committed by GitHub
2 changed files with 105 additions and 28 deletions
@@ -4,38 +4,97 @@ import {
GlossaryListItem, GlossaryListItem,
} from "@/components/ui/glossary/GlossaryText"; } from "@/components/ui/glossary/GlossaryText";
export default function RenderHTML({ data }) { // ?NOTE The <container> tag still throws an error, but for now I've chosen not to go down that rabbit hole
if (!data) { // ?It seems slightly too complicated for this last sprint.
return null;
/**
* Normalize HTML attributes for use in React elements.
*
* Converts common HTML attribute names to their React equivalents (for
* example `class` -> `className`) and returns a shallow copy so the
* original attribute object is not mutated.
*
* @param {Object} attributes - Raw attributes from the HTML parser.
* @returns {Object} A normalized attributes object safe to spread on React elements.
*/
const normalizeAttribs = (attributes = {}) => {
if (!attributes) return {};
const normalized = { ...attributes };
// Map common HTML attribute names to React prop names.
// Some HTML parsers produce `class`, others produce `classname` (lowercase).
// Ensure we normalize both to `className` and remove the original keys so
// invalid DOM props (like `classname`) are not spread onto React elements.
if (normalized.classname && !normalized.className) {
normalized.className = normalized.classname;
delete normalized.classname;
} }
if (normalized.class && !normalized.className) {
normalized.className = normalized.class;
delete normalized.class;
}
return normalized;
};
// Parse HTML and replace custom glossary tags with React components /**
const parsedContent = parse(data, { * RenderHTML
replace: (domNode) => { *
// Only process element nodes, not text or comment nodes * Safely parses HTML produced by the Quill editor (or other sources) and
if (domNode.type !== "tag") { * replaces a small set of custom tags with React components or safer
return; * equivalents. Uses `html-react-parser` under the hood and processes
} * nested content via a recursive replacer.
*
* Supported tag replacements:
* - `glossaryparagraph` -> `GlossaryParagraph` (React component)
* - `glossarylistitem` -> `GlossaryListItem` (React component)
* - `anchor` -> `span.glossary-anchor[data-href=...]` (avoids nested <a> issues)
*
* @param {{data: string}} props - Component props containing raw HTML string.
* @returns {JSX.Element|null} Parsed React nodes wrapped in a `div`, or `null`.
*/
export default function RenderHTML({ data }) {
if (!data) return null;
// Handle GlossaryParagraph custom tags // Handlers for specific custom tags. Each handler receives normalized
if (domNode.name === "glossaryparagraph") { // attributes and the already-converted children (as React nodes).
// Extract and convert child nodes to React, preserving HTML structure const handlers = {
const children = domNode.children ? domToReact(domNode.children) : null; glossaryparagraph: (attributes, children) => (
return ( <GlossaryParagraph {...attributes}>{children}</GlossaryParagraph>
<GlossaryParagraph {...domNode.attribs}>{children}</GlossaryParagraph> ),
); glossarylistitem: (attributes, children) => (
} <GlossaryListItem {...attributes}>{children}</GlossaryListItem>
),
// Handle GlossaryListItem custom tags column: (attributes, children) => {
if (domNode.name === "glossarylistitem") { const safeAttribs = { ...attributes };
// Extract and convert child nodes to React, preserving HTML structure safeAttribs.className = [safeAttribs.className, "glossary-column"]
const children = domNode.children ? domToReact(domNode.children) : null; .filter(Boolean)
return ( .join(" ");
<GlossaryListItem {...domNode.attribs}>{children}</GlossaryListItem> return <div {...safeAttribs}>{children}</div>; // This replacement should maybe use <Column> if it exists
);
}
}, },
}); anchor: (attributes, children) => {
const safeAttribs = { ...attributes };
const href = safeAttribs.href || safeAttribs["data-href"];
if (href) {
safeAttribs["data-href"] = href;
delete safeAttribs.href;
}
safeAttribs.className = [safeAttribs.className, "glossary-anchor"]
.filter(Boolean)
.join(" ");
return <span {...safeAttribs}>{children}</span>; // This replacement logic should maybe use <Anchor> if it exists
},
};
const replacer = (domNode) => {
if (domNode.type !== "tag") return;
const name = domNode.name;
const normalizedAttributes = normalizeAttribs(domNode.attribs || {});
const children = domNode.children
? domToReact(domNode.children, { replace: replacer })
: null;
const handler = handlers[name];
return handler ? handler(normalizedAttributes, children) : undefined;
};
const parsedContent = parse(data, { replace: replacer });
return <div>{parsedContent}</div>; return <div>{parsedContent}</div>;
} }
@@ -243,6 +243,24 @@ describe("RenderHTML Component", () => {
expect(code).toBeTruthy(); expect(code).toBeTruthy();
expect(code.textContent).toBe("console.log()"); expect(code.textContent).toBe("console.log()");
}); });
it("should render custom anchor tags as span.glossary-anchor with data-href preserved", () => {
const html = '<anchor href="/test">Click me</anchor>';
const { container } = render(<RenderHTML data={html} />);
const anchorSpan = container.querySelector("span.glossary-anchor");
expect(anchorSpan).toBeTruthy();
// data-href becomes dataset.href
expect(anchorSpan.dataset.href).toBe("/test");
expect(anchorSpan.textContent).toBe("Click me");
});
it("should render custom column tags as div.glossary-column and preserve classes/children", () => {
const html = '<column class="chapter">Hello Column</column>';
const { container } = render(<RenderHTML data={html} />);
const columnDiv = container.querySelector("div.chapter.glossary-column");
expect(columnDiv).toBeTruthy();
expect(columnDiv.textContent).toContain("Hello Column");
});
}); });
describe("Performance scenarios", () => { describe("Performance scenarios", () => {