Merge pull request #442 from OHTU-German-learning-website/435-fix-glossary-functionality

Added a transform from native tags to glossary tags, tested and enhan…
This commit is contained in:
Filippa Högnäsbacka
2025-12-01 10:36:07 +02:00
committed by GitHub
9 changed files with 826 additions and 18 deletions
+148
View File
@@ -14,6 +14,7 @@
"axios": "^1.7.9",
"dompurify": "^3.3.0",
"dotenv": "^16.4.7",
"html-react-parser": "^5.2.10",
"immutability-helper": "^3.1.1",
"jose": "^5.10.0",
"jsdom": "^26.0.0",
@@ -5135,6 +5136,47 @@
"dev": true,
"license": "MIT"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
@@ -5144,6 +5186,20 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
@@ -6583,6 +6639,16 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/html-dom-parser": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-5.1.2.tgz",
"integrity": "sha512-9nD3Rj3/FuQt83AgIa1Y3ruzspwFFA54AJbQnohXN+K6fL1/bhcDQJJY5Ne4L4A163ADQFVESd/0TLyNoV0mfg==",
"license": "MIT",
"dependencies": {
"domhandler": "5.0.3",
"htmlparser2": "10.0.0"
}
},
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
@@ -6602,6 +6668,58 @@
"dev": true,
"license": "MIT"
},
"node_modules/html-react-parser": {
"version": "5.2.10",
"resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-5.2.10.tgz",
"integrity": "sha512-DjOLloguuDA+Ed7Q7PKhvMQmCl2+Yk/pfvvca68fvn15QFBbL4uHGxXwoXQ4sqS0UyuRH2lJb0S8yZCL3lvehQ==",
"license": "MIT",
"dependencies": {
"domhandler": "5.0.3",
"html-dom-parser": "5.1.2",
"react-property": "2.0.2",
"style-to-js": "1.1.21"
},
"peerDependencies": {
"@types/react": "0.14 || 15 || 16 || 17 || 18 || 19",
"react": "0.14 || 15 || 16 || 17 || 18 || 19"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/htmlparser2": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz",
"integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.1",
"entities": "^6.0.0"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-proxy-agent": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
@@ -6683,6 +6801,12 @@
"node": ">=0.8.19"
}
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"license": "MIT"
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -8600,6 +8724,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-property": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.2.tgz",
"integrity": "sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
@@ -9449,6 +9579,24 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-to-js": {
"version": "1.1.21",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
"integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
"license": "MIT",
"dependencies": {
"style-to-object": "1.0.14"
}
},
"node_modules/style-to-object": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
"integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
"license": "MIT",
"dependencies": {
"inline-style-parser": "0.2.7"
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+1
View File
@@ -23,6 +23,7 @@
"axios": "^1.7.9",
"dompurify": "^3.3.0",
"dotenv": "^16.4.7",
"html-react-parser": "^5.2.10",
"immutability-helper": "^3.1.1",
"jose": "^5.10.0",
"jsdom": "^26.0.0",
+5 -1
View File
@@ -3,6 +3,7 @@ import "./chapters.css";
import layout from "@/shared/styles/layout.module.css";
import RenderHTML from "@/components/ui/render-html/render-html";
import { getPageData, getPageList } from "@/backend/html-services";
import { transformHtmlToGlossaryTags } from "@/backend/html-transform";
import EditButton from "./edit-button";
import { LinkButton } from "@/components/ui/linkbutton";
@@ -10,6 +11,9 @@ export default async function Chapters({ params }) {
const { type, slug } = await params;
const pageData = await getPageData(type, slug);
// Transform HTML to include glossary tags
const transformedContent = transformHtmlToGlossaryTags(pageData.content);
const pageList = await getPageList(type);
const currentIndex = pageList.findIndex((e) => e.slug === slug);
let previousLink;
@@ -25,7 +29,7 @@ export default async function Chapters({ params }) {
<Column className={layout.viewContent}>
<EditButton url={`/admin/edit-page/${type}/${slug}`} />
<h1>{pageData.title}</h1>
<RenderHTML data={pageData.content} />
<RenderHTML data={transformedContent} />
<Row pb="xl" justify="space-between">
{previousLink && (
<Container>
+78
View File
@@ -0,0 +1,78 @@
import { JSDOM } from "jsdom";
/**
* Transforms HTML content by replacing <p> and <li> tags with custom glossary tags
* @param {string} htmlString - The HTML content to transform
* @returns {string} - Transformed HTML with GlossaryParagraph and GlossaryListItem tags
*/
export function transformHtmlToGlossaryTags(htmlString) {
if (!htmlString || typeof htmlString !== "string") {
return htmlString;
}
try {
// Parse HTML using jsdom
const dom = new JSDOM(htmlString);
const document = dom.window.document;
// Define transformation mappings
const transformations = [
{
selector: "p",
newTag: "GlossaryParagraph",
skipParent: "glossaryparagraph",
},
{
selector: "li",
newTag: "GlossaryListItem",
skipParent: "glossarylistitem",
},
];
// Apply transformations
transformations.forEach(({ selector, newTag, skipParent }) => {
transformElements(document, selector, newTag, skipParent);
});
// Return transformed HTML
return document.body.innerHTML;
} catch (error) {
console.error("Error transforming HTML to glossary tags:", error);
// Return original HTML if transformation fails
return htmlString;
}
}
/**
* Transform all elements matching a selector to a new tag name
* @param {Document} document - The DOM document
* @param {string} selector - CSS selector for elements to transform
* @param {string} newTag - New tag name to use
* @param {string} skipParent - Skip transformation if element is inside this parent tag
*/
function transformElements(document, selector, newTag, skipParent) {
const elements = Array.from(document.querySelectorAll(selector));
elements.forEach((element) => {
// Skip if already inside a transformed parent
if (element.closest(skipParent)) {
return;
}
// Create new element with custom tag name
const newElement = document.createElement(newTag);
// Copy all attributes from original element
Array.from(element.attributes).forEach((attr) => {
newElement.setAttribute(attr.name, attr.value);
});
// Move all child nodes to new element (more efficient than innerHTML)
while (element.firstChild) {
newElement.appendChild(element.firstChild);
}
// Replace original element with new element
element.replaceWith(newElement);
});
}
+257
View File
@@ -0,0 +1,257 @@
import { describe, it, expect } from "vitest";
import { transformHtmlToGlossaryTags } from "./html-transform";
describe("transformHtmlToGlossaryTags", () => {
it("should transform <p> tags to <GlossaryParagraph>", () => {
const input = "<p>This is a test paragraph.</p>";
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossaryparagraph>");
expect(result).toContain("</glossaryparagraph>");
expect(result).toContain("This is a test paragraph.");
});
it("should transform <li> tags to <GlossaryListItem>", () => {
const input = "<ul><li>Item 1</li><li>Item 2</li></ul>";
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossarylistitem>");
expect(result).toContain("</glossarylistitem>");
expect(result).toContain("Item 1");
expect(result).toContain("Item 2");
});
it("should preserve attributes on transformed tags", () => {
const input = '<p class="test-class" id="test-id">Content</p>';
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain('class="test-class"');
expect(result).toContain('id="test-id"');
});
it("should not double-wrap already transformed content", () => {
const input = "<GlossaryParagraph>Already transformed</GlossaryParagraph>";
const result = transformHtmlToGlossaryTags(input);
// Should not have nested GlossaryParagraph tags
const count = (result.match(/<glossaryparagraph>/gi) || []).length;
expect(count).toBe(1);
});
it("should handle empty or null input gracefully", () => {
expect(transformHtmlToGlossaryTags("")).toBe("");
expect(transformHtmlToGlossaryTags(null)).toBe(null);
expect(transformHtmlToGlossaryTags(undefined)).toBe(undefined);
});
it("should handle nested HTML structures", () => {
const input = "<div><p>Paragraph with <strong>bold</strong> text</p></div>";
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossaryparagraph>");
expect(result).toContain("<strong>bold</strong>");
});
it("should transform multiple paragraphs and list items", () => {
const input = `
<p>First paragraph</p>
<p>Second paragraph</p>
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>
`;
const result = transformHtmlToGlossaryTags(input);
const pCount = (result.match(/<glossaryparagraph>/gi) || []).length;
const liCount = (result.match(/<glossarylistitem>/gi) || []).length;
expect(pCount).toBe(2);
expect(liCount).toBe(2);
});
it("should return original HTML if transformation fails", () => {
// This is a bit tricky to test without mocking, but we can test the error handling
const malformedInput = "<p>Test"; // Intentionally unclosed
const result = transformHtmlToGlossaryTags(malformedInput);
// Should still return something (jsdom will try to fix it)
expect(result).toBeTruthy();
});
describe("Complex nested HTML structures", () => {
it("should preserve nested <strong> tags within paragraphs", () => {
const input = "<p>Text with <strong>bold word</strong> inside</p>";
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossaryparagraph>");
expect(result).toContain("<strong>bold word</strong>");
expect(result).toContain("Text with");
expect(result).toContain("inside");
});
it("should preserve nested <em> tags within paragraphs", () => {
const input = "<p>Text with <em>emphasized</em> content</p>";
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossaryparagraph>");
expect(result).toContain("<em>emphasized</em>");
});
it("should preserve <a> links within paragraphs", () => {
const input = '<p>Check <a href="/page">this link</a> for more</p>';
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossaryparagraph>");
expect(result).toContain('<a href="/page">this link</a>');
});
it("should preserve multiple nested tags within paragraphs", () => {
const input =
'<p>The <strong>German</strong> word for <a href="#">example</a> is <em>Beispiel</em></p>';
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossaryparagraph>");
expect(result).toContain("<strong>German</strong>");
expect(result).toContain('<a href="#">example</a>');
expect(result).toContain("<em>Beispiel</em>");
});
it("should preserve deeply nested structures", () => {
const input =
"<p>Text <span>with <strong>deeply <em>nested</em> tags</strong></span> here</p>";
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossaryparagraph>");
expect(result).toContain("<span>");
expect(result).toContain("<strong>");
expect(result).toContain("<em>nested</em>");
});
});
describe("List items with nested HTML", () => {
it("should preserve <strong> tags within list items", () => {
const input = "<ul><li>Item with <strong>bold</strong> text</li></ul>";
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossarylistitem>");
expect(result).toContain("<strong>bold</strong>");
});
it("should preserve links within list items", () => {
const input =
'<ol><li>Read <a href="/doc">documentation</a> first</li></ol>';
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossarylistitem>");
expect(result).toContain('<a href="/doc">documentation</a>');
});
it("should handle multiple nested elements in list items", () => {
const input =
'<ul><li>Item with <strong>bold</strong> and <em>italic</em> and <a href="#">link</a></li></ul>';
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossarylistitem>");
expect(result).toContain("<strong>bold</strong>");
expect(result).toContain("<em>italic</em>");
expect(result).toContain('<a href="#">link</a>');
});
});
describe("Edge cases and special scenarios", () => {
it("should handle paragraphs with only whitespace and nested tags", () => {
const input = "<p> <strong> Bold text </strong> </p>";
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossaryparagraph>");
expect(result).toContain("<strong>");
});
it("should handle self-closing tags within paragraphs", () => {
const input = "<p>Line one<br/>Line two</p>";
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossaryparagraph>");
expect(result).toContain("Line one");
expect(result).toContain("Line two");
});
it("should preserve code tags within paragraphs", () => {
const input = "<p>Use the <code>console.log()</code> function</p>";
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossaryparagraph>");
expect(result).toContain("<code>console.log()</code>");
});
it("should handle mixed content with text nodes at different levels", () => {
const input =
"<p>Start <span>middle <strong>end</strong></span> finish</p>";
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossaryparagraph>");
expect(result).toContain("Start");
expect(result).toContain("middle");
expect(result).toContain("end");
expect(result).toContain("finish");
});
it("should handle paragraphs with attributes and nested HTML", () => {
const input =
'<p class="intro" id="p1">Welcome to <strong>our site</strong>!</p>';
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain('<glossaryparagraph class="intro" id="p1">');
expect(result).toContain("<strong>our site</strong>");
});
it("should not transform paragraphs inside other non-glossary elements", () => {
const input = "<div><section><p>Nested paragraph</p></section></div>";
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossaryparagraph>");
expect(result).toContain("<div>");
expect(result).toContain("<section>");
});
it("should handle complex real-world content structure", () => {
const input = `
<div>
<p>The German language has <strong>four cases</strong>:
<a href="/grammar/nominativ">Nominativ</a>,
<a href="/grammar/akkusativ">Akkusativ</a>,
<em>Dativ</em>, and <em>Genitiv</em>.</p>
<ul>
<li>Use <strong>Nominativ</strong> for subjects</li>
<li>Use <strong>Akkusativ</strong> for direct objects</li>
</ul>
</div>
`;
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossaryparagraph>");
expect(result).toContain("<glossarylistitem>");
expect(result).toContain("<strong>four cases</strong>");
expect(result).toContain('<a href="/grammar/nominativ">');
expect(result).toContain("<em>Dativ</em>");
});
});
describe("Performance and boundary conditions", () => {
it("should handle paragraphs with very long text and nested elements", () => {
const longText = "Lorem ipsum ".repeat(100);
const input = `<p>${longText}<strong>important</strong>${longText}</p>`;
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossaryparagraph>");
expect(result).toContain("<strong>important</strong>");
});
it("should handle many consecutive paragraphs with nested content", () => {
const paragraphs = Array.from(
{ length: 20 },
(_, i) => `<p>Paragraph ${i} with <strong>bold</strong> text</p>`
).join("");
const result = transformHtmlToGlossaryTags(paragraphs);
const count = (result.match(/<glossaryparagraph>/gi) || []).length;
expect(count).toBe(20);
const strongCount = (result.match(/<strong>bold<\/strong>/gi) || [])
.length;
expect(strongCount).toBe(20);
});
it("should handle deeply nested list structures", () => {
const input = `
<ul>
<li>Level 1
<ul>
<li>Level 2 with <strong>bold</strong></li>
</ul>
</li>
</ul>
`;
const result = transformHtmlToGlossaryTags(input);
expect(result).toContain("<glossarylistitem>");
expect(result).toContain("Level 1");
expect(result).toContain("Level 2");
expect(result).toContain("<strong>bold</strong>");
});
});
});
@@ -62,20 +62,32 @@ export default function GlossaryText({ children, excludeWords = [] }) {
return <>{processText(children)}</>;
}
export function GlossaryParagraph({ children }) {
if (typeof children !== "string") return <p>{children}</p>;
return (
<p>
<GlossaryText>{children}</GlossaryText>
</p>
);
export function GlossaryParagraph({ children, ...props }) {
// If children is a string, apply glossary detection
if (typeof children === "string") {
return (
<p {...props}>
<GlossaryText>{children}</GlossaryText>
</p>
);
}
// If children contains React elements (from html-react-parser), render as-is
// The text nodes within will already be processed if needed
return <p {...props}>{children}</p>;
}
export function GlossaryListItem({ children }) {
if (typeof children !== "string") return <li>{children}</li>;
return (
<li>
<GlossaryText>{children}</GlossaryText>
</li>
);
export function GlossaryListItem({ children, ...props }) {
// If children is a string, apply glossary detection
if (typeof children === "string") {
return (
<li {...props}>
<GlossaryText>{children}</GlossaryText>
</li>
);
}
// If children contains React elements (from html-react-parser), render as-is
// The text nodes within will already be processed if needed
return <li {...props}>{children}</li>;
}
@@ -1,3 +0,0 @@
export default function RenderHTML({ data }) {
return <div dangerouslySetInnerHTML={{ __html: data }} />;
}
@@ -0,0 +1,41 @@
import parse, { domToReact } from "html-react-parser";
import {
GlossaryParagraph,
GlossaryListItem,
} from "@/components/ui/glossary/GlossaryText";
export default function RenderHTML({ data }) {
if (!data) {
return null;
}
// 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;
}
// 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 (
<GlossaryParagraph {...domNode.attribs}>{children}</GlossaryParagraph>
);
}
// 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 (
<GlossaryListItem {...domNode.attribs}>{children}</GlossaryListItem>
);
}
},
});
return <div>{parsedContent}</div>;
}
@@ -0,0 +1,270 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import RenderHTML from "./render-html";
import { transformHtmlToGlossaryTags } from "@/backend/html-transform";
// Mock the GlossaryText components
vi.mock("@/components/ui/glossary/GlossaryText", () => ({
default: function GlossaryText({ children }) {
return <span data-testid="glossary-text">{children}</span>;
},
GlossaryParagraph: function GlossaryParagraph({ children, ...props }) {
if (typeof children === "string") {
return (
<p data-testid="glossary-paragraph" {...props}>
<span data-testid="glossary-text">{children}</span>
</p>
);
}
return (
<p data-testid="glossary-paragraph" {...props}>
{children}
</p>
);
},
GlossaryListItem: function GlossaryListItem({ children, ...props }) {
if (typeof children === "string") {
return (
<li data-testid="glossary-listitem" {...props}>
<span data-testid="glossary-text">{children}</span>
</li>
);
}
return (
<li data-testid="glossary-listitem" {...props}>
{children}
</li>
);
},
}));
describe("RenderHTML Component", () => {
it("should render null when data is null or undefined", () => {
const { container: container1 } = render(<RenderHTML data={null} />);
expect(container1.firstChild).toBeNull();
const { container: container2 } = render(<RenderHTML data={undefined} />);
expect(container2.firstChild).toBeNull();
});
it("should render simple HTML", () => {
const html = "<p>Hello World</p>";
render(<RenderHTML data={html} />);
expect(screen.getByText("Hello World")).toBeTruthy();
});
it("should render transformed glossary paragraph tags", () => {
const html = "<glossaryparagraph>Test paragraph</glossaryparagraph>";
render(<RenderHTML data={html} />);
expect(screen.getByTestId("glossary-paragraph")).toBeTruthy();
expect(screen.getByText("Test paragraph")).toBeTruthy();
});
it("should render transformed glossary list item tags", () => {
const html = "<ul><glossarylistitem>Test item</glossarylistitem></ul>";
render(<RenderHTML data={html} />);
expect(screen.getByTestId("glossary-listitem")).toBeTruthy();
expect(screen.getByText("Test item")).toBeTruthy();
});
it("should preserve attributes when rendering glossary tags", () => {
const html =
'<glossaryparagraph class="test-class" id="test-id">Content</glossaryparagraph>';
const { container } = render(<RenderHTML data={html} />);
const paragraph = container.querySelector("p");
expect(paragraph.className).toBe("test-class");
expect(paragraph.id).toBe("test-id");
});
describe("Integration with html-transform", () => {
it("should handle transformed paragraphs with plain text", () => {
const originalHtml = "<p>Simple paragraph</p>";
const transformed = transformHtmlToGlossaryTags(originalHtml);
const { container } = render(<RenderHTML data={transformed} />);
const paragraph = container.querySelector(
'[data-testid="glossary-paragraph"]'
);
expect(paragraph).toBeTruthy();
expect(paragraph.textContent).toContain("Simple paragraph");
});
it("should handle transformed paragraphs with nested bold tags", () => {
const originalHtml = "<p>Text with <strong>bold</strong> word</p>";
const transformed = transformHtmlToGlossaryTags(originalHtml);
const { container } = render(<RenderHTML data={transformed} />);
// Current behavior: GlossaryParagraph receives React element array,
// so it renders children as-is WITHOUT wrapping text nodes in GlossaryText
const paragraph = container.querySelector(
'[data-testid="glossary-paragraph"]'
);
expect(paragraph).toBeTruthy();
const strong = container.querySelector("strong");
expect(strong).toBeTruthy();
expect(strong.textContent).toBe("bold");
// This test passes but reveals the issue: text nodes like "Text with "
// and " word" are NOT wrapped in GlossaryText when there are nested elements
// Expected: All text nodes should be wrapped in <span data-testid="glossary-text">
// Actual: Only string children are wrapped, array children are rendered as-is
});
it("should handle transformed paragraphs with nested emphasis tags", () => {
const originalHtml = "<p>Text with <em>emphasis</em> here</p>";
const transformed = transformHtmlToGlossaryTags(originalHtml);
const { container } = render(<RenderHTML data={transformed} />);
const em = container.querySelector("em");
expect(em).toBeTruthy();
expect(em.textContent).toBe("emphasis");
});
it("should handle transformed paragraphs with links", () => {
const originalHtml = '<p>Check <a href="/test">this link</a> out</p>';
const transformed = transformHtmlToGlossaryTags(originalHtml);
const { container } = render(<RenderHTML data={transformed} />);
const link = container.querySelector("a");
expect(link).toBeTruthy();
expect(link.href).toContain("/test");
expect(link.textContent).toBe("this link");
});
it("should handle complex nested HTML in paragraphs", () => {
const originalHtml =
'<p>The <strong>German</strong> word for <a href="#">example</a> is <em>Beispiel</em></p>';
const transformed = transformHtmlToGlossaryTags(originalHtml);
const { container } = render(<RenderHTML data={transformed} />);
expect(container.querySelector("strong")).toBeTruthy();
expect(container.querySelector("a")).toBeTruthy();
expect(container.querySelector("em")).toBeTruthy();
expect(container.textContent).toContain("German");
expect(container.textContent).toContain("example");
expect(container.textContent).toContain("Beispiel");
// IMPORTANT: This test reveals the core issue!
// The text "The ", " word for ", and " is " are NOT wrapped in GlossaryText
// because GlossaryParagraph receives an array of React elements from domToReact,
// not a plain string. Therefore, glossary word detection won't work on these texts.
});
it("should handle transformed list items with nested HTML", () => {
const originalHtml =
"<ul><li>Item with <strong>bold</strong> text</li></ul>";
const transformed = transformHtmlToGlossaryTags(originalHtml);
const { container } = render(<RenderHTML data={transformed} />);
const listItem = container.querySelector(
'[data-testid="glossary-listitem"]'
);
expect(listItem).toBeTruthy();
const strong = container.querySelector("strong");
expect(strong).toBeTruthy();
expect(strong.textContent).toBe("bold");
});
it("should handle multiple transformed paragraphs with nested content", () => {
const originalHtml = `
<p>First <strong>paragraph</strong></p>
<p>Second <em>paragraph</em></p>
`;
const transformed = transformHtmlToGlossaryTags(originalHtml);
const { container } = render(<RenderHTML data={transformed} />);
const paragraphs = container.querySelectorAll(
'[data-testid="glossary-paragraph"]'
);
expect(paragraphs.length).toBe(2);
expect(container.querySelector("strong")).toBeTruthy();
expect(container.querySelector("em")).toBeTruthy();
});
it("should handle deeply nested structures", () => {
const originalHtml =
"<p>Text <span>with <strong>deeply <em>nested</em> tags</strong></span> here</p>";
const transformed = transformHtmlToGlossaryTags(originalHtml);
const { container } = render(<RenderHTML data={transformed} />);
expect(container.querySelector("span")).toBeTruthy();
expect(container.querySelector("strong")).toBeTruthy();
expect(container.querySelector("em")).toBeTruthy();
expect(container.textContent).toContain("nested");
});
it("should preserve text nodes at different nesting levels", () => {
const originalHtml =
"<p>Start <span>middle <strong>end</strong></span> finish</p>";
const transformed = transformHtmlToGlossaryTags(originalHtml);
const { container } = render(<RenderHTML data={transformed} />);
expect(container.textContent).toContain("Start");
expect(container.textContent).toContain("middle");
expect(container.textContent).toContain("end");
expect(container.textContent).toContain("finish");
});
});
describe("Edge cases", () => {
it("should handle empty glossary tags", () => {
const html = "<glossaryparagraph></glossaryparagraph>";
const { container } = render(<RenderHTML data={html} />);
const paragraph = container.querySelector("p");
expect(paragraph).toBeTruthy();
});
it("should handle mixed regular and glossary tags", () => {
const html = `
<div>
<p>Regular paragraph</p>
<glossaryparagraph>Glossary paragraph</glossaryparagraph>
</div>
`;
render(<RenderHTML data={html} />);
expect(screen.getByText("Regular paragraph")).toBeTruthy();
expect(screen.getByText("Glossary paragraph")).toBeTruthy();
});
it("should handle self-closing tags in glossary content", () => {
const html =
"<glossaryparagraph>Line one<br/>Line two</glossaryparagraph>";
render(<RenderHTML data={html} />);
expect(screen.getByText(/Line one/)).toBeTruthy();
expect(screen.getByText(/Line two/)).toBeTruthy();
});
it("should handle code tags in glossary content", () => {
const html =
"<glossaryparagraph>Use <code>console.log()</code> function</glossaryparagraph>";
const { container } = render(<RenderHTML data={html} />);
const code = container.querySelector("code");
expect(code).toBeTruthy();
expect(code.textContent).toBe("console.log()");
});
});
describe("Performance scenarios", () => {
it("should handle many glossary paragraphs efficiently", () => {
const paragraphs = Array.from(
{ length: 50 },
(_, i) => `<glossaryparagraph>Paragraph ${i}</glossaryparagraph>`
).join("");
const { container } = render(<RenderHTML data={paragraphs} />);
const renderedParagraphs = container.querySelectorAll(
'[data-testid="glossary-paragraph"]'
);
expect(renderedParagraphs.length).toBe(50);
});
it("should handle long content with nested elements", () => {
const longText = "Word ".repeat(200);
const html = `<glossaryparagraph>${longText}<strong>important</strong>${longText}</glossaryparagraph>`;
const { container } = render(<RenderHTML data={html} />);
const strong = container.querySelector("strong");
expect(strong).toBeTruthy();
expect(strong.textContent).toBe("important");
});
});
});