mirror of
https://github.com/OHTU-German-learning-website/OHTU-German-learning-website.git
synced 2026-10-12 19:13:51 +03:00
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:
Generated
+148
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user