feat(styles): administrator-managed style registry for the embedded editor#32
Conversation
…ditor
Adds an admin Styles management page (Site administration → Plugins →
Activity modules → eXeLearning (website) → Styles) where managers can
upload eXeLearning style .zip packages, enable/disable built-in styles,
and enable/disable/delete uploaded ones — without rebuilding the
static editor bundle.
Architecture
- `mod_exeweb\local\styles_service` owns the pure logic: ZIP
validation (path traversal, absolute paths, size cap, extension
allow-list, single config.xml), slug allocation with collision
suffix, registry persistence in config_plugin(exeweb), and the
`build_theme_registry_override()` payload the editor consumes.
- `admin/styles.php` is a standard Moodle admin_externalpage that
renders the upload form and lists, using HTTP POST + sesskey
(no custom AJAX).
- `editor/styles.php/{slug}/{file}` serves the extracted style
assets from moodledata with PATH_INFO, gated by site capability
checks and a registry membership check.
- Uploaded style bundles extract to
`{dataroot}/mod_exeweb/styles/{slug}/` — a sibling of
`mod_exeweb/embedded_editor/` so reinstalling the embedded editor
never destroys admin-managed styles.
- `editor/index.php` injects
`window.eXeLearning.config.themeRegistryOverride` and mirrors
`blockImportInstall` onto the pre-existing `userStyles`
(ONLINE_THEMES_INSTALL) flag so the 'Import this project style?'
modal is suppressed end-to-end.
Admin toggle
- `stylesblockimport` (default: 1) controls whether imported
project styles are refused. When on, the editor hides the
'Imported' tab (see companion core PR) and silently falls back
to the default style instead of offering to install.
Behavior
- Disabled built-ins disappear from the editor's selector.
- Uploaded styles show up alongside built-ins with stable ids.
- Projects referencing a missing/disabled style fall back to `base`.
- The admin link appears only when editor mode is 'embedded'.
Tests
- `tests/local/styles_service_test.php`: ZIP validator edge cases,
install, unique-slug on collision, delete, override enabled flag,
import-blocked config contract.
Language
- Adds strings under `styles*` in `lang/en/exeweb.php`.
Depends on
- Core editor hook: exelearning/exelearning#1722 (merged).
- Core editor UI follow-up: exelearning/exelearning#1724 (hides
the 'Imported' tab when blockImportInstall is set).
Replace the plain <input type="file"> with a Moodle filemanager
element wrapped in a moodleform. Administrators now get the same UX
they already know from every other file setting in Moodle: drag-and-
drop, multi-file, native filetype restrictions, progress, previews.
On submit, the page consumes the draft filearea: each uploaded .zip
is copied to a per-request temp path, validated and extracted via
styles_service::install_from_zip(), and the draft file is deleted.
A single redirect reports 'Installed: A, B, C' or error details
per-file so the admin knows exactly what happened.
Where do the ZIPs go? Moodle's File API (files table + filedir)
receives them into the draft area of the uploading user; after
extraction they live at {dataroot}/mod_exeweb/styles/{slug}/ and the
draft is discarded. No .zip is retained after install, so nothing
needs to be garbage-collected later.
Matches upstream eXeLearning ONLINE_THEMES_INSTALL=true, so existing installs and new ones preserve the familiar behavior (imports allowed). Admins now opt-in to the lockdown from Site admin → Styles instead of being opted in silently.
Move the filemanager upload widget from a standalone admin page into the plugin's own settings page, matching the existing 'Default template' configstoredfile pattern so admins don't have to context-switch to install styles. - classes/admin/admin_setting_stylesupload.php — extends the native admin_setting_configstoredfile. On save the parent stores the dropped files in the 'styles_drops' filearea; we then walk the area, validate + extract each ZIP via styles_service::install_from_zip and delete the consumed file. Admin notifications report per-file success or error. - settings.php — add the inline setting and keep a slim link pointing to /mod/exeweb/admin/styles.php for the list/toggle/delete UI. Extend the JS toggle so the upload field, the management link and the block-import checkbox all hide when editor mode != embedded. - admin/styles.php — remove the moodleform upload path; the page is now just the lists + toggle/delete actions, plus a link back to the settings filemanager. - classes/form/styles_upload_form.php — deleted (superseded). - lang/en + lang/es — new strings for the inline UX and Spanish translations for every new key.
Replace the 'Manage installed styles' link with two inline widgets so admins tick/untick styles directly in the plugin settings page, same as any other Moodle multicheckbox setting. - admin_setting_stylesbuiltins: one checkbox per built-in style. Unchecking hides the style from the editor's selector. The styles_registry is the single source of truth; this widget reads from and writes back to it. - admin_setting_stylesuploaded: one checkbox per uploaded style, plus an inline Delete link (sesskey-protected, confirm prompt). - settings.php: drops the old 'Manage installed styles' button and wires in the two widgets. JS toggle now hides the whole styles block (upload, uploaded list, built-in list, block-import) when editor mode != embedded. - lang: new *_hint strings + Spanish translations.
Three integration fixes to bring mod_exeweb in line with wp-exelearning after end-to-end testing in WordPress: - Trap reassignments of window.eXeLearning and window.eXeLearning.config. The static editor's boot sequence reassigns both (the inline script in index.html resets the whole object, and app.bundle.js later parses 'config' from JSON back into an object), so a plain assignment of themeRegistryOverride is wiped before the editor reads it. Wrap the injection in a self-restoring defineProperty getter/setter so the override and the userStyles mirror survive every reset. - Switch the uploaded-style type from 'uploaded' to 'admin' in the override payload. The editor's navbar tabs filter strictly: 'base' | 'site' | 'admin' render in Sistema, 'user' in Imported, and any other value is silently dropped. Admin-uploaded styles match 'admin' semantically and land on the Sistema tab alongside built-ins. - Publish a 'files' manifest alongside each uploaded entry, enumerated from the extracted storage directory. The embedded editor's ResourceFetcher consumes it via themeRegistryOverride.uploaded[].files to fetch admin-approved styles file by file instead of expecting a zip under /bundles/themes/, so preview and HTML5 export pack the real theme assets under theme/* rather than falling back to the placeholder theme/content.css + theme/default.js.
Three end-to-end bugs discovered after installing a style from the
admin UI and then selecting it in the embedded editor:
- Register the external styles page under the existing 'modsettings'
category. It was added to 'modsettingsexeweb' which does not exist,
so /admin/search.php and the plugin settings loader spammed
"parent does not exist!" debug messages via admin_get_root().
- Read the style drops filearea from component 'exeweb' instead of
'mod_exeweb'. admin_setting_configstoredfile derives the component
from the first segment of the setting name ('exeweb/styles_drops'
→ 'exeweb'), so files saved by the parent's write_setting() live
under that component; our consume_pending_uploads() was walking a
filearea that Moodle never writes to, silently failing to register
every uploaded ZIP.
- Require filelib.php before calling send_file() in the styles
serve endpoint. Moodle autoloads lib/setuplib.php, but send_file
lives in lib/filelib.php, so the first request to
/mod/exeweb/editor/styles.php/<slug>/<file> crashed with
"Call to undefined function send_file()" rendered as a themed 404
page, which the embedded editor treated as missing CSS.
ZipArchive::statIndex() surfaces every explicit directory entry in the archive (e.g. 'img/', 'fonts/'), and is_allowed_filename() returns false for any trailing-slash name because it looks for a file extension. Real style packages always contain subdirectories, so uploading a valid ZIP crashed with "stylesupload_badext: img/" and the registry stayed empty. Skip directory entries before the prefix and extension checks — they are not assets, and the per-file extraction path already iterates the full archive and only writes regular files.
There was a problem hiding this comment.
Thank you. It's a great work. Only a few details are not working correctly:
- When you install the plugin and switch to the inline editor, the block that allows you to install the editor does not appear immediately. After saving, the block does appear. The
onchangeevent is not triggered. The event is trigger if you edit the plugin configuration later. This is not important. It's just a usability issue for admin users. - The
screenshot.pngfor those styles is not displayed. It returns a 404 error:
/mod/exeweb/editor/styles.php/style-name/screenshot.png.
The editor's icon picker reads `icons` from the theme payload that the
host integration emits via `themeRegistryOverride`. Built-in themes get
this list synthesized by the editor itself (theme-parser.ts::scanThemeIcons
walks the `icons/` folder), but admin-uploaded styles arrive prebuilt and
were missing the field, so the picker fell back to the empty default and
showed nothing.
Mirror the upstream scan logic in PHP: walk `<style-dir>/icons/`, accept
png/svg/gif/jpg/jpeg, build `[id => {id, title, type:img, value:url}]`,
and include it on every uploaded entry. URLs are served by the existing
editor/styles.php endpoint.
ignaciogros
left a comment
There was a problem hiding this comment.
Thanks for those changes, @erseco.
I've tested again and:
1. Can't save without adding a style
- In the configuration page, add a style and save.
- Try to save without adding a new style.
You can't save, because a style seems to be required:
I believe that field should never be required.
2. Block import preference issue
- Block style imports.
- Add an activity with a content that uses a different style (not installed).
- eXe asks if you want to install it.
It doesn't install it, but maybe it shouldn't ask. This is just a usability problem and it's not important right now.
3. Missing screenshot
- Add a style and open eXe. The screenshot's there.
- Add a new style. The screenshot is missing.
All that's probably happening in mod_exescorm too. I'll test it as soon as possible.
Thanks!
Sorry, @erseco... My fault. One of the styles I was using for testing had no screenshot... That works. |
The styles upload setting delegated to admin_setting_configstoredfile, which persists the submitted file path in plugin config and trips its errorsetting validation when the form is saved a second time without changes -- by then consume_pending_uploads() has already extracted and removed the ZIP, so the cached config points at a file that no longer exists. Bypass the parent and stage drafts directly via file_save_draft_area_files(), returning '' regardless of whether a new ZIP was attached. Built-in styles continue to default to enabled because disabled_builtins remains empty on first install.
The static editor's ResourceFetcher rejects on missing CSS / iDevice resources, which surfaces as an "Uncaught (in promise)" that aborts the Yjs theme bind and leaves the editor unresponsive when one of those assets is unreachable (notably under moodle-playground, where static.php cannot resolve every theme path embedded in bundle.json). Port the WP / Omeka-S workaround: wrap window.fetch and jQuery's $.ajaxTransport so 404s on .css / idevices URLs return an empty stylesheet, and short-circuit the editor's preview-sw.js registration so the registration error stops spamming the console without blocking anything. Brings mod_exeweb in line with the other host integrations.
ignaciogros
left a comment
There was a problem hiding this comment.
Great job, @erseco!
This is still happening when you disable styles upload, but I don't think it's a problem now:
I suggest merging these improvements into main now and trying to fix minor issues later. The plugin might have to handle other cases too. Example:
- admin allows style imports,
- a user imports a style,
- admin disallows imports.
What happens if the user edits a content that uses an imported style?
Maybe the plugin's already handling those cases...
admin_find_write_settings() only routes a setting through write_setting() when its parent name appears in $_POST. With every checkbox in the styles_uploaded / styles_builtins widgets cleared the browser submits nothing under that name, so the registry never learned the user disabled the last enabled entry — unchecking the only uploaded style and saving left it enabled. Mirror core's admin_setting_configmulticheckbox template by emitting a hidden sentinel input next to the list so the parent name is always present and write_setting() runs even when nothing is checked.
ignaciogros
left a comment
There was a problem hiding this comment.
Since this PR introduces substantial changes, I think it should be merged into main now, and address those problems in dedicated issues.

Summary
Adds a Site administration → Plugins → Activity modules → eXeLearning (website) → Styles management page where managers upload eXeLearning style
.zippackages, enable/disable built-in styles, and enable/disable/delete uploaded ones — without rebuilding the static editor bundle or editing plugin source files.Closes #28.
Changes
classes/local/styles_service.php— pure logic: ZIP validator(path traversal / absolute paths / size cap / extension allow-list /
single config.xml), slug allocation with collision suffix, registry
persistence via
config_plugin(exeweb), and thebuild_theme_registry_override()payload the editor consumes.admin/styles.php— standard Moodleadmin_externalpagewith theupload form, uploaded/built-in tables, and toggle/delete actions
via HTTP POST + sesskey.
editor/styles.php/{slug}/{file}— PATH_INFO endpoint that servesextracted style assets from moodledata, gated by capability and
registry-membership checks.
editor/index.php— injectswindow.eXeLearning.config.themeRegistryOverrideand mirrorsblockImportInstallonto the pre-existinguserStyles(ONLINE_THEMES_INSTALL) flag so the "Import this project style?"
modal is suppressed end-to-end.
settings.php— adds link to the styles page (hidden when editormode ≠ embedded) and the
stylesblockimportadmin checkbox(default: on).
lang/en/exeweb.php— newstyles*strings.tests/local/styles_service_test.php— unit tests covering thevalidator, registry, install, collision suffix, delete, override
shape, and the import-blocked config contract.
Storage
Uploaded style bundles extract to
{dataroot}/mod_exeweb/styles/{slug}/— a sibling of the editorinstall directory, so reinstalling the embedded editor never destroys
admin-managed styles.
Admin toggle: "Block user-imported styles"
Defaults to on. When on:
feat(themes): hide the Imported styles tab when imports are blocked exelearning#1724).
imported .elpx, falling back to the default style.
This mirrors the eXeLearning
ONLINE_THEMES_INSTALL=falsepolicy.Backward compatibility
empty registry the editor sees all built-ins.
base.moodle/site:configand
mod/exeweb:manageembeddededitorcapabilities.Test plan
php -lpasses on all modified/new files.tests/local/styles_service_test.phpcovers validator edge cases, registry behavior, collision
suffix, delete, override shape, and the blockimport toggle.
in the editor's style selector.
editor's style selector.
editor's "Imported" tab returns.
the editor falls back to
basewithout errors.Depends on
themeRegistryOverridehook (merged).Moodle Playground Preview
The changes in this pull request can be previewed and tested using a Moodle Playground instance.