Skip to content

Commit 7d576d1

Browse files
fix: allow HTML5 void elements in innerHTML by switching to text/html parser for fragment assembly
Signed-off-by: Abhishek-Punhani <punhani.manavabhi@gmail.com>
1 parent 7ef2d2c commit 7d576d1

2 files changed

Lines changed: 56 additions & 4 deletions

File tree

contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/assembleItem.spec.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,58 @@ describe('assembleItem', () => {
143143
).toThrow('mutually exclusive');
144144
});
145145
});
146+
147+
describe('innerHTML — HTML5 void elements (TipTap regression)', () => {
148+
it('handles a bare <br> without producing a parseerror in the XML output', () => {
149+
const node = buildXmlNode({
150+
tag: 'qti-prompt',
151+
innerHTML: '<p>Line one</p><br><p>Line two</p>',
152+
});
153+
// Serialized output must be valid XML
154+
const xml = serializer.serializeToString(node);
155+
const doc = new DOMParser().parseFromString(xml, 'text/xml');
156+
expect(doc.querySelector('parsererror')).toBeNull();
157+
});
158+
159+
it('preserves text content when innerHTML contains a <br>', () => {
160+
const node = buildXmlNode({
161+
tag: 'qti-prompt',
162+
innerHTML: '<p>First</p><br><p>Second</p>',
163+
});
164+
expect(node.textContent).toContain('First');
165+
expect(node.textContent).toContain('Second');
166+
});
167+
168+
it('handles an unclosed <img> without producing a parseerror', () => {
169+
const node = buildXmlNode({
170+
tag: 'qti-simple-choice',
171+
innerHTML: '<img src="cat.png" alt="cat"><span>A cat</span>',
172+
});
173+
const xml = serializer.serializeToString(node);
174+
const doc = new DOMParser().parseFromString(xml, 'text/xml');
175+
expect(doc.querySelector('parsererror')).toBeNull();
176+
expect(node.textContent).toContain('A cat');
177+
});
178+
179+
it('self-closes void elements in the serialized XML output', () => {
180+
const node = buildXmlNode({ tag: 'qti-prompt', innerHTML: 'Hello<br>World' });
181+
const xml = serializer.serializeToString(node);
182+
// <br> must be self-closed in the output (br/ or br with no children)
183+
expect(xml).toMatch(/<br[\s/]/);
184+
// And the result must still parse without error
185+
expect(
186+
new DOMParser().parseFromString(xml, 'text/xml').querySelector('parsererror'),
187+
).toBeNull();
188+
});
189+
190+
it('preserves well-formed HTML markup correctly', () => {
191+
const node = buildXmlNode({
192+
tag: 'qti-simple-choice',
193+
innerHTML: '<p>Option <strong>A</strong></p>',
194+
});
195+
expect(node.querySelector('strong').textContent).toBe('A');
196+
});
197+
});
146198
});
147199

148200
describe('assembleItemXml', () => {

contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/assembleItem.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,11 @@ export function buildXmlNode({ tag, attrs = {}, children, innerHTML }) {
3838
}
3939

4040
if (innerHTML !== undefined) {
41-
const parsed = new DOMParser().parseFromString(
42-
`<qti-fragment>${innerHTML}</qti-fragment>`,
43-
'text/xml',
41+
const htmlDoc = new DOMParser().parseFromString(
42+
`<!DOCTYPE html><body>${innerHTML}</body>`,
43+
'text/html',
4444
);
45-
for (const child of [...parsed.documentElement.childNodes]) {
45+
for (const child of [...htmlDoc.body.childNodes]) {
4646
el.appendChild(xmlDoc.importNode(child, true));
4747
}
4848
} else {

0 commit comments

Comments
 (0)