diff --git a/gradesa/src/components/ui/render-html/render-html.jsx b/gradesa/src/components/ui/render-html/render-html.jsx index a497113..0307bff 100644 --- a/gradesa/src/components/ui/render-html/render-html.jsx +++ b/gradesa/src/components/ui/render-html/render-html.jsx @@ -4,38 +4,97 @@ import { GlossaryListItem, } from "@/components/ui/glossary/GlossaryText"; -export default function RenderHTML({ data }) { - if (!data) { - return null; +// ?NOTE The tag still throws an error, but for now I've chosen not to go down that rabbit hole +// ?It seems slightly too complicated for this last sprint. + +/** + * 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, { - replace: (domNode) => { - // Only process element nodes, not text or comment nodes - if (domNode.type !== "tag") { - return; - } +/** + * RenderHTML + * + * Safely parses HTML produced by the Quill editor (or other sources) and + * replaces a small set of custom tags with React components or safer + * 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 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 - if (domNode.name === "glossaryparagraph") { - // Extract and convert child nodes to React, preserving HTML structure - const children = domNode.children ? domToReact(domNode.children) : null; - return ( - {children} - ); - } - - // Handle GlossaryListItem custom tags - if (domNode.name === "glossarylistitem") { - // Extract and convert child nodes to React, preserving HTML structure - const children = domNode.children ? domToReact(domNode.children) : null; - return ( - {children} - ); - } + // Handlers for specific custom tags. Each handler receives normalized + // attributes and the already-converted children (as React nodes). + const handlers = { + glossaryparagraph: (attributes, children) => ( + {children} + ), + glossarylistitem: (attributes, children) => ( + {children} + ), + column: (attributes, children) => { + const safeAttribs = { ...attributes }; + safeAttribs.className = [safeAttribs.className, "glossary-column"] + .filter(Boolean) + .join(" "); + return
{children}
; // This replacement should maybe use 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 {children}; // This replacement logic should maybe use 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
{parsedContent}
; } diff --git a/gradesa/src/components/ui/render-html/render-html.test.jsx b/gradesa/src/components/ui/render-html/render-html.test.jsx index aa55dc9..78e33a3 100644 --- a/gradesa/src/components/ui/render-html/render-html.test.jsx +++ b/gradesa/src/components/ui/render-html/render-html.test.jsx @@ -243,6 +243,24 @@ describe("RenderHTML Component", () => { expect(code).toBeTruthy(); expect(code.textContent).toBe("console.log()"); }); + + it("should render custom anchor tags as span.glossary-anchor with data-href preserved", () => { + const html = 'Click me'; + const { container } = render(); + 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 = 'Hello Column'; + const { container } = render(); + const columnDiv = container.querySelector("div.chapter.glossary-column"); + expect(columnDiv).toBeTruthy(); + expect(columnDiv.textContent).toContain("Hello Column"); + }); }); describe("Performance scenarios", () => {