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 #}