diff --git a/.distignore b/.distignore
index efb14b8..fdc801e 100644
--- a/.distignore
+++ b/.distignore
@@ -63,11 +63,15 @@ coverage
*.log
# --- Build / release outputs (recreated by `make package`) --------------
+# Anchor the archive excludes to the repo root with a leading slash: the
+# bundled eXeLearning editor ships its themes/content as
+# `js/editor/bundles/*.zip` (needed for the editor's Save/export), and an
+# unanchored `*.zip` would strip them from the package, breaking Save.
build
dist
release
-*.tar.gz
-*.zip
+/*.tar.gz
+/*.zip
# --- eXeLearning editor source clone ------------------------------------
# The built editor (downloaded or compiled) lives under js/editor/ and is
diff --git a/.github/workflows/playground-preview.yml b/.github/workflows/playground-preview.yml
index e4e63da..be33b4a 100644
--- a/.github/workflows/playground-preview.yml
+++ b/.github/workflows/playground-preview.yml
@@ -65,10 +65,24 @@ jobs:
echo "version=pr-${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT"
fi
- # Pulls the prebuilt static editor from the latest exelearning/exelearning
- # release (e.g. exelearning-static-vX.Y.Z.zip) into js/editor/ — no bun
- # build. `make package-zip` then bundles it. `clean` keeps js/editor/.
- - name: Download the eXeLearning static editor (latest release)
+ # Resolve the latest *stable* eXeLearning editor tag once, so the
+ # download and the version reported in the PR comment stay in sync.
+ - name: Resolve eXeLearning editor version
+ id: editor
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+ REF=$(gh api repos/exelearning/exelearning/releases/latest --jq '.tag_name')
+ echo "ref=$REF" >> "$GITHUB_OUTPUT"
+ echo "eXeLearning editor: $REF"
+
+ # Pulls the prebuilt static editor from that release (e.g.
+ # exelearning-static-vX.Y.Z.zip) into js/editor/ — no bun build.
+ # `make package-zip` then bundles it. `clean` keeps js/editor/.
+ - name: Download the eXeLearning static editor
+ env:
+ EXELEARNING_EDITOR_REF: ${{ steps.editor.outputs.ref }}
run: make download-editor
- name: Build the playground ZIP
@@ -117,27 +131,25 @@ jobs:
uses: actions/github-script@v7
env:
PREVIEW_URL: ${{ steps.link.outputs.url }}
- PREVIEW_TAG: ${{ steps.id.outputs.tag }}
+ PREVIEW_EDITOR: ${{ steps.editor.outputs.ref }}
with:
script: |
const marker = '';
const url = process.env.PREVIEW_URL;
- const tag = process.env.PREVIEW_TAG;
+ const editor = process.env.PREVIEW_EDITOR;
+ const button = 'https://raw.githubusercontent.com/ateeducacion/nextcloud-playground/refs/heads/main/assets/playground-preview-button.svg';
const body = [
marker,
- '### ▶️ Preview this PR in the Nextcloud Playground',
+ '### Preview this PR in the Nextcloud Playground',
'',
- `[**Open this PR in the Nextcloud Playground**](${url})`,
+ `
`,
'',
"A fresh Nextcloud boots in your browser with this branch's " +
- '`exelearning` app installed and enabled (log in as `admin` / `admin`, ' +
- 'then upload an `.elpx` in Files).',
- '',
- `Built artifact: \`exelearning.zip\` on the \`${tag}\` prerelease.`,
+ '`exelearning` app installed and enabled (log in as `admin` / `admin`). ' +
+ 'Two sample `.elpx` are seeded under `exelearning-samples/` in Files — ' +
+ 'click one to open the viewer.',
'',
- '> The viewer relies on a scoped Service Worker; some viewer features ' +
- "may be limited inside the playground's own Service Worker. Core " +
- 'install, Files and the app UI work.',
+ `Bundled eXeLearning editor: \`${editor}\` (latest stable release).`,
].join('\n');
const { owner, repo } = context.repo;
const issue_number = context.payload.pull_request.number;
diff --git a/README.md b/README.md
index 126764e..2858530 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,8 @@
# nextcloud-exelearning
[](https://github.com/exelearning/nextcloud-exelearning/actions/workflows/ci.yml)
-[](https://ateeducacion.github.io/nextcloud-playground/?blueprint-url=https://raw.githubusercontent.com/exelearning/nextcloud-exelearning/main/blueprint.json)
+
+
Preview and edit [eXeLearning](https://exelearning.net/) `.elpx` packages
directly inside Nextcloud Files.
diff --git a/blueprint.json b/blueprint.json
index a1fa879..ea29809 100644
--- a/blueprint.json
+++ b/blueprint.json
@@ -17,6 +17,8 @@
"email": "admin@example.com"
},
"steps": [
+ { "step": "disableApp", "app": "firstrunwizard" },
+ { "step": "setConfig", "app": "core", "key": "whatsNewEnabled", "value": "no" },
{
"step": "installApp",
"appId": "exelearning",
diff --git a/lib/Controller/EditorController.php b/lib/Controller/EditorController.php
index a403a4b..4432022 100644
--- a/lib/Controller/EditorController.php
+++ b/lib/Controller/EditorController.php
@@ -228,12 +228,17 @@ public function iframe(): DataDisplayResponse|DataResponse {
}
$editorBaseHref = rtrim($this->urlGenerator->linkTo(Application::APP_ID, ''), '/') . '/js/editor/';
- $parentOrigin = $this->request->getServerProtocol() . '://' . $this->request->getServerHost();
- $config = json_encode([
- 'basePath' => rtrim($editorBaseHref, '/'),
- 'parentOrigin' => $parentOrigin,
- 'trustedOrigins' => [$parentOrigin],
+ // `basePath`, `parentOrigin` and `trustedOrigins` are derived client-side
+ // from the live document base + window origin rather than baked in here.
+ // When Nextcloud is served under a scoped sub-path (e.g. the browser
+ // Playground rewrites `` to the scoped URL), a server-computed
+ // basePath would carry an empty webroot and the editor's ResourceFetcher
+ // would load its theme/content bundles from an unscoped path that 404s on
+ // save. Deriving from `document.baseURI` (which equals the scoped
+ // `` at runtime) keeps it correct in both a normal install and
+ // under a scoped path.
+ $staticConfig = json_encode([
'hideUI' => (object)[
'fileMenu' => true,
'saveButton' => true,
@@ -244,8 +249,14 @@ public function iframe(): DataDisplayResponse|DataResponse {
],
], JSON_UNESCAPED_SLASHES);
- $headInject = ''
- . '';
+ $configScript = '';
+
+ $headInject = '' . $configScript;
if (preg_match('/
]*>/i', $html, $m, PREG_OFFSET_CAPTURE)) {
$pos = $m[0][1] + strlen($m[0][0]);
$html = substr($html, 0, $pos) . $headInject . substr($html, $pos);
diff --git a/src/editor/editor-page.ts b/src/editor/editor-page.ts
index fee248b..571960d 100644
--- a/src/editor/editor-page.ts
+++ b/src/editor/editor-page.ts
@@ -27,15 +27,6 @@ if (typeof window !== 'undefined' && window.OC?.appswebroots?.exelearning) {
__webpack_public_path__ = `${window.OC.appswebroots.exelearning}/js/`
}
-/**
- *
- */
-function appWebRoot(): string {
- // Falls back to the canonical /apps path so unit tests and stripped-down
- // pages without OC.appswebroots still produce a sensible URL.
- return window.OC?.appswebroots?.exelearning ?? '/apps/exelearning'
-}
-
interface InitialFile {
id: number
name: string
@@ -64,11 +55,12 @@ async function boot(): Promise {
return
}
- // Defaults to the route URL; initial state can still override.
- const editorIframeUrl = safeLoad(
- 'editorIframeUrl',
- `${appWebRoot()}/editor/iframe`,
- )
+ // Build the iframe URL client-side so it carries the live webroot. The
+ // server-rendered initial-state value is computed with an empty webroot when
+ // Nextcloud runs under a sub-path (e.g. the browser Playground), so using it
+ // as the iframe src would escape the scope and 404. generateUrl() is correct
+ // in both a normal install and under a scoped path.
+ const editorIframeUrl = generateUrl('/apps/exelearning/editor/iframe')
const frame = new EditorFrame(root, { editorIframeUrl })
diff --git a/src/elpx/iframe-renderer.ts b/src/elpx/iframe-renderer.ts
index 5b30ffb..3ff39a9 100644
--- a/src/elpx/iframe-renderer.ts
+++ b/src/elpx/iframe-renderer.ts
@@ -33,19 +33,20 @@ export interface IframeOptions {
}
/**
- * Builds the sandboxed iframe that renders a registered package session.
- * The `src` is built from the runtime base + sessionId + index entry; the
- * Service Worker fulfils requests under that scope from in-memory bytes.
- * @param options Runtime base, sessionId, index entry and accessible title.
+ * Builds a sandboxed iframe pointed at an already-resolved `src`. Shared by the
+ * Service Worker path ({@link createPackageIframe}) and the server-side asset
+ * fallback so both get identical sandbox flags and external-link rewiring.
+ * @param src Fully-resolved URL the iframe should load.
+ * @param title Accessible title for the iframe.
*/
-export function createPackageIframe(options: IframeOptions): HTMLIFrameElement {
+export function buildSandboxedIframe(src: string, title: string): HTMLIFrameElement {
const iframe = document.createElement('iframe')
iframe.className = 'exelearning-viewer__iframe'
- iframe.title = options.title
+ iframe.title = title
iframe.setAttribute('sandbox', SANDBOX_FLAGS.join(' '))
iframe.setAttribute('allow', IFRAME_ALLOW)
iframe.setAttribute('referrerpolicy', 'no-referrer')
- iframe.src = buildRuntimeUrl(options.runtimeBase, options.sessionId, options.indexEntry)
+ iframe.src = src
iframe.addEventListener('load', () => {
try {
rewireExternalLinks(iframe)
@@ -57,6 +58,19 @@ export function createPackageIframe(options: IframeOptions): HTMLIFrameElement {
return iframe
}
+/**
+ * Builds the sandboxed iframe that renders a registered package session.
+ * The `src` is built from the runtime base + sessionId + index entry; the
+ * Service Worker fulfils requests under that scope from in-memory bytes.
+ * @param options Runtime base, sessionId, index entry and accessible title.
+ */
+export function createPackageIframe(options: IframeOptions): HTMLIFrameElement {
+ return buildSandboxedIframe(
+ buildRuntimeUrl(options.runtimeBase, options.sessionId, options.indexEntry),
+ options.title,
+ )
+}
+
/**
* Forces every external (`scheme:` or `//host`) link inside the iframe to
* open in a new tab with `noopener noreferrer`, so the Viewer modal never
diff --git a/src/elpx/paths.ts b/src/elpx/paths.ts
index 94cf84e..fd6efe4 100644
--- a/src/elpx/paths.ts
+++ b/src/elpx/paths.ts
@@ -6,6 +6,7 @@
*/
export const RUNTIME_PREFIX = '/apps/exelearning/runtime'
+export const ASSET_PREFIX = '/apps/exelearning/asset'
const PROTOCOL_LIKE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/
@@ -106,6 +107,30 @@ export function buildRuntimeUrl(base: string, sessionId: string, entry: string):
.join('/')}`
}
+/**
+ * Builds an iframe-loadable URL for an entry served by the **server-side**
+ * AssetController. Used as a fallback when the runtime Service Worker can't be
+ * registered (e.g. Nextcloud embedded in another origin, like the Playground,
+ * where the browser fetches the SW script straight from the network and 404s).
+ *
+ * `fileId` is the Nextcloud file id; the server re-checks the user can read it
+ * and extracts the requested entry from the stored package on demand.
+ * @param base Asset base URL (typically `generateUrl(ASSET_PREFIX)`).
+ * @param fileId Nextcloud file id of the `.elpx` package.
+ * @param entry Normalised entry path inside the package.
+ */
+export function buildAssetUrl(base: string, fileId: number, entry: string): string {
+ const normalized = normalizeEntryPath(entry)
+ if (normalized === null) {
+ throw new Error(`Refusing to build asset URL for unsafe entry: ${entry}`)
+ }
+ const cleanBase = base.replace(/\/+$/, '')
+ return `${cleanBase}/${encodeURIComponent(String(fileId))}/${normalized
+ .split('/')
+ .map(encodeURIComponent)
+ .join('/')}`
+}
+
/**
* Parses a runtime URL produced by {@link buildRuntimeUrl} back into its
* session and entry components. The base path must match RUNTIME_PREFIX.
diff --git a/src/view/view-page.ts b/src/view/view-page.ts
index fcb2445..528cdce 100644
--- a/src/view/view-page.ts
+++ b/src/view/view-page.ts
@@ -1,6 +1,7 @@
// Same publicPath fix as main.ts.
import { createApp } from 'vue'
import { loadState } from '@nextcloud/initial-state'
+import { generateUrl } from '@nextcloud/router'
import ElpxViewPage from './ElpxViewPage.vue'
@@ -47,7 +48,13 @@ function boot(): void {
const file = safeLoad('file', null)
const editorAvailable = safeLoad('editorAvailable', false)
- const editorIframeUrl = safeLoad('editorIframeUrl', '/apps/exelearning/editor/iframe')
+ // Build the editor iframe URL client-side so it carries the correct webroot.
+ // The server-rendered value (via initial state) is computed with an empty
+ // webroot when Nextcloud is served under a sub-path (e.g. the browser
+ // Playground scopes everything under /playground//…); using it as the
+ // iframe src verbatim would escape that scope and 404. generateUrl() resolves
+ // against the live OC.webroot, which is correct in both cases.
+ const editorIframeUrl = generateUrl('/apps/exelearning/editor/iframe')
const initialMode = safeLoad<'preview' | 'editor'>('initialMode', 'preview')
createApp(ElpxViewPage, { file, editorAvailable, editorIframeUrl, initialMode })
diff --git a/src/viewer/ElpxViewer.vue b/src/viewer/ElpxViewer.vue
index b552bf9..49f3aba 100644
--- a/src/viewer/ElpxViewer.vue
+++ b/src/viewer/ElpxViewer.vue
@@ -34,6 +34,7 @@