From f5125b09fd263074686715127c39aebc00c22bf4 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Tue, 6 Oct 2026 19:21:46 -0700 Subject: [PATCH 1/3] fix(qti): stop raw-text elements reviving escaped markup in serializeAsHtml - Drop noscript, script and style, as TipTap does. - Turn iframe, noembed, noframes, plaintext and xmp into their text. Co-Authored-By: Claude Opus 5.5 --- .../serialization/__tests__/xml.spec.js | 21 +++++++++++++++++++ .../views/QTIEditor/serialization/xml.js | 17 ++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/xml.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/xml.spec.js index b59b0ce680..4f97513d8f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/xml.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/xml.spec.js @@ -338,6 +338,27 @@ describe('serializeAsHtml', () => { expect(math.namespaceURI).toBe(MATHML_NS); }); + const breakout = tag => `</${tag}><img src="x"/>`; + + it.each(['noscript', 'script', 'style'])('drops a <%s>', tag => { + const html = serializeAsHtml( + children(`

<${tag}>${breakout(tag)}after

`), + ); + expect(html).toBe('

after

'); + expect(parseXML(html, 'text/html').querySelector('img')).toBeNull(); + }); + + it.each(['iframe', 'noembed', 'noframes', 'plaintext', 'xmp'])( + 'keeps the text of a <%s> as escaped text, so markup in it does not come back live', + tag => { + const html = serializeAsHtml( + children(`

a<${tag}>${breakout(tag)}after

`), + ); + expect(html).toBe(`

a${breakout(tag)}after

`); + expect(parseXML(html, 'text/html').querySelector('img')).toBeNull(); + }, + ); + it('skips comments', () => { expect(serializeAsHtml(children('

a

'))).toBe('

a

'); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js index fcedc997ae..486960ee8f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js @@ -59,6 +59,8 @@ export function getPromptHTML(interactionEl) { const xmlDoc = parser.parseFromString('', 'text/xml'); const XHTML_NS = 'http://www.w3.org/1999/xhtml'; +const DROPPED_ELEMENTS = new Set(['noscript', 'script', 'style']); +const RAW_TEXT_ELEMENTS = new Set(['iframe', 'noembed', 'noframes', 'plaintext', 'xmp']); /** * Re-create a node parsed from HTML inside the XML document. @@ -74,7 +76,8 @@ const XHTML_NS = 'http://www.w3.org/1999/xhtml'; * @param {Document} [doc] - Document to re-create the node in * @param {string|null} [plainNamespace] - Namespace whose elements become the document's * default ones; any other is kept - * @returns {Node|null} null for node types that carry no content (comments, etc.) + * @returns {Node|null} null for node types that carry no content (comments, etc.) and, when + * re-creating into an HTML document, for noscript, script and style elements */ function adoptNode(node, doc = xmlDoc, plainNamespace = XHTML_NS) { if (node.nodeType === Node.TEXT_NODE || node.nodeType === Node.CDATA_SECTION_NODE) { @@ -90,6 +93,18 @@ function adoptNode(node, doc = xmlDoc, plainNamespace = XHTML_NS) { ? doc.createElement(node.localName) : doc.createElementNS(namespace, node.tagName); + // The HTML serializer writes these elements' text unescaped, so escaped markup in it + // would come back live. TipTap ignores noscript, script and style, and keeps only the + // others' text. + if (el.namespaceURI === XHTML_NS) { + if (DROPPED_ELEMENTS.has(el.localName)) { + return null; + } + if (RAW_TEXT_ELEMENTS.has(el.localName)) { + return doc.createTextNode(node.textContent); + } + } + for (const attr of node.attributes) { // A literal xmlns attribute would re-introduce the namespace we just dropped. if (attr.name !== 'xmlns') { From 2458270a84a1294de38fbf8ecb5107c7e49bf504 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Tue, 6 Oct 2026 19:21:52 -0700 Subject: [PATCH 2/3] fix(qti): keep text after a formula when reopening a question Prompts, options and hints were read back with XML innerHTML, which self-closes the empty formula span; TipTap then nested the trailing text inside it and dropped it. Read them with serializeAsHtml instead, via a shared getContentHTML. Fixes #6269 Co-Authored-By: Claude Opus 5.5 --- .../QTIEditor/interactions/associate/parse.js | 4 +- .../choice/__tests__/validation.spec.js | 19 +++ .../QTIEditor/interactions/choice/parse.js | 4 +- .../QTIEditor/interactions/match/parse.js | 4 +- .../QTIEditor/interactions/ordering/parse.js | 4 +- .../__tests__/formulaItem.spec.js | 117 ++++++++++++++++++ .../serialization/__tests__/hints.spec.js | 2 +- .../views/QTIEditor/serialization/hints.js | 15 +-- .../views/QTIEditor/serialization/xml.js | 23 ++-- .../utils/__tests__/richText.spec.js | 2 + .../shared/views/QTIEditor/utils/richText.js | 8 +- .../TipTapEditor/utils/imageSrc.js | 27 ++-- .../TipTapEditor/__tests__/imageSrc.spec.js | 14 +++ 13 files changed, 205 insertions(+), 38 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/formulaItem.spec.js diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/parse.js index 5560a7780b..908db6eb14 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/associate/parse.js @@ -1,7 +1,7 @@ import flatMap from 'lodash/flatMap'; import flatten from 'lodash/flatten'; import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; -import { buildXmlNode, getPromptHTML, parseXML } from '../../serialization/xml'; +import { buildXmlNode, getContentHTML, getPromptHTML, parseXML } from '../../serialization/xml'; import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; import { hasRichTextContent, richTextComparisonKey } from '../../utils/richText'; @@ -82,7 +82,7 @@ export function parseAssociateInteraction(bodyXml, responseDeclarations) { const pool = [...root.querySelectorAll('qti-simple-associable-choice')].map(el => ({ id: el.getAttribute('identifier') || generateRandomSlug('choice'), - content: el.innerHTML, + content: getContentHTML(el), matchMax: parseInt(el.getAttribute('match-max'), 10) || 1, })); const poolById = new Map(pool.map(choice => [choice.id, choice])); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validation.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validation.spec.js index 50aee678c7..9f6e8224e2 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validation.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/__tests__/validation.spec.js @@ -102,6 +102,25 @@ describe('validate()', () => { expect(errorIds).not.toContain('e'); }); + it('flags a stored formula option and the same formula typed in again', () => { + const { choices } = choiceInteractionDescriptor.parse( + ` +

+
`, + [], + ); + const state = makeState({ + choices: [ + ...choices, + makeAnswer({ id: 'z', content: '

' }), + ], + }); + const errors = validate(state, QuestionType.SINGLE_SELECT); + expect( + errors.filter(e => e.code === ValidationError.DUPLICATE_CHOICE_CONTENT).map(e => e.id), + ).toEqual(['a', 'z']); + }); + it('does not return error when choices have unique text content', () => { const state = makeState({ choices: [ diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js index c8239bd75e..df809cc9be 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/choice/parse.js @@ -1,5 +1,5 @@ import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; -import { buildXmlNode, getPromptHTML, parseXML } from '../../serialization/xml'; +import { buildXmlNode, getContentHTML, getPromptHTML, parseXML } from '../../serialization/xml'; import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; import { Orientation, QuestionType, RESPONSE_IDENTIFIER } from '../../constants'; @@ -90,7 +90,7 @@ export function parseChoiceInteraction(bodyXml, responseDeclarations) { const choices = [...root.querySelectorAll('qti-simple-choice')].map(el => ({ id: el.getAttribute('identifier') || generateRandomSlug('choice'), - content: el.innerHTML, + content: getContentHTML(el), correct: correctIds.has(el.getAttribute('identifier') ?? ''), fixed: el.getAttribute('fixed') === 'true', })); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js index c3f866287e..ae39c65d61 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/match/parse.js @@ -1,5 +1,5 @@ import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; -import { buildXmlNode, getPromptHTML, parseXML } from '../../serialization/xml'; +import { buildXmlNode, getContentHTML, getPromptHTML, parseXML } from '../../serialization/xml'; import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; import { hasRichTextContent, richTextComparisonKey } from '../../utils/richText'; @@ -72,7 +72,7 @@ function matchSets(el) { function readSet(setEl, prefix) { return [...setEl.querySelectorAll('qti-simple-associable-choice')].map(el => ({ id: el.getAttribute('identifier') || generateRandomSlug(prefix), - content: el.innerHTML, + content: getContentHTML(el), })); } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js index 4dd7513104..d3b9203e97 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/interactions/ordering/parse.js @@ -1,5 +1,5 @@ import { QTIDeclaration } from '../../serialization/qti/QTIDeclaration'; -import { buildXmlNode, getPromptHTML, parseXML } from '../../serialization/xml'; +import { buildXmlNode, getContentHTML, getPromptHTML, parseXML } from '../../serialization/xml'; import CorrectResponse from '../../serialization/qti/declarations/correctResponse'; import { generateRandomSlug } from '../../utils/generateRandomSlug'; import { Orientation, RESPONSE_IDENTIFIER } from '../../constants'; @@ -79,7 +79,7 @@ export function parseOrderingInteraction(bodyXml, responseDeclarations) { const rawItems = [...root.querySelectorAll('qti-simple-choice')].map(el => ({ id: el.getAttribute('identifier') || generateRandomSlug('order'), - content: el.innerHTML, + content: getContentHTML(el), fixed: el.getAttribute('fixed') === 'true', })); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/formulaItem.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/formulaItem.spec.js new file mode 100644 index 0000000000..e1ce6a7044 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/formulaItem.spec.js @@ -0,0 +1,117 @@ +import { assembleItemXml } from '../assembleItem'; +import { parseItem } from '../parseItem'; +import { choiceInteractionDescriptor } from '../../interactions/choice/Descriptor'; +import { orderingInteractionDescriptor } from '../../interactions/ordering/Descriptor'; +import { matchInteractionDescriptor } from '../../interactions/match/Descriptor'; +import { associateInteractionDescriptor } from '../../interactions/associate/Descriptor'; +import { useEditor } from '../../../TipTapEditor/TipTapEditor/composables/useEditor'; +import { QuestionType } from '../../constants'; + +const CONTENT = '

Solve for x

'; + +const INTERACTIONS = [ + { + name: 'choice', + descriptor: choiceInteractionDescriptor, + questionType: QuestionType.SINGLE_SELECT, + state: { + prompt: CONTENT, + choices: [ + { id: 'a', content: CONTENT, correct: true, fixed: false }, + { id: 'b', content: '

Other

', correct: false, fixed: false }, + ], + shuffle: false, + orientation: 'vertical', + showAnswerCount: true, + }, + option: state => state.choices[0].content, + }, + { + name: 'ordering', + descriptor: orderingInteractionDescriptor, + questionType: QuestionType.ORDERING, + state: { + prompt: CONTENT, + items: [ + { id: 'a', content: CONTENT }, + { id: 'b', content: '

Other

' }, + ], + orientation: 'vertical', + shuffle: true, + }, + option: state => state.items[0].content, + }, + { + name: 'match', + descriptor: matchInteractionDescriptor, + questionType: QuestionType.MATCH, + state: { + prompt: CONTENT, + rows: [{ id: 'row_a', content: CONTENT, matches: [{ id: 'b', content: '

Other

' }] }], + distractors: [], + }, + option: state => state.rows[0].content, + }, + { + name: 'associate', + descriptor: associateInteractionDescriptor, + questionType: QuestionType.ASSOCIATE, + state: { + prompt: CONTENT, + pairs: [ + [ + { id: 'a', content: CONTENT }, + { id: 'b', content: '

Other

' }, + ], + ], + distractors: [], + }, + option: state => state.pairs[0][0].content, + }, +]; + +const load = html => { + const { initializeEditor, editor } = useEditor(); + initializeEditor(html, 'edit'); + const loaded = editor.value.getHTML(); + editor.value.destroy(); + return loaded; +}; + +const buildItem = interaction => { + const { bodyXml, responseDeclarations } = interaction.descriptor.buildXML( + interaction.state, + interaction.questionType, + ); + return parseItem( + assembleItemXml({ + identifier: 'item_1', + title: 'Question', + language: 'en', + bodyXml, + responseDeclarations, + hints: [{ id: 'hint_1', content: CONTENT }], + }), + ); +}; + +describe.each(INTERACTIONS)('a $name item with text after a formula', interaction => { + let state; + + beforeAll(() => { + const [parsed] = buildItem(interaction).interactions; + state = interaction.descriptor.parse(parsed.bodyXml, parsed.responseDeclarations); + }); + + it.each([ + ['prompt', () => state.prompt], + ['option', () => interaction.option(state)], + ])('keeps the text in its %s once loaded into the editor', (_, read) => { + expect(load(read())).toBe(CONTENT); + }); +}); + +it('keeps the text after a formula in a hint once loaded into the editor', () => { + const item = buildItem(INTERACTIONS[0]); + expect(load(item.hints[0].content)).toBe(CONTENT); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/hints.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/hints.spec.js index 6c57379ca1..aa2212bf29 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/hints.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/__tests__/hints.spec.js @@ -117,7 +117,7 @@ describe('assembleItemXml with hints', () => { hints: [{ id: 'a', content: '

' }], }); expect(parseHints(parseXML(xml)).map(h => h.content)).toEqual([ - '

', + '

', ]); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/hints.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/hints.js index 56d8f4dbf0..ddfd43aff6 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/hints.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/hints.js @@ -16,6 +16,7 @@ import { generateRandomSlug } from '../utils/generateRandomSlug'; import { hasRichTextContent } from '../utils/richText'; +import { getContentHTML } from './xml'; /** The catalog this editor writes hints into. */ export const HINT_CATALOG_ID = 'kolibri-hints'; @@ -23,16 +24,6 @@ export const HINT_CATALOG_ID = 'kolibri-hints'; /** The support value that marks a card as a hint. Mirrors qti/catalog.py. */ export const HINT_SUPPORT = 'ext:kolibri-hint'; -/** - * The item's own namespace, which its content inherits from the root and therefore does - * not declare. Serializing a subtree on its own re-declares it on every top-level - * element, so reading a card's markup back out of the document reintroduces a - * declaration that was never in the stored XML. Dropped by value rather than by pattern, - * so a foreign namespace a hint legitimately carries — MathML from the formula button — - * is left alone. - */ -const QTI_NAMESPACE_DECLARATION = / xmlns="http:\/\/www\.imsglobal\.org\/xsd\/imsqtiasi_v3p0"/g; - /** * Read the item's hints, in document order. * @@ -52,9 +43,7 @@ export function parseHints(doc) { id: generateRandomSlug('hint'), // Pretty-printed XML puts the card's indentation inside the element, and the // editor would otherwise open on a stray blank line. - content: htmlContent - ? htmlContent.innerHTML.replace(QTI_NAMESPACE_DECLARATION, '').trim() - : '', + content: htmlContent ? getContentHTML(htmlContent).trim() : '', }; }); } diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js index 486960ee8f..b5223546b4 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/serialization/xml.js @@ -44,17 +44,26 @@ export function parseXML(xmlString, mimeType = 'text/xml') { } /** - * Extract the inner HTML of the first child of an interaction element. + * Extract the markup of the first child of an interaction element, as HTML. * Returns an empty string when no prompt element is present. - * Using innerHTML (not textContent) preserves rich inline markup (

, , etc.) - * for round-trip fidelity. * * @param {Element} interactionEl - The root element * @returns {string} */ export function getPromptHTML(interactionEl) { const promptEl = interactionEl.querySelector('qti-prompt'); - return promptEl ? promptEl.innerHTML : ''; + return promptEl ? getContentHTML(promptEl) : ''; +} + +/** + * An element's content as HTML, for a rich text editor to load. Not `innerHTML`: see + * serializeAsHtml. + * + * @param {Element} el + * @returns {string} + */ +export function getContentHTML(el) { + return serializeAsHtml([...el.childNodes], el.namespaceURI); } const xmlDoc = parser.parseFromString('', 'text/xml'); @@ -153,9 +162,9 @@ export function isContentNode(node) { * Serialize XML nodes as an HTML string, for state that a rich text editor parses as HTML. * * XMLSerializer writes an empty element as ``, and the HTML parser does not treat `/>` as - * self-closing on unknown elements such as QTI's, so the following siblings end up nested - * inside it. It also writes `xmlns` on elements in the item's namespace. Re-creating the nodes - * in an HTML document gives every element an explicit end tag and no `xmlns`; foreign + * self-closing on non-void elements such as `` or QTI's, so the following siblings end up + * nested inside it. It also writes `xmlns` on elements in the item's namespace. Re-creating the + * nodes in an HTML document gives every element an explicit end tag and no `xmlns`; foreign * subtrees (MathML, SVG) keep their namespace. * * @param {Node[]} nodes diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/richText.spec.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/richText.spec.js index 2480e74c1d..d398d94b0c 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/richText.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/__tests__/richText.spec.js @@ -19,6 +19,8 @@ describe('hasRichTextContent', () => { // item has been published — an item can be read from either side of that. ['

', true], ['

', true], + // Content read from stored XML writes a `>` in an attribute value unescaped. + ['

', true], ['

x

', true], // The Math node writes no attribute for an empty formula, leaving nothing behind. ['

', false], diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/richText.js b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/richText.js index 449c58c511..622a3f823b 100644 --- a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/richText.js +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/utils/richText.js @@ -13,6 +13,7 @@ */ import { QTISanitizer } from '../serialization/qti/QTISanitizer'; +import { parseXML } from '../serialization/xml'; /** * Markup that is content in its own right, with no text to find. @@ -21,7 +22,7 @@ import { QTISanitizer } from '../serialization/qti/QTISanitizer'; * only becomes MathML when the item is published — so both forms count, the item being * read here having come from either side of that. */ -const EMBEDDED_MEDIA = /<(img|math|svg)\b|]*\sdata-latex=/i; +const EMBEDDED_MEDIA = 'img, math, svg, span[data-latex]'; /** * The fragment's visible text. @@ -43,7 +44,10 @@ function toText(content) { * @returns {boolean} */ export function hasRichTextContent(content) { - return toText(content).length > 0 || EMBEDDED_MEDIA.test(content ?? ''); + return ( + toText(content).length > 0 || + Boolean(parseXML(content ?? '', 'text/html').querySelector(EMBEDDED_MEDIA)) + ); } /** diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/imageSrc.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/imageSrc.js index b2ade17999..8f91343476 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/imageSrc.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/imageSrc.js @@ -14,8 +14,10 @@ import { storageUrl } from 'shared/vuex/file/utils'; // backend which references publishing is able to resolve. const CHECKSUM_FILENAME = /^([a-f0-9]{32})\.([0-9a-z]+)$/; -const IMG_TAG = /]*>/gi; -const SRC_ATTRIBUTE = /\bsrc\s*=\s*(["'])(.*?)\1/i; +// Quoted values are skipped whole: the HTML serializer leaves `>` unescaped in them, and +// they can contain `src=`. +const IMG_TAG = /"']|"[^"]*"|'[^']*')*>/gi; +const ATTRIBUTE = /(\s)([^\s"'>/=]+)(?:(\s*=\s*)("[^"]*"|'[^']*'|[^\s"'>]+))?/g; /** * Rewrite the src of every in an HTML string. @@ -32,12 +34,23 @@ function mapImageSrcs(html, mapSrc) { if (!html) { return html; } - return html.replace(IMG_TAG, tag => - tag.replace(SRC_ATTRIBUTE, (attribute, quote, src) => { + return html.replace(IMG_TAG, tag => { + // The first src wins, as in the HTML parser. + let seen = false; + return tag.replace(ATTRIBUTE, (attribute, space, name, equals, value = '') => { + if (seen || name.toLowerCase() !== 'src') { + return attribute; + } + seen = true; + const quote = value[0]; + if (quote !== '"' && quote !== "'") { + return attribute; + } + const src = value.slice(1, -1); const mapped = mapSrc(src); - return mapped === src ? attribute : `src=${quote}${mapped}${quote}`; - }), - ); + return mapped === src ? attribute : `${space}${name}${equals}${quote}${mapped}${quote}`; + }); + }); } /** diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/imageSrc.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/imageSrc.spec.js index 54b949c355..c14784505a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/imageSrc.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/imageSrc.spec.js @@ -17,6 +17,20 @@ describe('resolveImageSrcs', () => { ); }); + it('resolves a src that follows an attribute value containing >', () => { + expect(resolveImageSrcs(`a > b`)).toBe( + `a > b`, + ); + }); + + it('resolves the src attribute, not src= inside another attribute', () => { + expect( + resolveImageSrcs( + `see src="${FILENAME}"`, + ), + ).toBe(`see src="${FILENAME}"`); + }); + it('resolves every image in the content', () => { const html = ``; expect(resolveImageSrcs(html)).toBe(``); From 651615fc47a41ea17683e940ce4a4d12c40aa7b3 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Wed, 7 Oct 2026 07:58:00 -0700 Subject: [PATCH 3/3] docs(qti): add a QTIEditor README Co-Authored-By: Claude Opus 5.5 --- .../frontend/shared/views/QTIEditor/README.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 contentcuration/contentcuration/frontend/shared/views/QTIEditor/README.md diff --git a/contentcuration/contentcuration/frontend/shared/views/QTIEditor/README.md b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/README.md new file mode 100644 index 0000000000..95a7373557 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/QTIEditor/README.md @@ -0,0 +1,35 @@ +## QTI Editor + +Edits an exercise's assessment items stored as [QTI 3](https://www.imsglobal.org/spec/qti/v3p0/impl) XML in `raw_data`. Rendered by `channelEdit/components/AssessmentTab/AssessmentTab.vue`. + +### Flow +1. `index.vue` lists the items; each is a `components/QTIItemEditor`. +2. `composables/useQtiItem.js` parses `raw_data` with `serialization/parseItem.js` into interaction blocks (`bodyXml` + `responseDeclarations`), hints and item metadata. +3. `components/InteractionSection` resolves each block's descriptor and question type with `composables/useInteractionDescriptor.js`. +4. The interaction's `Editor.vue` (`interactions/index.js`) edits a plain state object through `composables/useInteraction.js`: `descriptor.parse` → state → `descriptor.buildXML` → `descriptor.validate`. +5. `useQtiItem` rebuilds the full item with `serialization/assembleItem.js`; `QTIItemEditor` emits `update:rawData`. + +`validateItem.js` `validateQtiItem` runs the same parse and validation headless, without the Vue editors. + +### Layout +- `interactions//` — one per QTI interaction: + - `Descriptor.js` — subclass of `InteractionDescriptor`: matches the element, picks the question type, delegates to `parse.js` and `validation.js`. + - `parse.js` — XML ↔ state. + - `validation.js` — state → error codes. + - `Editor.vue` — the UI; absent for headless interactions (`HEADLESS_INTERACTIONS`). +- `interactions/descriptors.js` — descriptor registry, free of `.vue` imports. `interactions/index.js` adds the editors. +- `serialization/` — item-level parse/assemble, hints (`qti-catalog-info`), XML helpers (`xml.js`), response declarations (`qti/`). +- `components/` — shared UI: hints, chip lists, toolbars, question type selector. + +### Adding an interaction +1. Create `interactions//` with `Descriptor.js`, `parse.js`, `validation.js` and `Editor.vue`. +2. Register the descriptor in `interactions/descriptors.js` and the editor in `interactions/index.js`. +3. Add its question types to `constants.js` and strings to `qtiEditorStrings.js`. + +### Rich text fields +Prompts, choices, items and hints are edited in `TipTapEditor` with `format="html"`. + +> [!IMPORTANT] +> In `parse`, read any field TipTap loads as HTML with `getContentHTML` (or `getPromptHTML`) from `serialization/xml.js`, never `innerHTML` or `XMLSerializer`. XML serialization self-closes empty elements (``); the HTML parser doesn't treat `/>` as closing, so everything after it is nested inside and lost. +> +> On build, pass the HTML to `buildXmlNode({ innerHTML })`, which parses it as HTML and re-creates it as XML.