diff --git a/README.md b/README.md
index 659a6b5..e160055 100644
--- a/README.md
+++ b/README.md
@@ -95,6 +95,26 @@ Administrators can upload eXeLearning style packages and control which styles th
Uploaded ZIPs are validated against path traversal, absolute paths, oversize archives (default 20 MB, filterable via `exelearning_styles_max_zip_size`), and a strict file-extension allow-list.
+## Developer hooks
+
+The plugin exposes a set of WordPress actions and filters (all prefixed with
+`exelearning_`) at its main lifecycle boundaries, so you can observe events and
+enrich presentation or metadata without modifying the plugin:
+
+- **ELPX extraction** — `exelearning_before_elpx_extract`, `exelearning_after_elpx_extract`
+- **Metadata** — `exelearning_elpx_metadata` (filter), `exelearning_after_elpx_metadata_saved`
+- **REST save** — `exelearning_before_elpx_save`, `exelearning_after_elpx_save`
+- **Shortcode rendering** — `exelearning_shortcode_atts`, `exelearning_preview_url`, `exelearning_shortcode_output` (filters)
+- **Styles** — `exelearning_after_style_installed`, `exelearning_after_style_deleted`, `exelearning_after_style_enabled_changed`, `exelearning_style_registry_entry` (filter)
+- **Static editor install** — `exelearning_before_editor_install`, `exelearning_after_editor_install`, `exelearning_editor_install_failed`
+
+These hooks are limited to observation and presentation/metadata enrichment: they
+cannot bypass validation, capability/nonce checks, path-traversal protection,
+checksum verification, or the content-proxy security model.
+
+See [`docs/HOOKS.md`](docs/HOOKS.md) for the full reference, parameters, return
+values, and usage examples.
+
## Development
For development, you can bring up a local WordPress environment with the plugin pre-installed:
diff --git a/docs/HOOKS.md b/docs/HOOKS.md
new file mode 100644
index 0000000..0dafa88
--- /dev/null
+++ b/docs/HOOKS.md
@@ -0,0 +1,402 @@
+# Developer hooks
+
+The eXeLearning plugin exposes a set of WordPress actions and filters at its main
+lifecycle boundaries so third-party developers and institutional integrations can
+observe events and enrich presentation or metadata.
+
+All hook names are prefixed with `exelearning_`.
+
+## Security model
+
+These hooks are intentionally limited to **observation** and **presentation/metadata
+enrichment**. They do **not** let a callback bypass any security control. In
+particular, no hook can:
+
+- Skip ZIP, MIME, or path-traversal validation.
+- Skip capability or nonce checks.
+- Change the trusted storage directories or the extraction hash.
+- Disable CSP/security headers or the content-proxy model.
+- Allow arbitrary remote editor download URLs, or weaken checksum/digest checks.
+- Alter cleanup behavior in a way that could leave orphaned or unsafe files.
+
+Every filter validates its return value defensively. Required internal keys are
+always restored from the plugin's trusted values, so a misbehaving callback can add
+data but can never drop or corrupt the data the plugin depends on.
+
+---
+
+## Actions
+
+### `exelearning_before_elpx_extract`
+
+Fires right before an `.elpx` archive is extracted, after it has already passed
+open/validation and the zip-bomb count guard. Observation only — it must not be used
+to bypass validation or change extraction behavior.
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `$file` | `string` | Source `.elpx` file path. |
+| `$destination` | `string` | Destination directory the archive extracts into. |
+
+```php
+add_action(
+ 'exelearning_before_elpx_extract',
+ function ( $file, $destination ) {
+ error_log( sprintf( 'About to extract %s into %s', $file, $destination ) );
+ },
+ 10,
+ 2
+);
+```
+
+### `exelearning_after_elpx_extract`
+
+Fires after an `.elpx` archive has been **successfully** extracted. It never fires on
+a failed or partial extraction.
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `$file` | `string` | Source `.elpx` file path. |
+| `$destination` | `string` | Final extraction directory. |
+| `$metadata` | `array` | Metadata parsed from the archive. |
+
+```php
+add_action(
+ 'exelearning_after_elpx_extract',
+ function ( $file, $destination, $metadata ) {
+ error_log( sprintf( 'ELPX extracted: %s', $file ) );
+ },
+ 10,
+ 3
+);
+```
+
+### `exelearning_after_elpx_metadata_saved`
+
+Fires after ELPX metadata has been written to the attachment post meta (both on
+upload and on reprocessing).
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `$attachment_id` | `int` | WordPress attachment ID. |
+| `$metadata` | `array` | Final metadata array that was saved. |
+
+```php
+add_action(
+ 'exelearning_after_elpx_metadata_saved',
+ function ( $attachment_id, $metadata ) {
+ // For example, index the resource in an external catalogue.
+ do_my_catalogue_sync( $attachment_id, $metadata );
+ },
+ 10,
+ 2
+);
+```
+
+### `exelearning_before_elpx_save`
+
+Fires before an existing `.elpx` file is replaced through the REST editor endpoint,
+after the request has already passed capability and nonce checks. Observation only —
+it must not change the storage path or replacement/cleanup behavior.
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `$attachment_id` | `int` | Attachment being updated. |
+| `$old_file_path` | `string` | Current file path before replacement. |
+
+```php
+add_action(
+ 'exelearning_before_elpx_save',
+ function ( $attachment_id, $old_file_path ) {
+ error_log( sprintf( 'Saving attachment %d', $attachment_id ) );
+ },
+ 10,
+ 2
+);
+```
+
+### `exelearning_after_elpx_save`
+
+Fires after an `.elpx` file has been successfully saved and committed: the new file
+is written, the new content is extracted, and metadata is persisted. It never fires
+on a failed save, extraction, or metadata persistence.
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `$attachment_id` | `int` | Updated attachment ID. |
+| `$new_hash` | `string` | Hash of the new extracted content. |
+| `$old_hash` | `string` | Previous extraction hash, or `''` if none. |
+
+```php
+add_action(
+ 'exelearning_after_elpx_save',
+ function ( $attachment_id, $new_hash, $old_hash ) {
+ error_log( sprintf( 'Attachment %d now at %s', $attachment_id, $new_hash ) );
+ },
+ 10,
+ 3
+);
+```
+
+### `exelearning_after_style_installed`
+
+Fires after a style ZIP has been installed and registered.
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `$slug` | `string` | Style slug. |
+| `$entry` | `array` | Final style registry entry that was persisted. |
+
+```php
+add_action(
+ 'exelearning_after_style_installed',
+ function ( $slug, $entry ) {
+ error_log( sprintf( 'Style installed: %s', $slug ) );
+ },
+ 10,
+ 2
+);
+```
+
+### `exelearning_after_style_deleted`
+
+Fires after an uploaded style has been deleted (both the files on disk and the
+registry entry).
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `$slug` | `string` | Style slug that was deleted. |
+
+```php
+add_action(
+ 'exelearning_after_style_deleted',
+ function ( $slug ) {
+ error_log( sprintf( 'Style deleted: %s', $slug ) );
+ }
+);
+```
+
+### `exelearning_after_style_enabled_changed`
+
+Fires after an uploaded style has been enabled or disabled.
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `$slug` | `string` | Style slug. |
+| `$enabled` | `bool` | New enabled state. |
+
+```php
+add_action(
+ 'exelearning_after_style_enabled_changed',
+ function ( $slug, $enabled ) {
+ error_log( sprintf( 'Style %s enabled=%s', $slug, $enabled ? 'yes' : 'no' ) );
+ },
+ 10,
+ 2
+);
+```
+
+### `exelearning_before_editor_install`
+
+Fires before the static editor installation starts, after the target version has
+been resolved. Observation only — it must not change the download URL or skip
+checksum/archive validation.
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `$version` | `string` | Requested editor version. |
+
+```php
+add_action(
+ 'exelearning_before_editor_install',
+ function ( $version ) {
+ error_log( sprintf( 'Installing editor %s', $version ) );
+ }
+);
+```
+
+### `exelearning_after_editor_install`
+
+Fires after the static editor has been installed successfully.
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `$metadata` | `array` | Installed editor metadata (`version`, `installed_at`). |
+
+```php
+add_action(
+ 'exelearning_after_editor_install',
+ function ( $metadata ) {
+ error_log( sprintf( 'Editor installed: %s', $metadata['version'] ) );
+ }
+);
+```
+
+### `exelearning_editor_install_failed`
+
+Fires when the static editor installation fails.
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `$error` | `WP_Error` | WP_Error describing the failure. |
+
+```php
+add_action(
+ 'exelearning_editor_install_failed',
+ function ( $error ) {
+ error_log( sprintf( 'Editor install failed: %s', $error->get_error_message() ) );
+ }
+);
+```
+
+---
+
+## Filters
+
+### `exelearning_elpx_metadata`
+
+Filters the ELPX metadata array before it is saved to attachment meta. Callbacks may
+enrich the array with additional keys but **must return an array**. Required internal
+keys (`_exelearning_title`, `_exelearning_description`, `_exelearning_license`,
+`_exelearning_language`, `_exelearning_resource_type`, `_exelearning_extracted`,
+`_exelearning_version`, `_exelearning_has_preview`) are always restored afterwards,
+so this filter cannot drop or tamper with the plugin's own metadata or the extraction
+hash.
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `$metadata` | `array` | Metadata array keyed by post meta key. |
+| `$file` | `string` | Source `.elpx` file path, or `''` when unavailable. |
+| `$elp_service` | `ExeLearning_Elp_File_Service` | The service that parsed the archive. |
+
+**Returns:** `array` — the enriched metadata array.
+
+```php
+add_filter(
+ 'exelearning_elpx_metadata',
+ function ( $metadata, $file, $elp_service ) {
+ $metadata['_exelearning_department'] = 'Mathematics';
+ return $metadata;
+ },
+ 10,
+ 3
+);
+```
+
+### `exelearning_shortcode_atts`
+
+Filters the `[exelearning]` shortcode attributes after defaults are merged and before
+rendering. The values the renderer relies on are re-sanitized afterwards, so this
+filter cannot inject unsafe values or bypass the attachment/permission checks.
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `$atts` | `array` | Sanitized shortcode attributes. |
+| `$file_id` | `int` | Attachment ID parsed from the shortcode. |
+
+**Returns:** `array` — the shortcode attributes.
+
+```php
+add_filter(
+ 'exelearning_shortcode_atts',
+ function ( $atts, $file_id ) {
+ $atts['height'] = 900;
+ return $atts;
+ },
+ 10,
+ 2
+);
+```
+
+### `exelearning_preview_url`
+
+Filters the preview URL before it is rendered into the iframe. The value is still
+escaped with `esc_url()` at output time. This filter must **not** be used to bypass
+the content-proxy security model; pointing the iframe at an unverified external
+origin is unsupported.
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `$preview_url` | `string` | Proxy preview URL. |
+| `$file_id` | `int` | Attachment ID. |
+| `$extracted_dir` | `string` | Extraction hash/directory for the attachment. |
+
+**Returns:** `string` — the preview URL.
+
+```php
+add_filter(
+ 'exelearning_preview_url',
+ function ( $preview_url, $file_id, $extracted_dir ) {
+ return add_query_arg( 'lang', 'en', $preview_url );
+ },
+ 10,
+ 3
+);
+```
+
+### `exelearning_shortcode_output`
+
+Filters the final shortcode HTML before it is returned. It receives the
+already-rendered, escaped HTML and lets themes or integrations wrap or modify it. The
+default output is unchanged when no callback is attached. Any HTML added by a callback
+is its own responsibility to keep safe.
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `$html` | `string` | Rendered shortcode HTML. |
+| `$file_id` | `int` | Attachment ID. |
+| `$atts` | `array` | Shortcode attributes used to render the output. |
+
+**Returns:** `string` — the shortcode HTML.
+
+```php
+add_filter(
+ 'exelearning_shortcode_output',
+ function ( $html, $file_id, $atts ) {
+ return '
' . $html . '
';
+ },
+ 10,
+ 3
+);
+```
+
+### `exelearning_style_registry_entry`
+
+Filters a style registry entry before it is persisted. **Must return an array**; a
+non-array return is discarded. Required internal keys (`css_files`, `enabled`,
+`installed_at`, `checksum`, `size`) are always restored from the trusted built entry
+so this filter cannot inject unsafe paths, strip integrity fields, or bypass ZIP
+validation.
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `$entry` | `array` | Style registry entry built from the validated ZIP. |
+| `$slug` | `string` | Allocated style slug. |
+| `$config` | `array` | Parsed style configuration. |
+
+**Returns:** `array` — the registry entry.
+
+```php
+add_filter(
+ 'exelearning_style_registry_entry',
+ function ( $entry, $slug, $config ) {
+ $entry['category'] = 'institutional';
+ return $entry;
+ },
+ 10,
+ 3
+);
+```
+
+---
+
+## Existing configuration filters
+
+The plugin also exposes a few low-level configuration filters used as safety limits:
+
+| Filter | Default | Purpose |
+|--------|---------|---------|
+| `exelearning_max_extract_files` | `10000` | Maximum number of files allowed in an ELPX archive. |
+| `exelearning_max_extract_bytes` | `1073741824` (1 GB) | Maximum uncompressed extraction size. |
+| `exelearning_styles_max_zip_size` | `20 MB` | Maximum uploaded style ZIP size. |
+| `exelearning_content_origin` | `''` | Origin URL used when serving extracted content. |
diff --git a/includes/class-elp-file-service.php b/includes/class-elp-file-service.php
index a37dcba..1276c44 100644
--- a/includes/class-elp-file-service.php
+++ b/includes/class-elp-file-service.php
@@ -195,6 +195,22 @@ public function extract( string $file_path, string $destination ) {
return new WP_Error( 'elp_too_many_files', 'ELP archive contains too many files.' );
}
+ /**
+ * Fires right before an .elpx archive is extracted.
+ *
+ * Intended for logging, audit trails, metrics, or external integrations.
+ * This is an observation point only: it runs after the archive has already
+ * passed open/validation and the zip-bomb count guard, and it must NOT be
+ * used to bypass validation, archive safety checks, path-traversal
+ * protection, or alter extraction behavior.
+ *
+ * @since 1.0.0
+ *
+ * @param string $file_path Source .elpx file path.
+ * @param string $destination Destination directory the archive extracts into.
+ */
+ do_action( 'exelearning_before_elpx_extract', $file_path, $destination );
+
// Extract entry by entry instead of ZipArchive::extractTo(): this lets us
// reject path traversal / absolute paths / stream wrappers, neutralize
// symlink entries (we always write regular files), and cap the total
@@ -213,6 +229,22 @@ public function extract( string $file_path, string $destination ) {
}
}
+ /**
+ * Fires after an .elpx archive has been successfully extracted.
+ *
+ * Only runs when extraction completed without error; it never fires on a
+ * failed or partial extraction. Intended for logging, audit trails,
+ * metrics, or external integrations. It is an observation point only and
+ * must NOT be used to bypass any validation or safety check.
+ *
+ * @since 1.0.0
+ *
+ * @param string $file_path Source .elpx file path.
+ * @param string $destination Final extraction directory.
+ * @param array $metadata Metadata parsed from the archive.
+ */
+ do_action( 'exelearning_after_elpx_extract', $file_path, $destination, $this->to_array() );
+
return true;
}
@@ -428,4 +460,82 @@ public function to_array(): array {
'learningResourceType' => $this->metadata['learning_resource_type'],
);
}
+
+ /**
+ * Attachment meta keys that the plugin relies on internally.
+ *
+ * These are always preserved from the trusted, pre-filter values so a
+ * third-party callback on {@see 'exelearning_elpx_metadata'} cannot drop or
+ * corrupt the data the plugin needs to locate and render extracted content.
+ *
+ * @since 1.0.0
+ *
+ * @var string[]
+ */
+ const REQUIRED_META_KEYS = array(
+ '_exelearning_title',
+ '_exelearning_description',
+ '_exelearning_license',
+ '_exelearning_language',
+ '_exelearning_resource_type',
+ '_exelearning_extracted',
+ '_exelearning_version',
+ '_exelearning_has_preview',
+ );
+
+ /**
+ * Applies the metadata filter before ELPX metadata is persisted.
+ *
+ * Lets integrations enrich the attachment metadata array (for example, add
+ * custom keys for an LMS or catalogue) right before it is written to post
+ * meta. The return value is validated defensively: a non-array return is
+ * discarded, and every required internal key is restored from the trusted
+ * pre-filter values so a misbehaving callback can add keys but can never drop
+ * or overwrite the data the plugin depends on.
+ *
+ * @since 1.0.0
+ *
+ * @param array $metadata Metadata array keyed by post meta key.
+ * @param string $file Source .elpx file path, or '' when unavailable.
+ * @param ExeLearning_Elp_File_Service $elp_service The service that parsed the archive.
+ * @return array Sanitized metadata array with all required internal keys preserved.
+ */
+ public static function filter_metadata( array $metadata, string $file, $elp_service ): array {
+ // Snapshot trusted internal values before handing data to third parties.
+ $trusted = array();
+ foreach ( self::REQUIRED_META_KEYS as $key ) {
+ if ( array_key_exists( $key, $metadata ) ) {
+ $trusted[ $key ] = $metadata[ $key ];
+ }
+ }
+
+ /**
+ * Filters the ELPX metadata array before it is saved to attachment meta.
+ *
+ * Callbacks may enrich the array with additional keys but MUST return an
+ * array. Required internal keys (see REQUIRED_META_KEYS) are always
+ * restored afterwards, so this filter cannot be used to drop or tamper
+ * with the plugin's own metadata, the extraction hash, or any security
+ * relevant value.
+ *
+ * @since 1.0.0
+ *
+ * @param array $metadata Metadata array keyed by post meta key.
+ * @param string $file Source .elpx file path, or '' when unavailable.
+ * @param ExeLearning_Elp_File_Service $elp_service The service that parsed the archive.
+ * @return array Enriched metadata array. Must be an array.
+ */
+ $filtered = apply_filters( 'exelearning_elpx_metadata', $metadata, $file, $elp_service );
+
+ if ( ! is_array( $filtered ) ) {
+ $filtered = array();
+ }
+
+ // Restore the trusted internal keys regardless of what the filter did.
+ foreach ( $trusted as $key => $value ) {
+ $filtered[ $key ] = $value;
+ }
+
+ return $filtered;
+ }
}
diff --git a/includes/class-elp-reprocessor.php b/includes/class-elp-reprocessor.php
index ef01362..9bd734c 100644
--- a/includes/class-elp-reprocessor.php
+++ b/includes/class-elp-reprocessor.php
@@ -338,14 +338,24 @@ public function extract_to_new_dir( $file_path ) {
* @param bool $has_preview Whether index.html exists.
*/
public function apply_metadata( $attachment_id, $elp_service, $hash, $has_preview ) {
- update_post_meta( $attachment_id, '_exelearning_title', $elp_service->get_title() );
- update_post_meta( $attachment_id, '_exelearning_description', $elp_service->get_description() );
- update_post_meta( $attachment_id, '_exelearning_license', $elp_service->get_license() );
- update_post_meta( $attachment_id, '_exelearning_language', $elp_service->get_language() );
- update_post_meta( $attachment_id, '_exelearning_resource_type', $elp_service->get_learning_resource_type() );
- update_post_meta( $attachment_id, '_exelearning_extracted', $hash );
- update_post_meta( $attachment_id, '_exelearning_version', $elp_service->get_version() );
- update_post_meta( $attachment_id, '_exelearning_has_preview', $has_preview ? '1' : '0' );
+ $metadata = array(
+ '_exelearning_title' => $elp_service->get_title(),
+ '_exelearning_description' => $elp_service->get_description(),
+ '_exelearning_license' => $elp_service->get_license(),
+ '_exelearning_language' => $elp_service->get_language(),
+ '_exelearning_resource_type' => $elp_service->get_learning_resource_type(),
+ '_exelearning_extracted' => $hash,
+ '_exelearning_version' => $elp_service->get_version(),
+ '_exelearning_has_preview' => $has_preview ? '1' : '0',
+ );
+
+ // Allow integrations to enrich metadata (required keys are preserved).
+ $file = (string) get_attached_file( $attachment_id );
+ $metadata = ExeLearning_Elp_File_Service::filter_metadata( $metadata, $file, $elp_service );
+
+ foreach ( $metadata as $key => $value ) {
+ update_post_meta( $attachment_id, $key, $value );
+ }
// Update attachment title/caption.
wp_update_post(
@@ -355,6 +365,16 @@ public function apply_metadata( $attachment_id, $elp_service, $hash, $has_previe
'post_content' => $elp_service->get_description(),
)
);
+
+ /**
+ * Fires after ELPX metadata has been saved to the attachment.
+ *
+ * @since 1.0.0
+ *
+ * @param int $attachment_id WordPress attachment ID.
+ * @param array $metadata Final metadata array that was saved.
+ */
+ do_action( 'exelearning_after_elpx_metadata_saved', $attachment_id, $metadata );
}
/**
diff --git a/includes/class-elp-upload-handler.php b/includes/class-elp-upload-handler.php
index 7ff59ee..ab772ac 100644
--- a/includes/class-elp-upload-handler.php
+++ b/includes/class-elp-upload-handler.php
@@ -135,6 +135,9 @@ public function process_elp_upload( $upload ) {
'_exelearning_has_preview' => $has_preview ? '1' : '0',
);
+ // Allow integrations to enrich metadata (required keys are preserved).
+ $metadata = ExeLearning_Elp_File_Service::filter_metadata( $metadata, $file, $elp_service );
+
$transient_key = 'exelearning_data_' . md5( $file );
set_transient(
@@ -186,6 +189,16 @@ public function save_elp_metadata( $attachment_id ) {
}
delete_transient( $transient_key );
+
+ /**
+ * Fires after ELPX metadata has been saved to the attachment.
+ *
+ * @since 1.0.0
+ *
+ * @param int $attachment_id WordPress attachment ID.
+ * @param array $metadata Final metadata array that was saved.
+ */
+ do_action( 'exelearning_after_elpx_metadata_saved', $attachment_id, $data['metadata'] );
}
}
diff --git a/includes/class-exelearning-rest-api.php b/includes/class-exelearning-rest-api.php
index d948967..7aab8d6 100644
--- a/includes/class-exelearning-rest-api.php
+++ b/includes/class-exelearning-rest-api.php
@@ -376,6 +376,21 @@ private function save_elp_file_locked( $attachment_id, $uploaded_file, $old_file
// Save old extraction hash before it gets replaced.
$old_hash = get_post_meta( $attachment_id, '_exelearning_extracted', true );
+ /**
+ * Fires before an existing .elpx file is replaced via the REST editor.
+ *
+ * Observation point only (logging, audit trails, metrics). It runs after
+ * the request has already passed capability and nonce checks and must NOT
+ * be used to alter the storage path, skip validation, or change the
+ * replacement/cleanup behavior.
+ *
+ * @since 1.0.0
+ *
+ * @param int $attachment_id Attachment being updated.
+ * @param string $old_file_path Current file path before replacement.
+ */
+ do_action( 'exelearning_before_elpx_save', $attachment_id, $old_file_path );
+
// Route the uploaded file through WordPress so the move (and MIME check)
// goes via the wp_handle_upload() wrapper that Plugin Check accepts.
require_once ABSPATH . 'wp-admin/includes/file.php';
@@ -461,6 +476,21 @@ private function save_elp_file_locked( $attachment_id, $uploaded_file, $old_file
)
);
+ /**
+ * Fires after an .elpx file has been successfully saved and committed.
+ *
+ * Only runs once the new file is written, the new content is extracted,
+ * and metadata is persisted; it never fires on a failed save, failed
+ * extraction, or failed metadata persistence. Observation point only.
+ *
+ * @since 1.0.0
+ *
+ * @param int $attachment_id Updated attachment ID.
+ * @param string $new_hash Hash of the new extracted content.
+ * @param string $old_hash Previous extraction hash, or '' if none.
+ */
+ do_action( 'exelearning_after_elpx_save', $attachment_id, $extraction['hash'], (string) $old_hash );
+
return rest_ensure_response( $this->build_save_response( $attachment_id ) );
}
diff --git a/includes/class-static-editor-installer.php b/includes/class-static-editor-installer.php
index e93bacf..9b69228 100644
--- a/includes/class-static-editor-installer.php
+++ b/includes/class-static-editor-installer.php
@@ -151,9 +151,67 @@ public function handle_install_request() {
public function install_latest_editor() {
$version = $this->discover_latest_version();
if ( is_wp_error( $version ) ) {
+ $this->fire_install_failed( $version );
return $version;
}
+ /**
+ * Fires before the static editor installation starts.
+ *
+ * Observation point only. It runs after the target version has been
+ * resolved and must NOT be used to change the download URL, skip checksum
+ * or archive validation, or alter the trusted install directory.
+ *
+ * @since 1.0.0
+ *
+ * @param string $version Requested editor version.
+ */
+ do_action( 'exelearning_before_editor_install', $version );
+
+ $result = $this->perform_editor_install( $version );
+
+ if ( is_wp_error( $result ) ) {
+ $this->fire_install_failed( $result );
+ return $result;
+ }
+
+ /**
+ * Fires after the static editor has been installed successfully.
+ *
+ * @since 1.0.0
+ *
+ * @param array $metadata Installed editor metadata (version, installed_at).
+ */
+ do_action( 'exelearning_after_editor_install', $result );
+
+ return $result;
+ }
+
+ /**
+ * Fire the editor-install failure action.
+ *
+ * @since 1.0.0
+ *
+ * @param WP_Error $error The failure that aborted installation.
+ */
+ private function fire_install_failed( $error ) {
+ /**
+ * Fires when the static editor installation fails.
+ *
+ * @since 1.0.0
+ *
+ * @param WP_Error $error WP_Error describing the failure.
+ */
+ do_action( 'exelearning_editor_install_failed', $error );
+ }
+
+ /**
+ * Download, verify, and install the editor for a resolved version.
+ *
+ * @param string $version Resolved editor version.
+ * @return array|WP_Error Installed metadata on success, WP_Error on failure.
+ */
+ private function perform_editor_install( $version ) {
$asset_url = $this->get_asset_url( $version );
$tmp_file = $this->download_asset( $asset_url );
diff --git a/includes/class-styles-service.php b/includes/class-styles-service.php
index 149bba6..1f60942 100644
--- a/includes/class-styles-service.php
+++ b/includes/class-styles-service.php
@@ -243,8 +243,19 @@ public static function set_uploaded_enabled( $slug, $enabled ) {
if ( ! isset( $registry['uploaded'][ $slug ] ) ) {
return new WP_Error( 'style_not_found', __( 'Style not found.', 'exelearning' ) );
}
- $registry['uploaded'][ $slug ]['enabled'] = (bool) $enabled;
+ $enabled = (bool) $enabled;
+ $registry['uploaded'][ $slug ]['enabled'] = $enabled;
self::save_registry( $registry );
+
+ /**
+ * Fires after an uploaded style has been enabled or disabled.
+ *
+ * @since 1.0.0
+ *
+ * @param string $slug Style slug.
+ * @param bool $enabled New enabled state.
+ */
+ do_action( 'exelearning_after_style_enabled_changed', $slug, $enabled );
return true;
}
@@ -287,6 +298,17 @@ public static function delete_uploaded( $slug ) {
}
unset( $registry['uploaded'][ $slug ] );
self::save_registry( $registry );
+
+ /**
+ * Fires after an uploaded style has been deleted.
+ *
+ * Runs after both the files on disk and the registry entry are removed.
+ *
+ * @since 1.0.0
+ *
+ * @param string $slug Style slug that was deleted.
+ */
+ do_action( 'exelearning_after_style_deleted', $slug );
return true;
}
@@ -336,11 +358,50 @@ public static function install_from_zip( $zip_path, $orig_name = '' ) {
);
}
- $entry = ExeLearning_Style_Package::build_entry( $config, $slug, $zip_path, $css_files );
+ $entry = ExeLearning_Style_Package::build_entry( $config, $slug, $zip_path, $css_files );
+
+ /**
+ * Filters the style registry entry before it is persisted.
+ *
+ * Allows integrations to enrich the stored style metadata (for example,
+ * add a category or display label). Must return an array; a non-array
+ * return is discarded. Required internal keys (css_files, enabled,
+ * installed_at, checksum, size) are always restored from the trusted
+ * built entry so this filter cannot inject unsafe paths, strip integrity
+ * fields, or bypass ZIP validation.
+ *
+ * @since 1.0.0
+ *
+ * @param array $entry Style registry entry built from the validated ZIP.
+ * @param string $slug Allocated style slug.
+ * @param array $config Parsed style configuration (theme.json / config).
+ * @return array Registry entry. Must be an array.
+ */
+ $filtered = apply_filters( 'exelearning_style_registry_entry', $entry, $slug, $config );
+ if ( is_array( $filtered ) ) {
+ // Restore trusted internal keys regardless of what the filter did.
+ foreach ( array( 'css_files', 'enabled', 'installed_at', 'checksum', 'size' ) as $required_key ) {
+ if ( array_key_exists( $required_key, $entry ) ) {
+ $filtered[ $required_key ] = $entry[ $required_key ];
+ }
+ }
+ $entry = $filtered;
+ }
+
$registry = self::get_registry();
$registry['uploaded'][ $slug ] = $entry;
self::save_registry( $registry );
+ /**
+ * Fires after a style ZIP has been installed and registered.
+ *
+ * @since 1.0.0
+ *
+ * @param string $slug Style slug.
+ * @param array $entry Final style registry entry that was persisted.
+ */
+ do_action( 'exelearning_after_style_installed', $slug, $entry );
+
$entry['id'] = $slug;
$entry['name'] = $slug;
return $entry;
diff --git a/public/class-shortcodes.php b/public/class-shortcodes.php
index 59f217e..15ccddf 100644
--- a/public/class-shortcodes.php
+++ b/public/class-shortcodes.php
@@ -50,6 +50,29 @@ public function display_exelearning( $atts, $content = null ) { // phpcs:ignore
'exelearning'
);
+ $file_id = intval( $atts['id'] );
+
+ /**
+ * Filters the shortcode attributes after defaults are merged and before rendering.
+ *
+ * Allows integrations to change presentation-level options (height,
+ * teacher mode, download button, download formats). The values the
+ * renderer relies on are re-sanitized below, so this filter cannot inject
+ * unsafe values, bypass the attachment/permission checks, or change which
+ * file is rendered in an unsafe way.
+ *
+ * @since 1.0.0
+ *
+ * @param array $atts Sanitized shortcode attributes.
+ * @param int $file_id Attachment ID parsed from the shortcode.
+ * @return array Shortcode attributes. Must be an array.
+ */
+ $filtered_atts = apply_filters( 'exelearning_shortcode_atts', $atts, $file_id );
+ if ( is_array( $filtered_atts ) ) {
+ $atts = $filtered_atts;
+ }
+
+ // Recompute the file ID from the (possibly filtered) attributes.
$file_id = intval( $atts['id'] );
if ( ! $file_id ) {
return $this->render_error( __( 'Invalid eXeLearning file ID.', 'exelearning' ) );
@@ -83,13 +106,50 @@ public function display_exelearning( $atts, $content = null ) { // phpcs:ignore
if ( ! $extracted_dir || '1' !== $has_preview ) {
// No preview available - show download link.
- return $this->render_no_preview( $title, $file_url, $download_html );
+ $html = $this->render_no_preview( $title, $file_url, $download_html );
+
+ /** This filter is documented below where the preview branch returns. */
+ return apply_filters( 'exelearning_shortcode_output', $html, $file_id, $atts );
}
// Build preview URL using secure proxy.
$preview_url = ExeLearning_Content_Proxy::get_proxy_url( $extracted_dir );
- return $this->render_preview( $title, $preview_url, $height, $file_url, $teacher_mode_visible, $download_html );
+ /**
+ * Filters the preview URL before it is rendered into the iframe.
+ *
+ * Allows integrations to wrap or adjust the preview URL. The value is
+ * still escaped with esc_url() at output time. This filter must NOT be
+ * used to bypass the plugin's content-proxy security model; pointing the
+ * iframe at an unverified external origin is unsupported.
+ *
+ * @since 1.0.0
+ *
+ * @param string $preview_url Proxy preview URL.
+ * @param int $file_id Attachment ID.
+ * @param string $extracted_dir Extraction hash/directory for the attachment.
+ * @return string Preview URL.
+ */
+ $preview_url = (string) apply_filters( 'exelearning_preview_url', $preview_url, $file_id, $extracted_dir );
+
+ $html = $this->render_preview( $title, $preview_url, $height, $file_url, $teacher_mode_visible, $download_html );
+
+ /**
+ * Filters the final shortcode HTML before it is returned.
+ *
+ * Receives the already-rendered, escaped HTML and lets themes or
+ * integrations wrap or modify it. The default output is unchanged when no
+ * callback is attached. Any HTML added by a callback is its own
+ * responsibility to keep safe.
+ *
+ * @since 1.0.0
+ *
+ * @param string $html Rendered shortcode HTML.
+ * @param int $file_id Attachment ID.
+ * @param array $atts Shortcode attributes used to render the output.
+ * @return string Shortcode HTML.
+ */
+ return apply_filters( 'exelearning_shortcode_output', $html, $file_id, $atts );
}
/**
diff --git a/readme.txt b/readme.txt
index 1d353b3..be9d863 100644
--- a/readme.txt
+++ b/readme.txt
@@ -25,3 +25,4 @@ For more information, see the [full documentation on GitHub](https://github.com/
= 0.0.0 =
* Initial release
+* Add developer lifecycle hooks (actions and filters) for ELPX extraction, metadata, REST saves, shortcode rendering, styles, and static editor installation. See docs/HOOKS.md.
diff --git a/tests/unit/DeveloperHooksTest.php b/tests/unit/DeveloperHooksTest.php
new file mode 100644
index 0000000..d1287ab
--- /dev/null
+++ b/tests/unit/DeveloperHooksTest.php
@@ -0,0 +1,491 @@
+cleanup_paths = array();
+ delete_option( ExeLearning_Styles_Service::OPTION_REGISTRY );
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tear_down() {
+ foreach ( $this->cleanup_paths as $path ) {
+ $this->recursive_delete( $path );
+ }
+ delete_option( ExeLearning_Styles_Service::OPTION_REGISTRY );
+ $storage = ExeLearning_Styles_Service::get_storage_dir();
+ if ( is_dir( $storage ) ) {
+ ExeLearning_Styles_Service::recursive_delete( $storage );
+ }
+ parent::tear_down();
+ }
+
+ /* -------------------------------------------------------------------- */
+ /* ELPX metadata filter */
+ /* -------------------------------------------------------------------- */
+
+ /**
+ * The metadata filter is invoked and can enrich the array.
+ */
+ public function test_elpx_metadata_filter_enriches() {
+ add_filter(
+ 'exelearning_elpx_metadata',
+ static function ( $metadata ) {
+ $metadata['_exelearning_custom'] = 'enriched';
+ return $metadata;
+ }
+ );
+
+ $result = ExeLearning_Elp_File_Service::filter_metadata( $this->sample_metadata(), '/tmp/example.elpx', null );
+
+ $this->assertSame( 'enriched', $result['_exelearning_custom'] );
+ // Required keys are still present.
+ $this->assertSame( 'Sample', $result['_exelearning_title'] );
+ $this->assertArrayHasKey( '_exelearning_extracted', $result );
+ }
+
+ /**
+ * A non-array return value is discarded and required keys are restored.
+ */
+ public function test_elpx_metadata_filter_non_array_is_safe() {
+ add_filter(
+ 'exelearning_elpx_metadata',
+ static function () {
+ return 'not an array';
+ }
+ );
+
+ $result = ExeLearning_Elp_File_Service::filter_metadata( $this->sample_metadata(), '', null );
+
+ $this->assertIsArray( $result );
+ foreach ( ExeLearning_Elp_File_Service::REQUIRED_META_KEYS as $key ) {
+ $this->assertArrayHasKey( $key, $result, "Required key {$key} must survive a bad filter." );
+ }
+ $this->assertSame( 'Sample', $result['_exelearning_title'] );
+ }
+
+ /**
+ * A callback cannot drop or overwrite required internal keys.
+ */
+ public function test_elpx_metadata_filter_cannot_corrupt_required_keys() {
+ add_filter(
+ 'exelearning_elpx_metadata',
+ static function () {
+ // Try to wipe everything and forge the extraction hash.
+ return array( '_exelearning_extracted' => 'forged-hash' );
+ }
+ );
+
+ $result = ExeLearning_Elp_File_Service::filter_metadata( $this->sample_metadata(), '', null );
+
+ // The trusted pre-filter value wins.
+ $this->assertSame( 'deadbeef', $result['_exelearning_extracted'] );
+ $this->assertSame( 'Sample', $result['_exelearning_title'] );
+ }
+
+ /* -------------------------------------------------------------------- */
+ /* Metadata saved action (real flow via the reprocessor) */
+ /* -------------------------------------------------------------------- */
+
+ /**
+ * The saved action fires after metadata is persisted, with the right args.
+ */
+ public function test_after_elpx_metadata_saved_fires() {
+ $captured = array();
+ add_action(
+ 'exelearning_after_elpx_metadata_saved',
+ static function ( $attachment_id, $metadata ) use ( &$captured ) {
+ $captured[] = array( $attachment_id, $metadata );
+ },
+ 10,
+ 2
+ );
+
+ $fixture = $this->make_elpx_attachment();
+ $id = $fixture['id'];
+
+ ( new ExeLearning_Reprocessor() )->reprocess( $id );
+ $this->cleanup_paths[] = $this->extraction_dir( get_post_meta( $id, '_exelearning_extracted', true ) );
+
+ $this->assertGreaterThan( 0, did_action( 'exelearning_after_elpx_metadata_saved' ) );
+ $this->assertSame( $id, $captured[0][0] );
+ $this->assertIsArray( $captured[0][1] );
+ $this->assertArrayHasKey( '_exelearning_extracted', $captured[0][1] );
+ }
+
+ /**
+ * The metadata filter is wired into the real persistence flow.
+ */
+ public function test_elpx_metadata_filter_persists_enrichment() {
+ add_filter(
+ 'exelearning_elpx_metadata',
+ static function ( $metadata ) {
+ $metadata['_exelearning_custom'] = 'lms-42';
+ return $metadata;
+ }
+ );
+
+ $fixture = $this->make_elpx_attachment();
+ $id = $fixture['id'];
+
+ ( new ExeLearning_Reprocessor() )->reprocess( $id );
+ $this->cleanup_paths[] = $this->extraction_dir( get_post_meta( $id, '_exelearning_extracted', true ) );
+
+ $this->assertSame( 'lms-42', get_post_meta( $id, '_exelearning_custom', true ) );
+ }
+
+ /* -------------------------------------------------------------------- */
+ /* Shortcode filters */
+ /* -------------------------------------------------------------------- */
+
+ /**
+ * The atts filter can change a presentation attribute.
+ */
+ public function test_shortcode_atts_filter_changes_height() {
+ add_filter(
+ 'exelearning_shortcode_atts',
+ static function ( $atts ) {
+ $atts['height'] = 900;
+ return $atts;
+ }
+ );
+
+ $result = ( new ExeLearning_Shortcodes() )->display_exelearning( array( 'id' => $this->previewable_attachment() ) );
+
+ $this->assertStringContainsString( '900px', $result );
+ }
+
+ /**
+ * Unsafe values returned by the atts filter are re-sanitized.
+ */
+ public function test_shortcode_atts_filter_values_are_resanitized() {
+ add_filter(
+ 'exelearning_shortcode_atts',
+ static function ( $atts ) {
+ $atts['height'] = 'evil">';
+ return $atts;
+ }
+ );
+
+ $result = ( new ExeLearning_Shortcodes() )->display_exelearning( array( 'id' => $this->previewable_attachment() ) );
+
+ $this->assertStringNotContainsString( '