Skip to content

feat(styles): administrator-managed style registry for the embedded editor#32

Merged
eXeLearningProject merged 15 commits intomainfrom
28-allow-installation-and-management-of-styles
Apr 29, 2026
Merged

feat(styles): administrator-managed style registry for the embedded editor#32
eXeLearningProject merged 15 commits intomainfrom
28-allow-installation-and-management-of-styles

Conversation

@erseco
Copy link
Copy Markdown
Collaborator

@erseco erseco commented Apr 23, 2026

Summary

Adds a Site administration → Plugins → Activity modules → eXeLearning (website) → Styles management page where managers upload eXeLearning style .zip packages, enable/disable built-in styles, and enable/disable/delete uploaded ones — without rebuilding the static editor bundle or editing plugin source files.

Closes #28.

Captura de pantalla 2026-04-23 a las 22 18 03

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 the
    build_theme_registry_override() payload the editor consumes.
  • admin/styles.php — standard Moodle admin_externalpage with the
    upload form, uploaded/built-in tables, and toggle/delete actions
    via HTTP POST + sesskey.
  • editor/styles.php/{slug}/{file} — PATH_INFO endpoint that serves
    extracted style assets from moodledata, gated by capability and
    registry-membership checks.
  • 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.
  • settings.php — adds link to the styles page (hidden when editor
    mode ≠ embedded) and the stylesblockimport admin checkbox
    (default: on).
  • lang/en/exeweb.php — new styles* strings.
  • tests/local/styles_service_test.php — unit tests covering the
    validator, 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 editor
install directory, so reinstalling the embedded editor never destroys
admin-managed styles.

Admin toggle: "Block user-imported styles"

Defaults to on. When on:

This mirrors the eXeLearning ONLINE_THEMES_INSTALL=false policy.

Backward compatibility

  • Existing installs continue to work without admin action; with an
    empty registry the editor sees all built-ins.
  • Projects referencing a disabled/deleted style fall back to base.
  • The styles page is only reachable with both moodle/site:config
    and mod/exeweb:manageembeddededitor capabilities.

Test plan

  • php -l passes on all modified/new files.
  • Unit test suite in tests/local/styles_service_test.php
    covers validator edge cases, registry behavior, collision
    suffix, delete, override shape, and the blockimport toggle.
  • Upload a style .zip via the admin page and confirm it shows up
    in the editor's style selector.
  • Toggle a built-in off and confirm it disappears from the
    editor's style selector.
  • Toggle "Block user-imported styles" off and confirm the
    editor's "Imported" tab returns.
  • Open an .elpx that references a disabled style and confirm
    the editor falls back to base without errors.

Depends on


Moodle Playground Preview

The changes in this pull request can be previewed and tested using a Moodle Playground instance.

Preview in Moodle Playground

⚠️ The embedded eXeLearning editor is not included in this preview. You can install it from Modules > eXeLearning Web > Configure using the "Download & Install Editor" button. All other module features (ELPX upload, viewer, preview) work normally.

…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).
@erseco erseco linked an issue Apr 23, 2026 that may be closed by this pull request
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.
erseco added 3 commits April 23, 2026 19:30
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.
@erseco erseco self-assigned this Apr 23, 2026
erseco and others added 4 commits April 23, 2026 23:54
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.
Copy link
Copy Markdown
Collaborator

@ignaciogros ignaciogros left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 onchange event 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.png for those styles is not displayed. It returns a 404 error:
    /mod/exeweb/editor/styles.php/style-name/screenshot.png.

Copy link
Copy Markdown
Collaborator

@ignaciogros ignaciogros left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just found another thing:

Image

The style I uploaded had icons, but you can't select an icon.

@ignaciogros
Copy link
Copy Markdown
Collaborator

ignaciogros commented Apr 27, 2026

If users are not allowed to install a style from a package, maybe this area should be hidden too, as in other versions:

imagen

erseco and others added 2 commits April 28, 2026 11:36
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.
Copy link
Copy Markdown
Collaborator

@ignaciogros ignaciogros left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Image

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!

@ignaciogros
Copy link
Copy Markdown
Collaborator

  1. Missing screenshot

Sorry, @erseco...

My fault. One of the styles I was using for testing had no screenshot...

That works.

@erseco erseco requested a review from ignaciogros April 28, 2026 16:09
erseco added 2 commits April 28, 2026 17:09
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.
Copy link
Copy Markdown
Collaborator

@ignaciogros ignaciogros left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great job, @erseco!

This is still happening when you disable styles upload, but I don't think it's a problem now:

imagen

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...

ignaciogros and others added 2 commits April 29, 2026 09:31
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.
@erseco erseco requested a review from ignaciogros April 29, 2026 10:00
Copy link
Copy Markdown
Collaborator

@ignaciogros ignaciogros left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this PR introduces substantial changes, I think it should be merged into main now, and address those problems in dedicated issues.

@eXeLearningProject eXeLearningProject merged commit 860dd4a into main Apr 29, 2026
1 check passed
@eXeLearningProject eXeLearningProject deleted the 28-allow-installation-and-management-of-styles branch April 29, 2026 10:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow installation and management of styles

3 participants