diff --git a/CLAUDE.md b/CLAUDE.md index a87de57b1..fa5908dd8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -558,6 +558,67 @@ The application MUST support importing legacy .elp files from pre-v3.0 eXeLearni - Use `assets/styles/components/_collaborative.scss` for Yjs/collaboration-related styles - The only exception is dynamically loading external CSS files (themes, iDevices) at runtime via `loadStyleByInsertingIt()` +### Frontend Asset URLs — Use `resolveAssetUrl()` + +**NEVER construct asset URLs manually** in frontend JavaScript. Always use `eXeLearning.resolveAssetUrl()` to get properly versioned URLs with the correct basePath. + +#### Why? + +The application can be installed at different URL paths (e.g., `/`, `/exelearning/`, `/web/app/`). Additionally, static assets are cache-busted with a version string. Manually constructing URLs will break when: +- The app is deployed at a non-root path (`BASE_PATH` environment variable) +- The version changes (cache invalidation) + +#### How to use + +```javascript +// BAD - Manual URL construction (NEVER do this) +const url = '/libs/jquery/jquery.min.js'; +const url = basePath + '/app/common/mermaid.js'; +const url = `${eXeLearning.config.basePath}/libs/tinymce/tinymce.min.js`; + +// GOOD - Use resolveAssetUrl() +const url = eXeLearning.resolveAssetUrl('/libs/jquery/jquery.min.js'); +// Returns: /basepath/v4.0.0/libs/jquery/jquery.min.js +``` + +#### Context-specific usage + +| Context | How to use | +|---------|-----------| +| **Workarea (main app)** | `eXeLearning.resolveAssetUrl(path)` | +| **iframes** (e.g., interactive-video editor) | `window.parent.eXeLearning?.resolveAssetUrl?.(path)` with fallback | +| **Exports** | Use relative paths (`./libs/` or `../libs/`) - function not available | + +#### In iframes (check availability first) + +```javascript +// In iframe code (e.g., iDevice editors) +const parentResolveAssetUrl = window.parent.eXeLearning?.resolveAssetUrl?.bind(window.parent.eXeLearning); +const basePath = window.parent.eXeLearning?.config?.basePath || ''; + +const assetUrl = (path) => parentResolveAssetUrl + ? parentResolveAssetUrl(path) + : basePath + path; // Fallback for exports +``` + +#### What resolveAssetUrl returns + +```javascript +// With basePath="/exelearning" and version="v4.0.0" +eXeLearning.resolveAssetUrl('/libs/jquery.js') +// → "/exelearning/v4.0.0/libs/jquery.js" + +// With empty basePath and version="v4.0.0" +eXeLearning.resolveAssetUrl('/app/common/mermaid.js') +// → "/v4.0.0/app/common/mermaid.js" +``` + +#### Where it's defined + +- **Definition**: `views/workarea/workarea.njk` (in the global `eXeLearning` object) +- **Available in**: All frontend JavaScript after page loads +- **NOT available in**: Exported HTML (use relative paths instead) + ## E2E Testing with Playwright ### Test Credentials diff --git a/public/app/app.js b/public/app/app.js index 953b820a5..4697288fc 100644 --- a/public/app/app.js +++ b/public/app/app.js @@ -490,14 +490,8 @@ export default class App { }); } - // COMPATIBILITY SHIM: Create eXeLearning.symfony for legacy iDevices - // Legacy iDevices (like interactive-video) reference eXeLearning.symfony.baseURL - // This shim maps them to the new eXeLearning.config structure - window.eXeLearning.symfony = { - baseURL: window.eXeLearning.config.baseURL || '', - basePath: window.eXeLearning.config.basePath || '', - fullURL: window.eXeLearning.config.fullURL || '', - }; + // Note: resolveAssetUrl is defined in the template (workarea.njk) to ensure + // it's available before any script loads, including common.js IIFE } setupSessionMonitor() { diff --git a/public/app/app.test.js b/public/app/app.test.js index a64df607f..cd3c1a272 100644 --- a/public/app/app.test.js +++ b/public/app/app.test.js @@ -151,10 +151,24 @@ describe('App utility methods', () => { window.location = originalLocation; }); + }); - it('creates symfony compatibility shim from config', () => { + describe('resolveAssetUrl', () => { + // resolveAssetUrl is defined in the template (workarea.njk) before app.js loads + // Simulate this by defining it in beforeEach before calling parseExelearningConfig + beforeEach(() => { window.eXeLearning.user = '{"id":1}'; - window.eXeLearning.config = '{"isOfflineInstallation":false,"baseURL":"http://localhost","basePath":"/app","fullURL":"http://localhost/app"}'; + window.eXeLearning.config = '{"basePath": "/mybase"}'; + window.eXeLearning.version = 'v1.0.0'; + + // Define resolveAssetUrl as the template would (before any script runs) + window.eXeLearning.resolveAssetUrl = function(path) { + const basePath = (this.config?.basePath || '').replace(/\/+$/, ''); + const normalizedPath = path.startsWith('/') ? path : '/' + path; + return this.version + ? basePath + '/' + this.version + normalizedPath + : basePath + normalizedPath; + }; const originalLocation = window.location; delete window.location; @@ -162,32 +176,61 @@ describe('App utility methods', () => { appInstance.parseExelearningConfig(); - // Verify symfony compatibility shim is created - expect(window.eXeLearning.symfony).toBeDefined(); - expect(window.eXeLearning.symfony.baseURL).toBe('http://localhost'); - expect(window.eXeLearning.symfony.basePath).toBe('/app'); - expect(window.eXeLearning.symfony.fullURL).toBe('http://localhost/app'); - window.location = originalLocation; }); - it('creates symfony compatibility shim with empty defaults', () => { - window.eXeLearning.user = '{"id":1}'; - window.eXeLearning.config = '{"isOfflineInstallation":true}'; + it('composes URL with basePath and version', () => { + expect(window.eXeLearning.resolveAssetUrl('/app/test.js')).toBe( + '/mybase/v1.0.0/app/test.js' + ); + }); - const originalLocation = window.location; - delete window.location; - window.location = { href: 'http://localhost/test', protocol: 'http:' }; + it('handles nested basePath', () => { + window.eXeLearning.config = { basePath: '/a/b/c' }; + expect(window.eXeLearning.resolveAssetUrl('/libs/x.js')).toBe( + '/a/b/c/v1.0.0/libs/x.js' + ); + }); - appInstance.parseExelearningConfig(); + it('handles empty basePath', () => { + window.eXeLearning.config = { basePath: '' }; + expect(window.eXeLearning.resolveAssetUrl('/app/x.js')).toBe( + '/v1.0.0/app/x.js' + ); + }); - // Verify symfony compatibility shim defaults to empty strings - expect(window.eXeLearning.symfony).toBeDefined(); - expect(window.eXeLearning.symfony.baseURL).toBe(''); - expect(window.eXeLearning.symfony.basePath).toBe(''); - expect(window.eXeLearning.symfony.fullURL).toBe(''); + it('normalizes path without leading slash', () => { + expect(window.eXeLearning.resolveAssetUrl('app/test.js')).toBe( + '/mybase/v1.0.0/app/test.js' + ); + }); - window.location = originalLocation; + it('handles missing version', () => { + window.eXeLearning.version = ''; + expect(window.eXeLearning.resolveAssetUrl('/app/x.js')).toBe( + '/mybase/app/x.js' + ); + }); + + it('handles undefined version', () => { + window.eXeLearning.version = undefined; + expect(window.eXeLearning.resolveAssetUrl('/app/x.js')).toBe( + '/mybase/app/x.js' + ); + }); + + it('strips trailing slashes from basePath', () => { + window.eXeLearning.config = { basePath: '/mybase///' }; + expect(window.eXeLearning.resolveAssetUrl('/app/x.js')).toBe( + '/mybase/v1.0.0/app/x.js' + ); + }); + + it('handles undefined config', () => { + window.eXeLearning.config = undefined; + expect(window.eXeLearning.resolveAssetUrl('/app/x.js')).toBe( + '/v1.0.0/app/x.js' + ); }); }); diff --git a/public/app/common/common.js b/public/app/common/common.js index e07ba3375..864b8e8a3 100644 --- a/public/app/common/common.js +++ b/public/app/common/common.js @@ -24,43 +24,13 @@ */ window.MathJax = window.MathJax || (function() { - var isWorkarea = typeof window.eXeLearning !== 'undefined' || document.querySelector('script[src*="app/common/exe_math"]'); + // Detect context: workarea vs export + // In workarea: eXeLearning.resolveAssetUrl is defined in template before any script loads + // In exports: use relative paths (./libs or ../libs) + var isWorkarea = typeof window.eXeLearning?.resolveAssetUrl === 'function'; var isIndex = document.documentElement.id === 'exe-index'; - // For workarea: use versioned path from eXeLearning config or detect from script tags - // For exports: use relative paths (./libs or ../libs) - var version = (window.eXeLearning && window.eXeLearning.version) || ''; - var configBasePath = ''; - if (isWorkarea) { - // Try to detect version and basePath from existing script tags (e.g., /web/exelearning/v0.0.0-alpha/app/...) - var scriptTag = document.querySelector('script[src*="/app/common/"]'); - if (scriptTag) { - var src = scriptTag.src; - // Extract version (e.g., v0.0.0-alpha) - var versionMatch = src.match(/\/(v[\d.]+[^/]*)\//); - if (versionMatch) version = versionMatch[1]; - // Extract basePath - everything before /v... or /app/ - // URL might be: /web/exelearning/v0.0.0/app/common/... or /v0.0.0/app/common/... - try { - var url = new URL(src); - var pathname = url.pathname; - // Find where the versioned path or /app/ starts - var appIndex = pathname.indexOf('/app/common/'); - if (appIndex > 0) { - var beforeApp = pathname.substring(0, appIndex); - // If there's a version, remove it from the path - if (version && beforeApp.endsWith('/' + version)) { - configBasePath = beforeApp.substring(0, beforeApp.length - version.length - 1); - } else { - configBasePath = beforeApp; - } - } - } catch (e) { - // If URL parsing fails, leave configBasePath empty - } - } - } var basePath = isWorkarea - ? (version ? configBasePath + '/' + version + '/app/common/exe_math' : configBasePath + '/app/common/exe_math') + ? window.eXeLearning.resolveAssetUrl('/app/common/exe_math') : (isIndex ? './libs/exe_math' : '../libs/exe_math'); var externalExtensions = [ @@ -200,6 +170,18 @@ var $exe = { }, // Load MathJax or just create the links to the code and/or image init: function () { + // Check if LaTeX is already pre-rendered to SVG + // Pre-rendered content has class exe-math-rendered + var preRenderedMath = $(".exe-math-rendered").length > 0; + + // If we have pre-rendered math, ALL LaTeX was pre-rendered during export + // No need to load MathJax library (~1MB) + if (preRenderedMath) { + // All LaTeX is pre-rendered - just create links, no MathJax needed + $exe.math.createLinks(); + return; + } + $("body").addClass("exe-auto-math"); // Always load it var math = $(".exe-math"); var mathjax = false; @@ -281,7 +263,12 @@ var $exe = { if (typeof window.mermaid === 'undefined') { const script = document.createElement("script"); - script.src = enginePath; + // Load Mermaid from the right path + // In workarea: resolveAssetUrl is defined in template + // In exports: use this.engine (relative path set above) + script.src = typeof window.eXeLearning?.resolveAssetUrl === 'function' + ? window.eXeLearning.resolveAssetUrl('/app/common/mermaid/mermaid.min.js') + : this.engine; script.async = true; script.onload = function () { mermaid = window.mermaid; @@ -365,9 +352,17 @@ var $exe = { } }, init: function () { + // Check if Mermaid diagrams are already pre-rendered to SVG + // Pre-rendered diagrams have class exe-mermaid-rendered + var preRenderedMermaid = $(".exe-mermaid-rendered").length > 0; + + // If we have pre-rendered mermaid, ALL diagrams were pre-rendered during export + // No need to load the Mermaid library (~2.7MB) + if (preRenderedMermaid) { + return; + } + // Check for mermaid elements that need rendering - // Pre-rendered diagrams have class exe-mermaid-rendered (not .mermaid) - // so they won't be matched by this selector. // Include ALL .mermaid elements (even data-processed="pending" which means // a previous render failed) so Mermaid library gets loaded and they can retry. var mermaidNodes = $(".mermaid"); @@ -619,18 +614,12 @@ var $exe = { hasTooltips: function () { if ($("A.exe-tooltip").length > 0) { var p = ""; - if (typeof (eXeLearning) !== 'undefined') { - // TODO: UNIFY - Fallback for branch compatibility. - // In 'main' branch: eXeLearning.symfony.fullURL exists (added by Symfony backend) - // In this branch: only eXeLearning.config.fullURL exists (set in workarea.njk) - // To unify: Either add 'symfony' property to workarea.njk template, - // or update main branch to use 'config' consistently. - p = (eXeLearning.symfony?.fullURL || eXeLearning.config?.fullURL || '') + "/app/common/exe_tooltips/"; + if (typeof eXeLearning !== 'undefined' && typeof eXeLearning.resolveAssetUrl === 'function') { + // Workarea context - use resolveAssetUrl for proper basePath and versioning + p = eXeLearning.resolveAssetUrl('/app/common/exe_tooltips/'); } else { - var ref = window.location.href; - // Check if it's the home page - p = "libs/exe_tooltips/"; - if (!document.getElementById("exe-index")) p = "../" + p; + // Export context - use relative paths + p = document.getElementById("exe-index") ? "libs/exe_tooltips/" : "../libs/exe_tooltips/"; } $exe.loadScript(p + "exe_tooltips.js", "$exe.tooltips.init('" + p + "')") } @@ -1461,46 +1450,18 @@ var $exeDevices = { } self._loading = true; - // For exports: use relative paths. For workarea: use versioned path if available - var isExport = $("html").prop("id") == "exe-index" || !document.querySelector('script[src*="/app/common/"]'); - var basePath; - if (isExport) { - basePath = $("html").prop("id") == "exe-index" ? "./libs/exe_math" : "../libs/exe_math"; - } else { - // Workarea: detect version and basePath from script tags - var version = (window.eXeLearning && window.eXeLearning.version) || ''; - var configBasePath = ''; - // Try to get basePath from parsed config first (if available) - if (window.eXeLearning && window.eXeLearning.config && typeof window.eXeLearning.config === 'object') { - configBasePath = window.eXeLearning.config.basePath || ''; - } - // Detect version and basePath from script tags as fallback - var scriptTag = document.querySelector('script[src*="/app/common/"]'); - if (scriptTag) { - var src = scriptTag.src; - if (!version) { - var versionMatch = src.match(/\/(v[\d.]+[^/]*)\//); - if (versionMatch) version = versionMatch[1]; - } - // Detect basePath from script src if not already set - if (!configBasePath) { - try { - var url = new URL(src); - var pathname = url.pathname; - var appIndex = pathname.indexOf('/app/common/'); - if (appIndex > 0) { - var beforeApp = pathname.substring(0, appIndex); - if (version && beforeApp.endsWith('/' + version)) { - configBasePath = beforeApp.substring(0, beforeApp.length - version.length - 1); - } else { - configBasePath = beforeApp; - } - } - } catch (e) {} - } - } - basePath = version ? configBasePath + '/' + version + '/app/common/exe_math' : configBasePath + '/app/common/exe_math'; - } + // Detect context: workarea vs export + // In workarea: resolveAssetUrl is defined in template + // In exports: use relative paths (./libs or ../libs) + var isWorkarea = typeof window.eXeLearning?.resolveAssetUrl === 'function'; + var isIndex = $("html").prop("id") == "exe-index"; + var basePath = isWorkarea + ? window.eXeLearning.resolveAssetUrl('/app/common/exe_math') + : (isIndex ? "./libs/exe_math" : "../libs/exe_math"); + // Script source: use resolveAssetUrl in workarea, relative path in exports + var scriptSrc = isWorkarea + ? window.eXeLearning.resolveAssetUrl('/app/common/exe_math/tex-mml-svg.js') + : (isIndex ? "./libs/exe_math/tex-mml-svg.js" : "../libs/exe_math/tex-mml-svg.js"); if (!window.MathJax) { window.MathJax = self.engineConfig; } @@ -1508,7 +1469,7 @@ var $exeDevices = { if (!window.MathJax.loader.paths) window.MathJax.loader.paths = {}; window.MathJax.loader.paths.mathjax = basePath; var script = document.createElement('script'); - script.src = self.engine; + script.src = scriptSrc; script.async = true; script.onload = function () { var checkReady = function () { diff --git a/public/app/common/common.test.js b/public/app/common/common.test.js index b883e9d0a..6aa652aa3 100644 --- a/public/app/common/common.test.js +++ b/public/app/common/common.test.js @@ -62,8 +62,10 @@ describe('common.js $exe helpers', () => { expect(global.$exe.getIdeviceInstalledExportPath('text')).toBe('/export/path'); }); - it('hasTooltips loads tooltip script when tooltips are present', () => { - global.eXeLearning = { symfony: { fullURL: 'http://example.com' } }; + it('hasTooltips loads tooltip script when tooltips are present with resolveAssetUrl', () => { + global.eXeLearning = { + resolveAssetUrl: (path) => `/basepath/v1.0.0${path}`, + }; document.body.innerHTML = ''; const loadSpy = vi.spyOn(global.$exe, 'loadScript').mockImplementation(() => {}); @@ -71,8 +73,8 @@ describe('common.js $exe helpers', () => { global.$exe.hasTooltips(); expect(loadSpy).toHaveBeenCalledWith( - 'http://example.com/app/common/exe_tooltips/exe_tooltips.js', - "$exe.tooltips.init('http://example.com/app/common/exe_tooltips/')" + '/basepath/v1.0.0/app/common/exe_tooltips/exe_tooltips.js', + "$exe.tooltips.init('/basepath/v1.0.0/app/common/exe_tooltips/')" ); }); @@ -161,6 +163,67 @@ describe('common.js $exe helpers', () => { global.$exe.math.init(); expect(document.body.classList.contains('exe-auto-math')).toBe(true); }); + + it('init skips MathJax loading when LaTeX is pre-rendered', () => { + // Content with pre-rendered LaTeX - ALL LaTeX was pre-rendered during export + document.body.innerHTML = '
'; + + // Spy on loadMathJax to verify it's not called + const loadMathJaxSpy = vi.spyOn(global.$exe.math, 'loadMathJax').mockImplementation(() => {}); + const createLinksSpy = vi.spyOn(global.$exe.math, 'createLinks').mockImplementation(() => {}); + + global.$exe.math.init(); + + // loadMathJax should NOT be called when content is pre-rendered + expect(loadMathJaxSpy).not.toHaveBeenCalled(); + // createLinks should still be called to add links to math elements + expect(createLinksSpy).toHaveBeenCalled(); + }); + + it('init skips MathJax even if raw LaTeX patterns exist alongside pre-rendered (export pre-renders ALL)', () => { + // If there's pre-rendered math, the export already processed ALL LaTeX + // Any remaining LaTeX-like patterns are in attributes or non-content areas + document.body.innerHTML = ` +
+

Some text

+ `; + + const loadMathJaxSpy = vi.spyOn(global.$exe.math, 'loadMathJax').mockImplementation(() => {}); + const createLinksSpy = vi.spyOn(global.$exe.math, 'createLinks').mockImplementation(() => {}); + + global.$exe.math.init(); + + // loadMathJax should NOT be called - pre-rendered means ALL LaTeX was processed + expect(loadMathJaxSpy).not.toHaveBeenCalled(); + expect(createLinksSpy).toHaveBeenCalled(); + }); + + it('init loads MathJax when there is no pre-rendered content and raw LaTeX exists', () => { + document.body.innerHTML = '

Formula: \\[E = mc^2\\]

'; + + // Reset body class from previous test + document.body.classList.remove('exe-auto-math'); + + global.$exe.math.init(); + + // Body should have exe-auto-math class (MathJax path was taken) + expect(document.body.classList.contains('exe-auto-math')).toBe(true); + }); + + it('init skips MathJax for pre-rendered content with data attributes containing LaTeX', () => { + // Pre-rendered content may have data attributes with original LaTeX + // The presence of exe-math-rendered means ALL was pre-rendered + document.body.innerHTML = '
'; + + const loadMathJaxSpy = vi.spyOn(global.$exe.math, 'loadMathJax').mockImplementation(() => {}); + const createLinksSpy = vi.spyOn(global.$exe.math, 'createLinks').mockImplementation(() => {}); + + global.$exe.math.init(); + + // loadMathJax should NOT be called - content is pre-rendered + expect(loadMathJaxSpy).not.toHaveBeenCalled(); + expect(createLinksSpy).toHaveBeenCalled(); + }); }); describe('$exe.mermaid', () => { @@ -178,6 +241,46 @@ describe('common.js $exe helpers', () => { global.$exe.mermaid.loadMermaid(); expect(appendChildSpy).toHaveBeenCalled(); }); + + it('init skips loading Mermaid when diagrams are pre-rendered', () => { + // Pre-rendered mermaid diagrams have class exe-mermaid-rendered + document.body.innerHTML = '
'; + + const loadMermaidSpy = vi.spyOn(global.$exe.mermaid, 'loadMermaid').mockImplementation(() => {}); + + global.$exe.mermaid.init(); + + // loadMermaid should NOT be called when content is pre-rendered + expect(loadMermaidSpy).not.toHaveBeenCalled(); + }); + + it('init loads Mermaid when there are .mermaid elements and no pre-rendered', () => { + // Raw mermaid elements need the library + document.body.innerHTML = '
graph TD; A-->B
'; + + const loadMermaidSpy = vi.spyOn(global.$exe.mermaid, 'loadMermaid').mockImplementation(() => {}); + + global.$exe.mermaid.init(); + + // loadMermaid should be called for raw mermaid elements + expect(loadMermaidSpy).toHaveBeenCalled(); + }); + + it('init skips Mermaid even if .mermaid elements exist alongside pre-rendered', () => { + // If exe-mermaid-rendered exists, ALL diagrams were pre-rendered during export + // Any remaining .mermaid elements are artifacts that won't render anyway + document.body.innerHTML = ` +
+
should be ignored
+ `; + + const loadMermaidSpy = vi.spyOn(global.$exe.mermaid, 'loadMermaid').mockImplementation(() => {}); + + global.$exe.mermaid.init(); + + // loadMermaid should NOT be called - pre-rendered means ALL was processed + expect(loadMermaidSpy).not.toHaveBeenCalled(); + }); }); describe('$exe.setModalWindowContentSize', () => { @@ -421,14 +524,43 @@ describe('common.js $exe helpers', () => { }); describe('$exe.hasTooltips edge cases', () => { - it('loads script when tooltips are present and not in eXe', () => { + it('loads script when tooltips are present and not in eXe (export context - index page)', () => { + delete global.eXeLearning; + document.body.innerHTML = '
'; + const loadSpy = vi.spyOn(global.$exe, 'loadScript').mockImplementation(() => {}); + + global.$exe.hasTooltips(); + + expect(loadSpy).toHaveBeenCalledWith( + 'libs/exe_tooltips/exe_tooltips.js', + "$exe.tooltips.init('libs/exe_tooltips/')" + ); + }); + + it('loads script when tooltips are present and not in eXe (export context - subpage)', () => { delete global.eXeLearning; document.body.innerHTML = ''; const loadSpy = vi.spyOn(global.$exe, 'loadScript').mockImplementation(() => {}); global.$exe.hasTooltips(); - expect(loadSpy).toHaveBeenCalled(); + expect(loadSpy).toHaveBeenCalledWith( + '../libs/exe_tooltips/exe_tooltips.js', + "$exe.tooltips.init('../libs/exe_tooltips/')" + ); + }); + + it('uses relative paths when eXeLearning exists but resolveAssetUrl is not a function', () => { + global.eXeLearning = { config: {} }; // No resolveAssetUrl + document.body.innerHTML = '
'; + const loadSpy = vi.spyOn(global.$exe, 'loadScript').mockImplementation(() => {}); + + global.$exe.hasTooltips(); + + expect(loadSpy).toHaveBeenCalledWith( + 'libs/exe_tooltips/exe_tooltips.js', + "$exe.tooltips.init('libs/exe_tooltips/')" + ); }); }); @@ -578,16 +710,18 @@ describe('common.js $exe helpers', () => { expect(loadSpy).not.toHaveBeenCalled(); }); - it('init loads mermaid when there are unprocessed mermaid elements alongside pre-rendered', () => { - // Both pre-rendered and raw mermaid elements + it('init skips mermaid when there are unprocessed mermaid elements alongside pre-rendered', () => { + // If exe-mermaid-rendered exists, ALL diagrams were pre-rendered during export + // Any remaining .mermaid elements are artifacts from the original content + // The export process pre-renders ALL mermaid diagrams document.body.innerHTML = `
graph TD; C-->D
`; const loadSpy = vi.spyOn(global.$exe.mermaid, 'loadMermaid').mockImplementation(() => {}); global.$exe.mermaid.init(); - // loadMermaid SHOULD be called when there are raw mermaid elements - expect(loadSpy).toHaveBeenCalled(); + // loadMermaid should NOT be called - pre-rendered means export processed ALL mermaid + expect(loadSpy).not.toHaveBeenCalled(); }); it('init loads mermaid when elements have data-processed="pending" (failed previous render)', () => { @@ -1683,10 +1817,15 @@ describe('common.js $exeDevices', () => { const math = getMath(); // Save originals const originalMathJax = window.MathJax; + const originalExeLearning = window.eXeLearning; // Remove MathJax completely to force script creation delete window.MathJax; + // Ensure window.eXeLearning is undefined so code uses export path fallback + // (resolveAssetUrl is checked with optional chaining so this won't error) + delete window.eXeLearning; + // Reset internal loading state math._loading = false; math._callbacks = []; @@ -1701,6 +1840,7 @@ describe('common.js $exeDevices', () => { // Restore window.MathJax = originalMathJax; + window.eXeLearning = originalExeLearning; }); it('updateLatex does not throw for invalid target', () => { diff --git a/public/app/editor/tinymce_5_settings.js b/public/app/editor/tinymce_5_settings.js index fd5a439c0..84bf87a89 100644 --- a/public/app/editor/tinymce_5_settings.js +++ b/public/app/editor/tinymce_5_settings.js @@ -40,8 +40,22 @@ var $exeTinyMCE = { }, contextmenu: 'exelink | inserttable | cell row column deletetable', language: 'all', // We set all so we can use eXe's i18n mechanism in all.js, - edicuatex_url: '/app/common/edicuatex/index.html', - edicuatex_mathjax_url: '/app/common/exe_math/tex-mml-svg.js', + /** + * Resolves edicuatex equation editor URL at runtime. + * Uses eXeLearning.resolveAssetUrl() for proper BASE_PATH/version handling. + * @returns {string} Full URL to edicuatex editor + */ + get edicuatex_url() { + return eXeLearning.resolveAssetUrl('/app/common/edicuatex/index.html'); + }, + /** + * Resolves MathJax library URL for the edicuatex editor at runtime. + * Uses eXeLearning.resolveAssetUrl() for proper BASE_PATH/version handling. + * @returns {string} Full URL to MathJax tex-mml-svg.js + */ + get edicuatex_mathjax_url() { + return eXeLearning.resolveAssetUrl('/app/common/exe_math/tex-mml-svg.js'); + }, getTemplates: function () { return [ { @@ -111,13 +125,8 @@ var $exeTinyMCE = { }, getAssetURL: function (url) { - // URL pattern: {basePath}/{version}/path (e.g., /web/exelearning/v0.0.0-alpha/libs/...) - let assetUrl = - eXeLearning.config.baseURL + - eXeLearning.config.basePath + - '/' + - eXeLearning.version; - return assetUrl + url; + // For full URLs including origin, combine baseURL with resolveAssetUrl path + return eXeLearning.config.baseURL + eXeLearning.resolveAssetUrl(url); }, /** @@ -285,8 +294,8 @@ var $exeTinyMCE = { rel_list: this.rel_list, // Math plugin - edicuatex_url: this.getAssetURL(this.edicuatex_url), - edicuatex_mathjax_url: this.getAssetURL(this.edicuatex_mathjax_url), + edicuatex_url: eXeLearning.config.baseURL + this.edicuatex_url, + edicuatex_mathjax_url: eXeLearning.config.baseURL + this.edicuatex_mathjax_url, // Images image_advtab: true, @@ -623,10 +632,8 @@ var $exeTinyMCE = { return ( themePath + 'style.css,' + - eXeLearning.app.api.apiUrlBase + - '/app/editor/tinymce_5_extra.css,' + - eXeLearning.app.api.apiUrlBase + - '/libs/bootstrap/bootstrap.min.css' + eXeLearning.resolveAssetUrl('/app/editor/tinymce_5_extra.css') + ',' + + eXeLearning.resolveAssetUrl('/libs/bootstrap/bootstrap.min.css') ); }, diff --git a/public/app/editor/tinymce_5_settings.test.js b/public/app/editor/tinymce_5_settings.test.js index e584e4038..b71ce7715 100644 --- a/public/app/editor/tinymce_5_settings.test.js +++ b/public/app/editor/tinymce_5_settings.test.js @@ -14,6 +14,7 @@ globalThis.eXeLearning = { basePath: '/exelearning', themeBaseType: 'XHTML', }, + resolveAssetUrl: (path) => `/exelearning/v3.0.0${path.startsWith('/') ? path : '/' + path}`, app: { common: { getVersionTimeStamp: vi.fn(() => '12345'), @@ -216,10 +217,22 @@ describe('TinyMCE 5 Settings', () => { expect(result).toBe('http://localhost/exelearning/v3.0.0/libs/test.js'); }); - it('getContentCSS returns comma-separated URLs', () => { + it('getContentCSS returns comma-separated URLs with resolveAssetUrl', () => { const result = globalThis.$exeTinyMCE.getContentCSS(); expect(result).toContain('/theme/path/style.css'); - expect(result).toContain('/app/editor/tinymce_5_extra.css'); + // Non-theme assets should use resolveAssetUrl (includes basePath and version) + expect(result).toContain('/exelearning/v3.0.0/app/editor/tinymce_5_extra.css'); + expect(result).toContain('/exelearning/v3.0.0/libs/bootstrap/bootstrap.min.css'); + }); + + it('edicuatex_url getter returns composed URL', () => { + const result = globalThis.$exeTinyMCE.edicuatex_url; + expect(result).toBe('/exelearning/v3.0.0/app/common/edicuatex/index.html'); + }); + + it('edicuatex_mathjax_url getter returns composed URL', () => { + const result = globalThis.$exeTinyMCE.edicuatex_mathjax_url; + expect(result).toBe('/exelearning/v3.0.0/app/common/exe_math/tex-mml-svg.js'); }); it('getContentCSS falls back to base theme when missing', () => { diff --git a/public/app/rest/apiCallManager.js b/public/app/rest/apiCallManager.js index a8315de4c..2b229ffa6 100644 --- a/public/app/rest/apiCallManager.js +++ b/public/app/rest/apiCallManager.js @@ -63,10 +63,7 @@ export default class ApiCallManager { * @returns */ async getThirdPartyCodeText() { - // Use basePath + version for proper cache busting - // URL pattern: {basePath}/{version}/path (e.g., /web/exelearning/v0.0.0-alpha/libs/README.md) - const version = eXeLearning?.version || 'v1.0.0'; - let url = this.apiUrlBase + this.apiUrlBasePath + '/' + version + '/libs/README.md'; + let url = this.apiUrlBase + eXeLearning.resolveAssetUrl('/libs/README.md'); return await this.func.getText(url); } @@ -76,10 +73,7 @@ export default class ApiCallManager { * @returns */ async getLicensesList() { - // Use basePath + version for proper cache busting - // URL pattern: {basePath}/{version}/path (e.g., /web/exelearning/v0.0.0-alpha/libs/LICENSES.md) - const version = eXeLearning?.version || 'v1.0.0'; - let url = this.apiUrlBase + this.apiUrlBasePath + '/' + version + '/libs/LICENSES.md'; + let url = this.apiUrlBase + eXeLearning.resolveAssetUrl('/libs/LICENSES.md'); return await this.func.getText(url); } diff --git a/public/app/rest/apiCallManager.test.js b/public/app/rest/apiCallManager.test.js index 7919bad29..cf599f8dc 100644 --- a/public/app/rest/apiCallManager.test.js +++ b/public/app/rest/apiCallManager.test.js @@ -26,6 +26,13 @@ describe('ApiCallManager', () => { basePath: '/exelearning', changelogURL: 'http://localhost/changelog', }, + version: 'v3.0.0', + // resolveAssetUrl must reference the current version at call time + resolveAssetUrl: function(path) { + const version = global.eXeLearning?.version || this.version || 'v1.0.0'; + const basePath = this.config?.basePath || ''; + return `${basePath}/${version}${path.startsWith('/') ? path : '/' + path}`; + }, }, common: { getVersionTimeStamp: vi.fn(() => '123456'), diff --git a/public/app/yjs/ResourceFetcher.js b/public/app/yjs/ResourceFetcher.js index 3e537c375..966770b22 100644 --- a/public/app/yjs/ResourceFetcher.js +++ b/public/app/yjs/ResourceFetcher.js @@ -41,6 +41,9 @@ class ResourceFetcher { this.apiBase = `${this.basePath}/api/resources`; // Version for cache-busting static file URLs this.version = window.eXeLearning?.version || 'v0.0.0'; + // Unified URL composition - use global resolveAssetUrl or fallback + this.resolveAssetUrl = + window.eXeLearning?.resolveAssetUrl?.bind(window.eXeLearning) || this._createFallbackResolveAssetUrl(); // Persistent IndexedDB cache (set via init() or setResourceCache()) this.resourceCache = null; // Bundle manifest (loaded on init) @@ -52,6 +55,21 @@ class ResourceFetcher { this.userThemeFiles = new Map(); } + /** + * Create a fallback resolveAssetUrl function when the global one is not available. + * This ensures ResourceFetcher works even without the full eXeLearning environment. + * @returns {Function} A function that resolves versioned asset URLs + * @private + */ + _createFallbackResolveAssetUrl() { + const basePath = this.basePath; + const version = this.version; + return path => { + const normalizedPath = path.startsWith('/') ? path : '/' + path; + return version ? `${basePath}/${version}${normalizedPath}` : `${basePath}${normalizedPath}`; + }; + } + /** * Initialize ResourceFetcher with optional ResourceCache * @param {ResourceCache} [resourceCache] - Optional ResourceCache instance @@ -841,14 +859,8 @@ class ResourceFetcher { // Try the most likely path first (with version for cache busting) const possiblePaths = isThirdParty - ? [ - `${this.basePath}/${this.version}/libs/${path}`, - `${this.basePath}/${this.version}/app/common/${path}`, - ] - : [ - `${this.basePath}/${this.version}/app/common/${path}`, - `${this.basePath}/${this.version}/libs/${path}`, - ]; + ? [this.resolveAssetUrl(`/libs/${path}`), this.resolveAssetUrl(`/app/common/${path}`)] + : [this.resolveAssetUrl(`/app/common/${path}`), this.resolveAssetUrl(`/libs/${path}`)]; for (const url of possiblePaths) { try { @@ -1052,7 +1064,7 @@ class ResourceFetcher { return this.cache.get(cacheKey); } - const logoUrl = `${this.basePath}/${this.version}/app/common/exe_powered_logo/exe_powered_logo.png`; + const logoUrl = this.resolveAssetUrl('/app/common/exe_powered_logo/exe_powered_logo.png'); try { const response = await fetch(logoUrl); if (response.ok) { diff --git a/public/app/yjs/ResourceFetcher.test.js b/public/app/yjs/ResourceFetcher.test.js index 15321710d..773c815fe 100644 --- a/public/app/yjs/ResourceFetcher.test.js +++ b/public/app/yjs/ResourceFetcher.test.js @@ -17,12 +17,17 @@ describe('ResourceFetcher', () => { beforeEach(() => { vi.clearAllMocks(); - // Mock eXeLearning global + // Mock eXeLearning global with resolveAssetUrl global.eXeLearning = { config: { basePath: '/web/exelearning', }, version: 'v3.1.0', + // Mock resolveAssetUrl to match the real implementation + resolveAssetUrl: path => { + const normalizedPath = path.startsWith('/') ? path : '/' + path; + return `/web/exelearning/v3.1.0${normalizedPath}`; + }, }; // Mock fetch @@ -73,6 +78,40 @@ describe('ResourceFetcher', () => { const fetcher = new ResourceFetcher(); expect(fetcher.version).toBe('v0.0.0'); }); + + it('uses global resolveAssetUrl when available', () => { + const fetcher = new ResourceFetcher(); + expect(typeof fetcher.resolveAssetUrl).toBe('function'); + expect(fetcher.resolveAssetUrl('/libs/jquery.js')).toBe('/web/exelearning/v3.1.0/libs/jquery.js'); + }); + + it('creates fallback resolveAssetUrl when global not available', () => { + delete global.eXeLearning; + const fetcher = new ResourceFetcher(); + expect(typeof fetcher.resolveAssetUrl).toBe('function'); + // Fallback uses empty basePath and v0.0.0 version + expect(fetcher.resolveAssetUrl('/libs/test.js')).toBe('/v0.0.0/libs/test.js'); + }); + + it('fallback resolveAssetUrl normalizes paths without leading slash', () => { + delete global.eXeLearning; + const fetcher = new ResourceFetcher(); + // Path without leading slash should be normalized + expect(fetcher.resolveAssetUrl('libs/test.js')).toBe('/v0.0.0/libs/test.js'); + }); + + it('fallback resolveAssetUrl uses basePath and version from config', () => { + // Set up global with basePath and version but without resolveAssetUrl + global.eXeLearning = { + config: { + basePath: '/custom/path', + }, + version: 'v2.0.0', + // No resolveAssetUrl - force fallback + }; + const fetcher = new ResourceFetcher(); + expect(fetcher.resolveAssetUrl('/app/test.js')).toBe('/custom/path/v2.0.0/app/test.js'); + }); }); describe('fetchTheme', () => { diff --git a/public/app/yjs/yjs-loader.js b/public/app/yjs/yjs-loader.js index aff070950..26e7ea82c 100644 --- a/public/app/yjs/yjs-loader.js +++ b/public/app/yjs/yjs-loader.js @@ -31,11 +31,9 @@ window.Logger = window.AppLogger; const Logger = window.Logger; - // Get basePath and version from eXeLearning (set by pages.controller.ts) - const getBasePath = () => window.eXeLearning?.config?.basePath || ''; - const getVersion = () => window.eXeLearning?.version || 'v1.0.0'; - // URL pattern: {basePath}/{version}/path (e.g., /web/exelearning/v0.0.0-alpha/libs/yjs/yjs.min.js) - const assetPath = (path) => `${getBasePath()}/${getVersion()}${path.startsWith('/') ? path : '/' + path}`; + // Use global helper for versioned asset URLs + // resolveAssetUrl is defined in the template (workarea.njk) before any script loads + const assetPath = (path) => window.eXeLearning.resolveAssetUrl(path); // Paths are computed lazily to ensure eXeLearning globals are available const getLIBS_PATH = () => assetPath('/libs/yjs'); diff --git a/public/app/yjs/yjs-loader.test.js b/public/app/yjs/yjs-loader.test.js index 0aac39f95..318b8b011 100644 --- a/public/app/yjs/yjs-loader.test.js +++ b/public/app/yjs/yjs-loader.test.js @@ -26,6 +26,14 @@ describe('YjsLoader', () => { window.eXeLearning = { config: { basePath: '' }, version: 'v1.0.0', + // resolveAssetUrl is defined in template, simulate it for tests + resolveAssetUrl: function(path) { + const basePath = (this.config?.basePath || '').replace(/\/+$/, ''); + const normalizedPath = path.startsWith('/') ? path : '/' + path; + return this.version + ? basePath + '/' + this.version + normalizedPath + : basePath + normalizedPath; + } }; window.Y = undefined; window.JSZip = undefined; @@ -563,6 +571,97 @@ describe('YjsLoader', () => { expect(typeof window.YjsLoader.load).toBe('function'); // The method signature is load(options = {}) }); + + it('assetPath uses resolveAssetUrl when available', () => { + // Setup mock with tracking + const mockCompose = spyOn(window.eXeLearning, 'resolveAssetUrl'); + mockCompose.mockImplementation(function(path) { + const basePath = (this.config?.basePath || '').replace(/\/+$/, ''); + const normalizedPath = path.startsWith('/') ? path : '/' + path; + return this.version + ? basePath + '/' + this.version + normalizedPath + : basePath + normalizedPath; + }); + + // Directly invoke the resolveAssetUrl function (which yjs-loader uses internally) + const result = window.eXeLearning.resolveAssetUrl('/app/yjs'); + + // Verify the function was called with expected path + expect(mockCompose).toHaveBeenCalledWith('/app/yjs'); + // Verify the result has the expected format + expect(result).toBe('/v1.0.0/app/yjs'); + }); + + it('resolveAssetUrl returns versioned paths correctly', () => { + window.eXeLearning = { + config: { basePath: '/web/exelearning' }, + version: 'v2.0.0', + resolveAssetUrl: function(path) { + const basePath = (this.config?.basePath || '').replace(/\/+$/, ''); + const normalizedPath = path.startsWith('/') ? path : '/' + path; + return this.version + ? basePath + '/' + this.version + normalizedPath + : basePath + normalizedPath; + } + }; + + // Verify the resolveAssetUrl function returns expected format + const result = window.eXeLearning.resolveAssetUrl('/libs/yjs'); + expect(result).toBe('/web/exelearning/v2.0.0/libs/yjs'); + }); + + it('resolveAssetUrl handles paths without leading slash', () => { + window.eXeLearning = { + config: { basePath: '' }, + version: 'v1.0.0', + resolveAssetUrl: function(path) { + const basePath = (this.config?.basePath || '').replace(/\/+$/, ''); + const normalizedPath = path.startsWith('/') ? path : '/' + path; + return this.version + ? basePath + '/' + this.version + normalizedPath + : basePath + normalizedPath; + } + }; + + // Path without leading slash should be normalized + const result = window.eXeLearning.resolveAssetUrl('libs/yjs'); + expect(result).toBe('/v1.0.0/libs/yjs'); + }); + + it('resolveAssetUrl handles empty basePath', () => { + window.eXeLearning = { + config: { basePath: '' }, + version: 'v1.0.0', + resolveAssetUrl: function(path) { + const basePath = (this.config?.basePath || '').replace(/\/+$/, ''); + const normalizedPath = path.startsWith('/') ? path : '/' + path; + return this.version + ? basePath + '/' + this.version + normalizedPath + : basePath + normalizedPath; + } + }; + + const result = window.eXeLearning.resolveAssetUrl('/libs/yjs'); + expect(result).toBe('/v1.0.0/libs/yjs'); + }); + + it('resolveAssetUrl handles trailing slash in basePath', () => { + window.eXeLearning = { + config: { basePath: '/myapp/' }, + version: 'v1.0.0', + resolveAssetUrl: function(path) { + const basePath = (this.config?.basePath || '').replace(/\/+$/, ''); + const normalizedPath = path.startsWith('/') ? path : '/' + path; + return this.version + ? basePath + '/' + this.version + normalizedPath + : basePath + normalizedPath; + } + }; + + // Trailing slash in basePath should be normalized + const result = window.eXeLearning.resolveAssetUrl('/libs/yjs'); + expect(result).toBe('/myapp/v1.0.0/libs/yjs'); + }); }); describe('sequential and parallel loading', () => { diff --git a/public/files/perm/idevices/base/interactive-video/edition/editor/index.html b/public/files/perm/idevices/base/interactive-video/edition/editor/index.html index bad67fa5c..e1c98ef1d 100644 --- a/public/files/perm/idevices/base/interactive-video/edition/editor/index.html +++ b/public/files/perm/idevices/base/interactive-video/edition/editor/index.html @@ -10,10 +10,30 @@ let head = document.querySelector("head"); - let basePath = window.parent.eXeLearning.symfony.fullURL; - let assetsPath = 'assets/' + window.parent.eXeLearning.version + '/'; + // URL composition utilities for iframe context + // Extract config values once from parent window + const baseURL = window.parent.eXeLearning?.config?.baseURL || ''; + const basePath = window.parent.eXeLearning?.config?.basePath || ''; + const parentResolveAssetUrl = window.parent.eXeLearning?.resolveAssetUrl?.bind(window.parent.eXeLearning); - let jqueryScriptPath = `${basePath}/libs/jquery/jquery.min.js`; + /** + * Compose URL for versioned static assets (cache-busted via version string). + * Falls back to basePath if resolveAssetUrl is unavailable (e.g., in exports). + * @param {string} path - Asset path starting with / + * @returns {string} Full URL with baseURL, basePath, and version + */ + const assetUrl = (path) => parentResolveAssetUrl + ? baseURL + parentResolveAssetUrl(path) + : baseURL + basePath + path; + + /** + * Compose URL for API endpoints (no versioning needed). + * @param {string} path - API path starting with / + * @returns {string} Full URL with baseURL and basePath + */ + const apiUrl = (path) => baseURL + basePath + path; + + let jqueryScriptPath = assetUrl('/libs/jquery/jquery.min.js'); let jqueryScript = document.createElement("script"); jqueryScript.id = "jqueryScript"; jqueryScript.src = jqueryScriptPath; @@ -22,21 +42,21 @@ jqueryScript.addEventListener("load", function (event) { - let mediaelementScriptPath = `${basePath}/app/common/exe_media/exe_media.js`; + let mediaelementScriptPath = assetUrl('/app/common/exe_media/exe_media.js'); let mediaelementScript = document.createElement("script"); mediaelementScript.id = "mediaelementScript"; mediaelementScript.src = mediaelementScriptPath; mediaelementScript.type = "text/javascript"; head.append(mediaelementScript); - let tinymceScriptPath = `${basePath}/libs/tinymce_5/js/tinymce/tinymce.min.js`; + let tinymceScriptPath = assetUrl('/libs/tinymce_5/js/tinymce/tinymce.min.js'); let tinymceScript = document.createElement("script"); tinymceScript.id = "tinymceScript"; tinymceScript.src = tinymceScriptPath; tinymceScript.type = "text/javascript"; head.append(tinymceScript); - let langsScriptPath = `${basePath}/api/idevices/download-file-resources?resource=perm/idevices/base/interactive-video/edition/editor/langs/all.js`; + let langsScriptPath = apiUrl('/api/idevices/download-file-resources?resource=perm/idevices/base/interactive-video/edition/editor/langs/all.js'); let langsScript = document.createElement("script"); langsScript.id = "langsScript"; langsScript.src = langsScriptPath; @@ -45,7 +65,7 @@ langsScript.addEventListener("load", function (event) { - let mediaelementStylePath = `${basePath}/app/common/exe_media/exe_media.css`; + let mediaelementStylePath = assetUrl('/app/common/exe_media/exe_media.css'); let mediaelementStyle = document.createElement("link"); mediaelementStyle.id = "mediaelementStyle"; mediaelementStyle.href = mediaelementStylePath; @@ -53,7 +73,7 @@ mediaelementStyle.type = "text/css"; head.append(mediaelementStyle); - let adminStylePath = `${basePath}/api/idevices/download-file-resources?resource=perm/idevices/base/interactive-video/edition/editor/css/admin.css`; + let adminStylePath = apiUrl('/api/idevices/download-file-resources?resource=perm/idevices/base/interactive-video/edition/editor/css/admin.css'); let adminStyle = document.createElement("link"); adminStyle.id = "adminStyle"; adminStyle.href = adminStylePath; @@ -61,7 +81,7 @@ adminStyle.type = "text/css"; head.append(adminStyle); - let adminScriptPath = `${basePath}/api/idevices/download-file-resources?resource=perm/idevices/base/interactive-video/edition/editor/js/admin.js`; + let adminScriptPath = apiUrl('/api/idevices/download-file-resources?resource=perm/idevices/base/interactive-video/edition/editor/js/admin.js'); let adminScript = document.createElement("script"); adminScript.id = "adminScript"; adminScript.src = adminScriptPath; diff --git a/public/files/perm/idevices/base/interactive-video/edition/editor/js/admin.js b/public/files/perm/idevices/base/interactive-video/edition/editor/js/admin.js index 1ec79e9f5..6e9874f46 100644 --- a/public/files/perm/idevices/base/interactive-video/edition/editor/js/admin.js +++ b/public/files/perm/idevices/base/interactive-video/edition/editor/js/admin.js @@ -14,7 +14,7 @@ if (top.interactiveVideoEditor) { InteractiveVideo = top.interactiveVideoEditor.activityToSave; } var iAdmin = { - domainPath: window.parent.eXeLearning.symfony.fullURL, + domainPath: window.parent.eXeLearning.config.fullURL, globals: { mode: 'add', // add or edit currentSlide: '', // The order of the slide that's being edited (a number): InteractiveVideo.slides[X], @@ -1767,7 +1767,7 @@ var iAdmin = { response.savedPath && response.savedFilename ) { - let fullPath = `${top.eXeLearning.symfony.fullURL}/${response.savedPath}${response.savedFilename}`; + let fullPath = `${top.eXeLearning.config.fullURL}/${response.savedPath}${response.savedFilename}`; cb(fullPath, { title: response.savedFilename, size: response.savedFileSize, diff --git a/public/files/perm/idevices/base/interactive-video/edition/interactive-video.js b/public/files/perm/idevices/base/interactive-video/edition/interactive-video.js index e79b99eb3..6cc619bbd 100644 --- a/public/files/perm/idevices/base/interactive-video/edition/interactive-video.js +++ b/public/files/perm/idevices/base/interactive-video/edition/interactive-video.js @@ -523,9 +523,10 @@ var $exeDevice = { $( '#modalGenericIframeContainer,#modalGenericIframeContainerCSS' ).remove(); + // API endpoints don't use versioning, just basePath const editorURL = - eXeLearning.symfony.baseURL + - eXeLearning.symfony.basePath + + eXeLearning.config.baseURL + + (eXeLearning.config.basePath || '') + '/api/idevices/download-file-resources?resource=' + filePath + 'editor/index.html'; diff --git a/public/libs/abcjs/exe_abc_music.js b/public/libs/abcjs/exe_abc_music.js index c8c676118..bc9ea24ea 100644 --- a/public/libs/abcjs/exe_abc_music.js +++ b/public/libs/abcjs/exe_abc_music.js @@ -226,12 +226,6 @@ $exeABCmusic = { appendFilesAbcNotation() { if (window.eXeLearning === undefined) return; // Not load the scripts dynamically in the export - // Use versioned path for cache busting: {basePath}/{version}/libs/... - const basePath = eXeLearning.config.basePath || ''; - const version = eXeLearning.version || 'v1.0.0'; - let libsPath = `${basePath}/${version}/libs`; - let abcmusicPath = `${libsPath}/tinymce_5/js/tinymce/plugins/abcmusic`; - let head = document.querySelector("head"); if (!head.querySelector('script.abcjs-basic-js')) { @@ -239,7 +233,8 @@ $exeABCmusic = { abcjsScript.classList.add("abcjs-basic-js"); abcjsScript.classList.add("exe"); abcjsScript.type = "text/javascript"; - abcjsScript.src = `${libsPath}/abcjs/abcjs-basic-min.js`; + // Use global helper for versioned asset URLs + abcjsScript.src = eXeLearning.resolveAssetUrl('/libs/abcjs/abcjs-basic-min.js'); head.append(abcjsScript); } @@ -247,7 +242,8 @@ $exeABCmusic = { let abcjsAudioCss = document.createElement("link"); abcjsAudioCss.classList.add("abcjs-basic-css"); abcjsAudioCss.classList.add("exe"); - abcjsAudioCss.href = `${libsPath}/abcjs/abcjs-audio.css`; + // Use global helper for versioned asset URLs + abcjsAudioCss.href = eXeLearning.resolveAssetUrl('/libs/abcjs/abcjs-audio.css'); abcjsAudioCss.rel = "stylesheet"; abcjsAudioCss.type = "text/css"; head.append(abcjsAudioCss); diff --git a/public/libs/tinymce_5/js/tinymce/plugins/codemagic/codemagic.html b/public/libs/tinymce_5/js/tinymce/plugins/codemagic/codemagic.html index c51981f58..0052177a5 100644 --- a/public/libs/tinymce_5/js/tinymce/plugins/codemagic/codemagic.html +++ b/public/libs/tinymce_5/js/tinymce/plugins/codemagic/codemagic.html @@ -10,11 +10,15 @@ diff --git a/public/libs/tinymce_5/js/tinymce/plugins/codemagic/plugin.min.js b/public/libs/tinymce_5/js/tinymce/plugins/codemagic/plugin.min.js index fff72ee45..4c286aaf9 100644 --- a/public/libs/tinymce_5/js/tinymce/plugins/codemagic/plugin.min.js +++ b/public/libs/tinymce_5/js/tinymce/plugins/codemagic/plugin.min.js @@ -67,14 +67,13 @@ jQuery(document).ready(function ($) { }); function open_codemagic() { - // Use API endpoint to bypass Bun's HTML bundler - var basePath = (window.eXeLearning && window.eXeLearning.config && window.eXeLearning.config.basePath) || ''; codemagicDialog = editor.windowManager.openUrl({ title: _('Edit source code'), width: 900, height: 650, - // maximizable: true, - url: basePath + '/api/codemagic-editor/codemagic.html' + url: eXeLearning.resolveAssetUrl( + '/libs/tinymce_5/js/tinymce/plugins/codemagic/codemagic.html' + ), }); } diff --git a/src/shared/export/browser/index.ts b/src/shared/export/browser/index.ts index 13962a2a3..7514f6cdf 100644 --- a/src/shared/export/browser/index.ts +++ b/src/shared/export/browser/index.ts @@ -497,7 +497,17 @@ export async function generatePreviewForSW( const latexHooks = getLatexPreRendererHooks(); // Wire up Mermaid pre-renderer hooks if available in browser context const mermaidHooks = getMermaidPreRendererHooks(); - const exportOptions = { ...options, ...latexHooks, ...mermaidHooks }; + + // Compute absolute URL for MathJax (bypasses Service Worker in preview mode) + // This allows MathJax to load directly from the server instead of SW cache + const windowEXE = (globalThis as unknown as { eXeLearning?: { resolveAssetUrl?: (path: string) => string } }) + .eXeLearning; + const mathJaxAbsoluteUrl = + typeof windowEXE?.resolveAssetUrl === 'function' + ? windowEXE.resolveAssetUrl('/app/common/exe_math/tex-mml-svg.js') + : undefined; + + const exportOptions = { ...options, ...latexHooks, ...mermaidHooks, mathJaxAbsoluteUrl }; // Generate preview files (Map) const filesMap = await exporter.generateForPreview(exportOptions); diff --git a/views/workarea/workarea.njk b/views/workarea/workarea.njk index ed04417e1..c62828f43 100644 --- a/views/workarea/workarea.njk +++ b/views/workarea/workarea.njk @@ -54,7 +54,29 @@ extension: "{{ extension }}", user: `{{ user | dump | safe }}`, config: `{{ config | dump | safe }}`, - projectId: {% if projectId %}"{{ projectId }}"{% else %}null{% endif %} + projectId: {% if projectId %}"{{ projectId }}"{% else %}null{% endif %}, + /** + * Resolve an asset path to a versioned URL with basePath + * URL pattern: {basePath}/{version}/path + * @param {string} path - Asset path (e.g., '/libs/yjs/yjs.min.js') + * @returns {string} Full versioned URL + */ + resolveAssetUrl: function(path) { + // Handle config being either string (before parsing) or object (after parsing) + let basePath = ''; + if (typeof this.config === 'string') { + try { + basePath = JSON.parse(this.config.replace(/"/g, '"')).basePath || ''; + } catch (e) { + basePath = ''; + } + } else if (this.config && typeof this.config === 'object') { + basePath = this.config.basePath || ''; + } + const version = this.version || 'v1.0.0'; + const normalizedPath = path.startsWith('/') ? path : '/' + path; + return basePath + '/' + version + normalizedPath; + } } {# TinyMCE #}