From 0c25dfcd46cb649a87d7a7bdf35030d5ca5f2784 Mon Sep 17 00:00:00 2001 From: Anders Weinstein Date: Wed, 11 Feb 2026 20:46:43 -0500 Subject: [PATCH 1/6] convert jmol applet to iframe, mapping path param; add test --- src/media.ts | 4 ++- src/resources/common.ts | 70 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/media.ts b/src/media.ts index 832ef4c..6ef2e76 100644 --- a/src/media.ts +++ b/src/media.ts @@ -115,6 +115,7 @@ const TARGET_SPECS: TargetSpec[] = [ { selector: 'embed_activity source', attr: 'text' }, { selector: 'linked_activity source', attr: 'text' }, { selector: 'asset, interface, dataset', attr: 'text' }, + { selector: 'applet param wb\\:path', attr: 'href' }, // in case process HTML, not used in OLI xml { selector: 'script', attr: 'src' }, { selector: 'img', attr: 'src' }, @@ -178,7 +179,8 @@ export function transformToFlatDirectory( $(e).parent()[0].name === 'linked_activity')); const isWebBundleElement = (e: cheerio.Element) => - ['link', 'iframe'].includes($(e)[0].name) || isSuperactivityAsset(e); + ['link', 'iframe', 'wb:path'].includes($(e)[0].name) || + isSuperactivityAsset(e); const targets = collectTargets($, 'relative'); diff --git a/src/resources/common.ts b/src/resources/common.ts index a4a48db..4aaab63 100644 --- a/src/resources/common.ts +++ b/src/resources/common.ts @@ -112,6 +112,7 @@ export function failIfHasValue( export function standardContentManipulations($: any) { failIfPresent($, ['ipa', 'bdo']); + handleJmolApplets($); handleCommandButtons($); DOM.unwrapInlinedMedia($, 'video'); @@ -343,13 +344,8 @@ export function standardContentManipulations($: any) { DOM.renameAttribute($, 'video source', 'src', 'url'); DOM.renameAttribute($, 'video', 'type', 'contenttype'); DOM.renameAttribute($, 'audio', 'type', 'audioType'); - // video subelement with .vtt subtitle file => torus captions child - DOM.rename($, 'video track[kind="subtitles"]', 'captions'); - DOM.renameAttribute($, 'video captions', 'srclang', 'language_code'); DOM.rename($, 'extra', 'popup'); - // simplifies handling within popup: - DOM.eliminateLevel($, 'meaning > material'); handleTheorems($); handleFormulaMathML($); @@ -407,6 +403,70 @@ function handleCommandButtons($: any) { $('command_button').wrap('

'); } +function handleJmolApplets($: any) { + const jmolWrapper = + 'edu.cmu.oli.messaging.applet.jmol.JmolAppletWrapper'; + + const normalizeScriptValue = (value: string) => + value.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim(); + + $('applet').each((i: any, elem: any) => { + const codeAttr = ($(elem).attr('code') || '').trim(); + const isJmol = codeAttr.includes(jmolWrapper); + + if (!isJmol) { + return; + } + + const idref = $(elem).attr('id') || ''; + const widthAttr = $(elem).attr('width'); + const heightAttr = $(elem).attr('height'); + + const params: Record = {}; + + $(elem) + .children('param') + .each((_j: any, p: any) => { + const name = ($(p).attr('name') || '').trim(); + if (!name) return; + + let raw = ''; + + if (name === 'load') { + const wbPath = $(p).find('wb\\:path'); + const href = wbPath.attr('href'); + raw = href !== undefined ? href : $(p).text(); + raw = normalizeScriptValue(raw); + } else if (name === 'script') { + raw = normalizeScriptValue($(p).text()); + } else { + raw = $(p).text().trim(); + } + + params[name] = raw; + }); + + const encodedParams = encodeURIComponent(JSON.stringify(params)); + const src = + '/superactivity/jsmol/jmolframe.html' + + `?idref=${encodeURIComponent(idref)}&params=${encodedParams}`; + + const iframe = $(''); + iframe.attr('src', src); + iframe.attr('scrolling', 'no'); + iframe.attr('frameborder', '0'); + + if (widthAttr !== undefined) { + iframe.attr('width', String(Number(widthAttr) + 5)); + } + if (heightAttr !== undefined) { + iframe.attr('height', String(Number(heightAttr) + 5)); + } + + $(elem).replaceWith(iframe); + }); +} + // We don't want empty caption tags ie to be expanded to an empty caption with

in it for iframes (maybe others?) // This will remove those before they get expanded function stripEmptyCaptions($: any, selector: string) { From fce61f7a7fcbf25031eb168cdde93b298b5afc8e Mon Sep 17 00:00:00 2001 From: Anders Weinstein Date: Thu, 12 Feb 2026 12:44:02 -0500 Subject: [PATCH 2/6] handle commented-out DOCTYPE --- src/utils/xml.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/utils/xml.ts b/src/utils/xml.ts index 5172528..ab973fb 100644 --- a/src/utils/xml.ts +++ b/src/utils/xml.ts @@ -16,11 +16,17 @@ const xmlParser = require('./parser'); export type TagVisitor = (tag: string, attributes: unknown) => void; export type ClosingTagVisitor = (tag: string) => void; +// one course left commented out old DOCTYPE when upgrading, must ignore +function stripCommentedDoctype(content: string): string { + return content.replace(//gi, ''); +} + function getPastDocType(content: string): string { - if (content.indexOf('DOCTYPE') !== -1) { - return content.substr(content.indexOf('>', content.indexOf('DOCTYPE')) + 1); + const cleaned = stripCommentedDoctype(content); + if (cleaned.indexOf('DOCTYPE') !== -1) { + return cleaned.substr(cleaned.indexOf('>', cleaned.indexOf('DOCTYPE')) + 1); } - return content; + return cleaned; } export function isBlockElement(name: string) { @@ -749,7 +755,8 @@ export function toJSON( export function rootTag(file: string): Promise { return new Promise((resolve, _reject) => { const content: string = fs.readFileSync(file, 'utf-8'); - const dtd = content.substr(content.indexOf('') + 1).replace(/\s+/g, ' ')); }); From fb8ad7fffd085753dce8932ca97635372702dc5b Mon Sep 17 00:00:00 2001 From: Anders Weinstein Date: Mon, 16 Feb 2026 17:37:03 -0500 Subject: [PATCH 3/6] migrate JMOL applet to JSMOL in iframe --- src/resources/common.ts | 22 ++++--- .../webcontent/jmol/jmol/pdbs/methane_mir.pdb | 1 + .../x-oli-workbook_page/jmol-applet.xml | 23 +++++++ test/jmol-applet-test.ts | 60 +++++++++++++++++++ 4 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 test/content/webcontent/jmol/jmol/pdbs/methane_mir.pdb create mode 100644 test/content/x-oli-workbook_page/jmol-applet.xml create mode 100644 test/jmol-applet-test.ts diff --git a/src/resources/common.ts b/src/resources/common.ts index 4aaab63..b39e4ef 100644 --- a/src/resources/common.ts +++ b/src/resources/common.ts @@ -298,9 +298,9 @@ export function standardContentManipulations($: any) { DOM.rename($, 'mtemp>material', 'p'); DOM.eliminateLevel($, 'mtemp'); - // Strip composite-activity if only one child + DOM.rename($, 'composite_activity > instructions', 'p'); + // Strip composite-activity entirely if only one child DOM.eliminateLevel($, 'composite_activity:has(> :only-child)'); - DOM.rename($, 'composite_activity', 'group'); // Strip alternatives within feedback/explanation, won't work. @@ -404,11 +404,13 @@ function handleCommandButtons($: any) { } function handleJmolApplets($: any) { - const jmolWrapper = - 'edu.cmu.oli.messaging.applet.jmol.JmolAppletWrapper'; + const jmolWrapper = 'edu.cmu.oli.messaging.applet.jmol.JmolAppletWrapper'; const normalizeScriptValue = (value: string) => - value.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim(); + value + .replace(/[\r\n]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); $('applet').each((i: any, elem: any) => { const codeAttr = ($(elem).attr('code') || '').trim(); @@ -431,12 +433,18 @@ function handleJmolApplets($: any) { if (!name) return; let raw = ''; - if (name === 'load') { + // In legacy use, molecule files were always loaded from webcontent, not public databases. + // These file loads were same-origin as jsmolframe page in legacy, but wind up cross-origin on + // torus (because frame comes from torus superactivity space but data files come from S3). For + // cross-origin loads, jsmol normally calls into a back-end jsmol.php on a specified server to + // avoid CORS problems, but Torus doesn't run this and we don't want to rely on an external server. + // JSMOL supports including a magic ?ALLOWSORIGIN? marker parameter in the URL to tell it + // host allows our origin via CORS, so we always add that to ensure it will do a direct load. const wbPath = $(p).find('wb\\:path'); const href = wbPath.attr('href'); raw = href !== undefined ? href : $(p).text(); - raw = normalizeScriptValue(raw); + raw = raw + '?ALLOWSORIGIN?'; } else if (name === 'script') { raw = normalizeScriptValue($(p).text()); } else { diff --git a/test/content/webcontent/jmol/jmol/pdbs/methane_mir.pdb b/test/content/webcontent/jmol/jmol/pdbs/methane_mir.pdb new file mode 100644 index 0000000..0c5c581 --- /dev/null +++ b/test/content/webcontent/jmol/jmol/pdbs/methane_mir.pdb @@ -0,0 +1 @@ +REMARK dummy test fixture for JMOL webcontent bundle resolution diff --git a/test/content/x-oli-workbook_page/jmol-applet.xml b/test/content/x-oli-workbook_page/jmol-applet.xml new file mode 100644 index 0000000..abf9025 --- /dev/null +++ b/test/content/x-oli-workbook_page/jmol-applet.xml @@ -0,0 +1,23 @@ + + + + + JMOL Test + + + + true + + + + set background white; + color cpk; wireframe 0.2; + + + diff --git a/test/jmol-applet-test.ts b/test/jmol-applet-test.ts new file mode 100644 index 0000000..7d0ecd0 --- /dev/null +++ b/test/jmol-applet-test.ts @@ -0,0 +1,60 @@ +import * as path from 'path'; +import { MediaSummary } from 'src/media'; +import { ProjectSummary } from 'src/project'; +import { convert } from 'src/convert'; + +const mediaSummary: MediaSummary = { + mediaItems: {}, + missing: [], + urlPrefix: 'https://example.com/media', + downloadRemote: false, + flattenedNames: {}, + webContentBundle: { + name: 'test', + url: 'https://example.com/media/bundles/test', + items: [], + totalSize: 0, + }, +}; + +const projectSummary = new ProjectSummary( + path.resolve('./test'), + '', + '', + mediaSummary +); + +describe('jmol applet', () => { + let results: any = {}; + + beforeAll(async () => { + return convert( + projectSummary, + './test/content/x-oli-workbook_page/jmol-applet.xml', + true + ).then((r) => { + results = r; + }); + }); + + test('should convert jmol applet to iframe with encoded params', () => { + const content = results[0].content.model[0].children; + const [iframe] = content; + + const params = { + progressbar: 'true', + load: + 'https://example.com/media/bundles/test/webcontent/jmol/jmol/pdbs/methane_mir.pdb?ALLOWSORIGIN?', + script: 'set background white; color cpk; wireframe 0.2;', + }; + + const expectedSrc = + '/superactivity/jsmol/jmolframe.html' + + `?idref=methane2¶ms=${encodeURIComponent(JSON.stringify(params))}`; + + expect(iframe.type).toBe('iframe'); + expect(iframe.src).toBe(expectedSrc); + expect(iframe.width).toBe('130'); + expect(iframe.height).toBe('130'); + }); +}); From 8606a918e7de022139ddc32828fa9114a61f19ed Mon Sep 17 00:00:00 2001 From: andersweinstein Date: Mon, 16 Feb 2026 22:41:00 +0000 Subject: [PATCH 4/6] Auto format --- test/jmol-applet-test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/jmol-applet-test.ts b/test/jmol-applet-test.ts index 7d0ecd0..c91c4ce 100644 --- a/test/jmol-applet-test.ts +++ b/test/jmol-applet-test.ts @@ -43,8 +43,7 @@ describe('jmol applet', () => { const params = { progressbar: 'true', - load: - 'https://example.com/media/bundles/test/webcontent/jmol/jmol/pdbs/methane_mir.pdb?ALLOWSORIGIN?', + load: 'https://example.com/media/bundles/test/webcontent/jmol/jmol/pdbs/methane_mir.pdb?ALLOWSORIGIN?', script: 'set background white; color cpk; wireframe 0.2;', }; From 3b48e1e30fe3b825525d5f94603968e72157db96 Mon Sep 17 00:00:00 2001 From: Anders Weinstein Date: Mon, 16 Feb 2026 17:58:27 -0500 Subject: [PATCH 5/6] preserve jmol id on iframe --- src/resources/common.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/resources/common.ts b/src/resources/common.ts index b39e4ef..a7aa159 100644 --- a/src/resources/common.ts +++ b/src/resources/common.ts @@ -460,6 +460,9 @@ function handleJmolApplets($: any) { `?idref=${encodeURIComponent(idref)}&params=${encodedParams}`; const iframe = $(''); + if (idref) { + iframe.attr('id', idref); + } iframe.attr('src', src); iframe.attr('scrolling', 'no'); iframe.attr('frameborder', '0'); From d988a62f017484b1c3af9e78c0d6a87cf336992c Mon Sep 17 00:00:00 2001 From: Anders Weinstein Date: Thu, 19 Feb 2026 11:12:06 -0500 Subject: [PATCH 6/6] fix handling of nested paragraphs --- src/resources/common.ts | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/src/resources/common.ts b/src/resources/common.ts index a7aa159..37ebaba 100644 --- a/src/resources/common.ts +++ b/src/resources/common.ts @@ -261,25 +261,10 @@ export function standardContentManipulations($: any) { // videos with size parameters have layout issue (not centered), so strip stripMediaSizing($, 'video'); - DOM.stripElement($, 'p>ol'); - DOM.stripElement($, 'p>ul'); - DOM.stripElement($, 'p>li'); - DOM.stripElement($, 'p>ol'); - DOM.stripElement($, 'p>ul'); - DOM.stripElement($, 'p>li'); - DOM.stripElement($, 'p>ol'); - DOM.stripElement($, 'p>ul'); - DOM.stripElement($, 'p>li'); - - DOM.stripElement($, 'p>p'); - DOM.stripElement($, 'p>p'); - // Change to allow block elements within list items // DOM.stripElement($, 'li p'); // DOM.rename($, 'li img', 'img_inline'); - DOM.stripElement($, 'p>quote'); - // $('p>table').remove(); $('p>title').remove(); @@ -364,6 +349,11 @@ export function standardContentManipulations($: any) { // Torus input_ref's use id attribute DOM.renameAttribute($, 'input_ref', 'input', 'id'); + + // Late restructuring steps above can re-introduce invalid block-in-paragraph + // nesting (for example when flattening side-by-side materials). Run a final + // fixpoint cleanup so wrap-then-strip normalization is deterministic. + stripInvalidParagraphNesting($); } export function convertStyleTag( @@ -403,6 +393,27 @@ function handleCommandButtons($: any) { $('command_button').wrap('

'); } +function stripInvalidParagraphNesting($: any) { + let changed = true; + while (changed) { + changed = false; + + ['video', 'audio', 'youtube', 'iframe'].forEach((type) => { + if ($(`p ${type}`).length > 0) { + DOM.unwrapInlinedMedia($, type); + changed = true; + } + }); + + ['p>p', 'p>quote', 'p>ol', 'p>ul', 'p>li', 'p>dl', 'p>blockquote'].forEach((selector) => { + if ($(selector).length > 0) { + DOM.stripElement($, selector); + changed = true; + } + }); + } +} + function handleJmolApplets($: any) { const jmolWrapper = 'edu.cmu.oli.messaging.applet.jmol.JmolAppletWrapper';