diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e35a542
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,61 @@
+# Changelog
+
+## Unreleased
+
+### Added
+
+- New `Drupal\rl\Experiment\VariantArmsTrait` providing reusable arm-id helpers
+ (`getArmIds`, `getArmText`, `buildVariantExperimentId`) for experiments that
+ follow the "v0 = original, v1..vN = stored variants" convention.
+- New `Drupal\rl\Experiment\VariantParser` static helper for parsing textarea
+ variant input into normalized lists.
+- New `Drupal\rl\Experiment\VariantExperimentInterface` extending
+ `ContentEntityInterface`. Variant-style experiment entities implement this
+ to plug into the shared selector / decorator / delete-form base classes.
+- New `Drupal\rl\Experiment\VariantSelectorBase`,
+ `VariantExperimentDecoratorBase`, and `VariantExperimentDeleteFormBase` for
+ consumer modules to extend.
+- New submodule `rl_page_title` for A/B testing page titles on any page (nodes,
+ Views displays, custom controllers, path-based). **Multilingual: per-language
+ experiments scoped via the `langcode` entity key, with an "all languages"
+ fallback. Each language has its own Thompson Sampling state.**
+- New submodule `rl_menu_link` for A/B testing menu link labels (works for both
+ `menu_link_content` entities and YAML-defined links). **Multilingual: same
+ per-language scoping as rl_page_title.**
+
+### Architecture
+
+- Both new variant submodules use **content entities**, not config entities.
+ This is a deliberate choice to scale to tens of thousands of experiments
+ per site without the config-management cliff and the O(N) lookup penalty
+ of config entities. Lookups are indexed; admin lists use Views; multilingual
+ is first-class via the `langcode` entity key. Mirrors the Redirect module's
+ storage approach.
+
+### Changed
+
+- **BC break (minor):** `ExperimentManagerInterface` now declares three new
+ methods:
+ - `purgeExperiment(string $experiment_id)` - removes turns, rewards,
+ totals, snapshots, and registry entry for an experiment in a single
+ transaction.
+ - `getTotalTurnsMultiple(array $experiment_ids): array` - batched lookup
+ of total turns for many experiments in one query, used by list builders
+ to avoid N+1 query patterns.
+ - `getAllArmsDataMultiple(array $experiment_ids): array` - batched lookup
+ of arm data for many experiments in one query, paired with
+ `getTotalTurnsMultiple()`.
+
+ The same three methods are also added to `ExperimentDataStorageInterface`
+ (the lower-level storage contract).
+
+ Any downstream consumer that directly implements either interface (rather
+ than extending the concrete classes) will need to add these methods to
+ satisfy the contract. There are no known external implementations of
+ either interface at the time of this change.
+
+ Mitigation for downstream maintainers: copy the implementations from
+ `ExperimentManager` and `ExperimentDataStorage`. The batch methods are
+ thin `IN`-clause wrappers around the existing single-row queries; the
+ purge method uses transactional deletes across `rl_arm_data`,
+ `rl_experiment_totals`, `rl_arm_snapshots`, and `rl_experiment_registry`.
diff --git a/modules/rl_menu_link/.agents/skills/rl_menu_link/SKILL.md b/modules/rl_menu_link/.agents/skills/rl_menu_link/SKILL.md
new file mode 100644
index 0000000..11a3b84
--- /dev/null
+++ b/modules/rl_menu_link/.agents/skills/rl_menu_link/SKILL.md
@@ -0,0 +1,57 @@
+# RL Menu Link — A/B test menu link labels via Drush CLI
+
+A/B test Drupal menu link labels for menu_link_content entities and
+YAML-defined links using Thompson Sampling. Per-language scoping.
+
+## Commands
+
+### Discovery
+- `drush rl:menu-link:list` — List experiments (`--enabled=yes|no|all`)
+- `drush rl:menu-link:get ` — Full details + live arm stats + original label
+
+### Lifecycle
+- `drush rl:menu-link:create --variants="A,B,C"` — Create
+- `drush rl:menu-link:update --label="X"` — Update
+- `drush rl:menu-link:delete ` — Delete + purge analytics
+
+### Common options
+- `--variants="A,B,C"` or `--variants=A --variants=B` — Alternative labels
+- `--label="..."` — Human-readable label
+- `--langcode=es` — Per-language scope (default `und` = all languages)
+- `--disabled` — Create as disabled
+- `--dry-run` — Preview without applying
+
+All commands output YAML. All state-changing commands support `--dry-run`.
+
+## Plugin ID format
+
+- User-created menu links: `menu_link_content:abc-uuid`
+- YAML-defined links: machine name like `system.admin_content`,
+ `user.page`, `system.admin_structure`
+
+## Concepts
+
+- The original label is always tested as variant `v0` (read live from
+ the menu link manager).
+- Stored variants are `v1`, `v2`, etc.
+- Each `(plugin_id, langcode)` pair is its own experiment with
+ independent Thompson Sampling state.
+- Reward signal: user clicked the tracked menu link.
+- Lookups are indexed; tested at 10K+ experiments per site.
+
+## Example
+
+```bash
+# A/B test the core "Content" admin link with alternatives.
+drush rl:menu-link:create system.admin_content \
+ --variants="Content,Manage Content,Site Content"
+
+# Check progress.
+drush rl:menu-link:get
+
+# Delete + purge.
+drush rl:menu-link:delete
+```
+
+The same admin UI is available at /admin/config/services/rl-menu-link
+via Views.
diff --git a/modules/rl_menu_link/.agents/skills/rl_menu_link/agents/openai.yaml b/modules/rl_menu_link/.agents/skills/rl_menu_link/agents/openai.yaml
new file mode 100644
index 0000000..32fd156
--- /dev/null
+++ b/modules/rl_menu_link/.agents/skills/rl_menu_link/agents/openai.yaml
@@ -0,0 +1,4 @@
+display_name: "RL Menu Link"
+description: "A/B test Drupal menu link labels (menu_link_content + YAML-defined) using Thompson Sampling. Per-language scoped, manage via Drush CLI."
+default_prompt: "List all menu link experiments and their current status."
+allow_implicit_invocation: true
diff --git a/modules/rl_menu_link/.claude/skills/rl_menu_link/SKILL.md b/modules/rl_menu_link/.claude/skills/rl_menu_link/SKILL.md
new file mode 100644
index 0000000..f536ded
--- /dev/null
+++ b/modules/rl_menu_link/.claude/skills/rl_menu_link/SKILL.md
@@ -0,0 +1,138 @@
+---
+name: rl_menu_link
+version: 1.0.0
+description: >
+ A/B test Drupal menu link labels using Thompson Sampling. Works for
+ both menu_link_content entities and YAML-defined links. Per-language
+ scoping. Manage experiments via Drush CLI.
+triggers:
+ - /rl-menu-link
+ - menu link test
+ - menu label test
+ - menu variant
+ - a/b test menu
+ - test menu link
+ - rl_menu_link
+---
+
+# RL Menu Link — Drush CLI
+
+You are managing A/B testing experiments for Drupal menu link labels.
+The rl_menu_link module is a content-entity-backed integration on top
+of the parent rl module's Thompson Sampling engine.
+
+## Preamble — Auto-discover Current State
+
+```bash
+# List all menu link experiments with stats.
+drush rl:menu-link:list --format=yaml
+
+# Filter to active experiments only.
+drush rl:menu-link:list --enabled=yes --format=yaml
+```
+
+## Commands Reference
+
+| Command | Alias | Purpose |
+|---|---|---|
+| `rl:menu-link:list` | `rl-mll` | List experiments (`--enabled=yes\|no\|all`) |
+| `rl:menu-link:get ` | `rl-mlg` | Show full details + live arm stats + original label |
+| `rl:menu-link:create ` | `rl-mlc` | Create experiment (`--variants`, `--label`, `--langcode`, `--disabled`, `--dry-run`) |
+| `rl:menu-link:update ` | `rl-mlu` | Update label / variants / enable / disable (`--dry-run`) |
+| `rl:menu-link:delete ` | `rl-mld` | Delete experiment AND purge RL analytics (`--dry-run`) |
+
+All state-changing commands support `--dry-run`. All commands output YAML.
+
+## Concepts
+
+- **Plugin ID**: every menu link in Drupal has a plugin ID. For
+ user-created `menu_link_content` entities it looks like
+ `menu_link_content:abc-uuid` (the entity UUID). For YAML-defined links
+ from contrib/custom modules it is the link's machine name (e.g.,
+ `system.admin_content`, `user.page`).
+- **Variant**: an alternative label text. The original label (whatever
+ the menu link normally renders) is always tested as variant `v0`.
+ Stored variants are `v1`, `v2`, etc.
+- **Langcode**: experiments are scoped per language. Use `und` (the
+ default, `LANGCODE_NOT_SPECIFIED`) for "all languages". Lookup tries
+ language-specific match first, falls back to "all languages".
+- **Reward**: a click on the tracked menu link.
+
+## Workflow Examples
+
+### Test alternatives for a content menu link
+
+```bash
+# Find the plugin ID first via the menu UI or:
+drush ev "echo \\Drupal::entityTypeManager()->getStorage('menu_link_content')->load(1)->getPluginId();"
+
+# Then create the experiment.
+drush rl:menu-link:create menu_link_content:abc-uuid \
+ --variants="Services,What We Do,Solutions" \
+ --label="Services menu link test"
+```
+
+### Test a core admin menu link
+
+```bash
+drush rl:menu-link:create system.admin_content \
+ --variants="Content,Manage Content,Edit Site"
+```
+
+### Test a Spanish-only variant
+
+```bash
+drush rl:menu-link:create menu_link_content:abc-uuid \
+ --variants="Servicios,Lo Que Hacemos" \
+ --langcode=es
+```
+
+### Use multiple --variants flags
+
+```bash
+drush rl:menu-link:create system.admin_structure \
+ --variants="Structure" \
+ --variants="Site Structure" \
+ --variants="Layout"
+```
+
+### Preview before applying
+
+```bash
+drush rl:menu-link:create system.admin_content \
+ --variants="One,Two" \
+ --dry-run
+```
+
+### Check current state of an experiment
+
+```bash
+drush rl:menu-link:get
+# Returns: id, label, plugin id, original_label (read live from menu
+# link manager), langcode, enabled, variants list, rl_experiment_id,
+# total_turns, per-arm turns/rewards/rate.
+```
+
+## How variants reach end users
+
+1. The module's preprocess_menu hook walks the menu tree on every
+ render and looks up an experiment by each item's plugin ID.
+2. Matching items get their `title` swapped with the Thompson Sampling
+ winner, and `data-rl-ml-experiment-id` / `data-rl-ml-arm-id` data
+ attributes are injected onto the rendered anchor.
+3. The bundled tracking JS uses IntersectionObserver to record an
+ impression when the link enters the viewport, and a click handler
+ to record a reward.
+4. Tens of thousands of experiments scale via indexed `(plugin_id,
+ langcode)` lookups (content entities, not config).
+
+## Notes
+
+- The same menu link can have separate experiments per language.
+- Vertical tabs on `menu_link_content` edit forms also create menu link
+ experiments; the Drush commands operate on the same content entities.
+- The admin UI is at `/admin/config/services/rl-menu-link` (Views).
+- Analytics are managed by the parent `rl` module; use `drush rl:list`
+ and the `rl:experiment:*` commands to inspect raw turn/reward data.
+- Deleting an experiment via this command purges the RL analytics
+ tables (turns, rewards, snapshots, registry) atomically.
diff --git a/modules/rl_menu_link/README.md b/modules/rl_menu_link/README.md
new file mode 100644
index 0000000..4513702
--- /dev/null
+++ b/modules/rl_menu_link/README.md
@@ -0,0 +1,118 @@
+# RL Menu Link
+
+A/B test menu link labels using Thompson Sampling. Works for both
+`menu_link_content` entities (user-created menu links) and YAML-defined
+menu links from contrib/custom modules.
+
+## What it does
+
+You give it alternative labels for a menu link. It rotates them across menu
+renders using Thompson Sampling, records impressions when the link is
+visible and rewards when the link is clicked, and converges on the
+best-performing label.
+
+## How it works
+
+### Storage
+
+A **content entity** per experiment, `rl_menu_link_experiment`, stores:
+
+- `menu_link_plugin_id` - the menu link plugin ID being tested. For
+ `menu_link_content` entities this looks like
+ `menu_link_content:abc-uuid`. For YAML-defined links it is the link's
+ machine name (e.g., `system.admin_content`).
+- `langcode` - the language scope
+- `variants_data` - JSON-encoded list of alternative labels
+- `enabled` - whether the experiment is currently running
+
+Indexed lookups on `(menu_link_plugin_id, langcode)` keep selector latency
+constant regardless of how many experiments exist.
+
+The original label is always tested as **arm v0** and is read live from the
+menu link manager - it is **not** stored on the experiment entity. Stored
+variants are arms v1, v2, ... vN.
+
+The RL experiment ID is a deterministic hash:
+`rl_menu_link-{12-char-sha1-of-plugin-id-pipe-langcode}`. The hash includes
+the langcode so each language has its own Thompson Sampling state.
+
+### Multilingual
+
+Each (plugin_id, langcode) pair is its own experiment row. Lookup tries
+the current request language first, then falls back to "all languages"
+(`LANGCODE_NOT_SPECIFIED`) if no language-specific experiment exists.
+Same model as the Redirect module.
+
+### Runtime
+
+1. `hook_preprocess_menu()` walks the menu tree, looks up an active
+ experiment for each item's plugin ID, and swaps `$item['title']` with
+ the winning variant. It also injects `data-rl-ml-experiment-id` and
+ `data-rl-ml-arm-id` attributes onto the rendered anchor so the tracking
+ JavaScript can match anchors precisely.
+2. The preprocess hook attaches `rl_menu_link:all` plus per-plugin-id cache
+ tags so saving an experiment can invalidate the cached menu output
+ immediately.
+3. `js/menu-tracking.js` uses `IntersectionObserver` to record a turn
+ (impression) when a tracked anchor enters the viewport, and a click
+ listener to record a reward when the user clicks. Each visit and each
+ click is its own event - there is no per-session cap, so repeat
+ visitors do not depress the conversion signal.
+4. Both events are POSTed via `navigator.sendBeacon()` to `rl.php`.
+
+### UX flows
+
+- **menu_link_content entities**: vertical tab "Label variants" on the
+ menu link edit form (advanced tabs group), inline textarea editing.
+- **YAML-defined menu links** (e.g., `system.admin_content`): standalone
+ admin form at `/admin/config/services/rl-menu-link/add` with a
+ textfield for the plugin ID. The form validates that the plugin ID is
+ registered with the menu link manager.
+- **Editing**: same vertical tab on the menu link edit form, or the
+ admin list at `/admin/config/services/rl-menu-link`.
+- **Deletion**: dedicated delete confirmation form that purges the RL
+ analytics tables before removing the config entity.
+
+## Configuration
+
+There is no settings form. The page cache TTL override (60 seconds while
+an experiment is active) is hardcoded as a class constant. Reward signal
+is "user clicked the link", which is the natural conversion signal for
+menu links and does not need configuration.
+
+## Permissions
+
+- `administer rl menu link experiments` - create, edit, delete
+ experiments. Restricted access.
+
+## Cache invalidation
+
+Saving or deleting an experiment invalidates two cache tags:
+
+- `rl_menu_link:all` - attached to every rendered menu, so any first-time
+ experiment invalidates all cached menu output.
+- `rl_menu_link:{plugin_id}` - attached to menus that contain the
+ affected link, for more targeted invalidation when the experiment
+ already existed.
+
+## Known limitations
+
+- **Click reward double-counting prevention**: a single click event is
+ guarded by a per-anchor data attribute that clears immediately after
+ the event finishes propagating. Subsequent clicks in the same page load
+ do count, by design - the goal is to track engagement, not session-level
+ uniqueness.
+- **State divergence on the vertical tab path**: identical to the
+ rl_page_title note - inline experiment writes happen after the parent
+ menu link save commits, so a write failure is logged but does not roll
+ back the parent save.
+- **YAML link plugin IDs are not autocompleted**: the standalone form
+ takes the plugin ID as a textfield with examples in the description.
+ Adding an autocomplete is a Phase 2 enhancement.
+
+## Tests
+
+Coverage is provided by the parent rl module's e2e tests under
+`scripts/e2e/`, which exercise install, experiment CRUD, and analytics
+end-to-end against a real Drupal site. Run via
+`docker compose --profile test run e2e-test` from the rl module root.
diff --git a/modules/rl_menu_link/config/install/views.view.rl_menu_link_experiment.yml b/modules/rl_menu_link/config/install/views.view.rl_menu_link_experiment.yml
new file mode 100644
index 0000000..417a40f
--- /dev/null
+++ b/modules/rl_menu_link/config/install/views.view.rl_menu_link_experiment.yml
@@ -0,0 +1,558 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - rl_menu_link
+ - user
+id: rl_menu_link_experiment
+label: 'Reinforcement Learning menu link experiments'
+module: views
+description: 'Admin list of menu link experiments.'
+tag: ''
+base_table: rl_menu_link_experiment
+base_field: id
+display:
+ default:
+ id: default
+ display_title: Default
+ display_plugin: default
+ position: 0
+ display_options:
+ access:
+ type: perm
+ options:
+ perm: 'administer rl menu link experiments'
+ cache:
+ type: tag
+ options: { }
+ query:
+ type: views_query
+ options:
+ query_comment: ''
+ disable_sql_rewrite: false
+ distinct: false
+ replica: false
+ query_tags: { }
+ exposed_form:
+ type: basic
+ options:
+ submit_button: Filter
+ reset_button: true
+ reset_button_label: Reset
+ exposed_sorts_label: 'Sort by'
+ expose_sort_order: true
+ sort_asc_label: Asc
+ sort_desc_label: Desc
+ pager:
+ type: full
+ options:
+ items_per_page: 50
+ offset: 0
+ id: 0
+ total_pages: null
+ tags:
+ previous: ‹ Previous
+ next: 'Next ›'
+ first: '« First'
+ last: 'Last »'
+ expose:
+ items_per_page: false
+ items_per_page_label: 'Items per page'
+ items_per_page_options: '5, 10, 25, 50'
+ items_per_page_options_all: false
+ items_per_page_options_all_label: '- All -'
+ offset: false
+ offset_label: Offset
+ quantity: 9
+ style:
+ type: table
+ options:
+ grouping: { }
+ row_class: ''
+ default_row_class: true
+ columns:
+ label: label
+ menu_link_plugin_id: menu_link_plugin_id
+ langcode: langcode
+ enabled: enabled
+ operations: operations
+ default: '-1'
+ info:
+ label:
+ sortable: true
+ default_sort_order: asc
+ align: ''
+ separator: ''
+ empty_column: false
+ responsive: ''
+ menu_link_plugin_id:
+ sortable: true
+ default_sort_order: asc
+ align: ''
+ separator: ''
+ empty_column: false
+ responsive: ''
+ langcode:
+ sortable: true
+ default_sort_order: asc
+ align: ''
+ separator: ''
+ empty_column: false
+ responsive: ''
+ enabled:
+ sortable: true
+ default_sort_order: asc
+ align: ''
+ separator: ''
+ empty_column: false
+ responsive: ''
+ operations:
+ sortable: false
+ default_sort_order: asc
+ align: ''
+ separator: ''
+ empty_column: false
+ responsive: ''
+ override: true
+ sticky: false
+ summary: ''
+ empty_table: true
+ caption: ''
+ description: ''
+ row:
+ type: fields
+ fields:
+ label:
+ id: label
+ table: rl_menu_link_experiment
+ field: label
+ relationship: none
+ group_type: group
+ admin_label: ''
+ entity_type: rl_menu_link_experiment
+ entity_field: label
+ plugin_id: field
+ label: Label
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: string
+ settings:
+ link_to_entity: false
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ menu_link_plugin_id:
+ id: menu_link_plugin_id
+ table: rl_menu_link_experiment
+ field: menu_link_plugin_id
+ relationship: none
+ group_type: group
+ admin_label: ''
+ entity_type: rl_menu_link_experiment
+ entity_field: menu_link_plugin_id
+ plugin_id: field
+ label: 'Menu link'
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: string
+ settings:
+ link_to_entity: false
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ langcode:
+ id: langcode
+ table: rl_menu_link_experiment
+ field: langcode
+ relationship: none
+ group_type: group
+ admin_label: ''
+ entity_type: rl_menu_link_experiment
+ entity_field: langcode
+ plugin_id: field_language
+ label: Language
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: language
+ settings:
+ link_to_entity: false
+ native_language: false
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ enabled:
+ id: enabled
+ table: rl_menu_link_experiment
+ field: enabled
+ relationship: none
+ group_type: group
+ admin_label: ''
+ entity_type: rl_menu_link_experiment
+ entity_field: enabled
+ plugin_id: field
+ label: Status
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: boolean
+ settings:
+ format: custom
+ format_custom_false: Paused
+ format_custom_true: Active
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ operations:
+ id: operations
+ table: rl_menu_link_experiment
+ field: operations
+ relationship: none
+ group_type: group
+ admin_label: ''
+ entity_type: rl_menu_link_experiment
+ plugin_id: entity_operations
+ label: Operations
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ destination: true
+ filters:
+ label:
+ id: label
+ table: rl_menu_link_experiment
+ field: label
+ relationship: none
+ group_type: group
+ admin_label: ''
+ plugin_id: string
+ operator: contains
+ expose:
+ operator_id: label_op
+ label: Label
+ description: ''
+ use_operator: false
+ operator: label_op
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: label
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ placeholder: ''
+ is_grouped: false
+ menu_link_plugin_id:
+ id: menu_link_plugin_id
+ table: rl_menu_link_experiment
+ field: menu_link_plugin_id
+ relationship: none
+ group_type: group
+ admin_label: ''
+ plugin_id: string
+ operator: contains
+ expose:
+ operator_id: menu_link_plugin_id_op
+ label: 'Plugin ID'
+ description: ''
+ use_operator: false
+ operator: menu_link_plugin_id_op
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: menu_link_plugin_id
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ placeholder: ''
+ is_grouped: false
+ enabled:
+ id: enabled
+ table: rl_menu_link_experiment
+ field: enabled
+ relationship: none
+ group_type: group
+ admin_label: ''
+ plugin_id: boolean
+ value: '1'
+ group: 1
+ expose:
+ operator_id: enabled_op
+ label: Status
+ description: ''
+ use_operator: false
+ operator: enabled_op
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: enabled
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ is_grouped: false
+ sorts:
+ label:
+ id: label
+ table: rl_menu_link_experiment
+ field: label
+ relationship: none
+ group_type: group
+ admin_label: ''
+ plugin_id: standard
+ order: ASC
+ exposed: false
+ expose:
+ label: ''
+ field_identifier: label
+ title: 'Reinforcement Learning menu link experiments'
+ empty:
+ area_text_custom:
+ id: area_text_custom
+ table: views
+ field: area_text_custom
+ relationship: none
+ group_type: group
+ admin_label: ''
+ plugin_id: text_custom
+ empty: true
+ tokenize: false
+ content: "No experiments yet. The easiest way to start one is to edit any menu link under Structure \u203a Menus and open the \"A/B test menu link title\" tab on its edit form."
+ page_1:
+ id: page_1
+ display_title: 'Admin page'
+ display_plugin: page
+ position: 1
+ display_options:
+ display_extenders: { }
+ path: admin/config/services/rl-menu-link
+ menu:
+ type: 'normal'
+ title: 'Reinforcement Learning menu link experiments'
+ description: 'A/B test menu link labels using Thompson Sampling.'
+ weight: 0
+ expanded: false
+ parent: 'system.admin_config_services'
+ context: '0'
+ menu_name: 'admin'
diff --git a/modules/rl_menu_link/drush.services.yml b/modules/rl_menu_link/drush.services.yml
new file mode 100644
index 0000000..aba568c
--- /dev/null
+++ b/modules/rl_menu_link/drush.services.yml
@@ -0,0 +1,10 @@
+services:
+ rl_menu_link.drush.commands:
+ class: Drupal\rl_menu_link\Drush\Commands\RlMenuLinkCommands
+ arguments:
+ - '@entity_type.manager'
+ - '@rl.experiment_registry'
+ - '@rl.experiment_manager'
+ - '@plugin.manager.menu.link'
+ tags:
+ - { name: drush.command }
diff --git a/modules/rl_menu_link/js/menu-tracking.js b/modules/rl_menu_link/js/menu-tracking.js
new file mode 100644
index 0000000..c7d12a3
--- /dev/null
+++ b/modules/rl_menu_link/js/menu-tracking.js
@@ -0,0 +1,83 @@
+/**
+ * @file
+ * Client-side tracking for RL Menu Link experiments.
+ *
+ * Records a turn (impression) when a tracked menu link enters the viewport,
+ * and a reward when the user clicks the link. Tracked anchors are identified
+ * by data-rl-ml-experiment-id and data-rl-ml-arm-id attributes injected by
+ * the preprocess_menu hook.
+ *
+ * Each click on a tracked link records a reward. There is no per-session
+ * cap, so repeated clicks during the same session each count - this keeps
+ * the conversion signal Thompson Sampling sees unbiased across visits.
+ *
+ * Per-page-load dedupe is provided by once() and a per-anchor data attribute
+ * to prevent the same impression or click from firing multiple times within
+ * a single page load (e.g., from repeated attachBehaviors invocations).
+ */
+
+(function (Drupal, drupalSettings, once) {
+
+ 'use strict';
+
+ Drupal.behaviors.rlMenuLinkTracking = {
+ attach: function (context) {
+ if (!drupalSettings.rlMenuLink || !drupalSettings.rlMenuLink.rlEndpointUrl) {
+ return;
+ }
+
+ var endpointUrl = drupalSettings.rlMenuLink.rlEndpointUrl;
+ var anchors = once('rl-menu-link-tracking', 'a[data-rl-ml-experiment-id]', context);
+
+ anchors.forEach(function (anchor) {
+ var experimentId = anchor.getAttribute('data-rl-ml-experiment-id');
+ var armId = anchor.getAttribute('data-rl-ml-arm-id');
+ if (!experimentId || !armId) {
+ return;
+ }
+
+ // Turn tracking via IntersectionObserver. The data attribute prevents
+ // multiple turns for the same anchor in a single page load.
+ if ('IntersectionObserver' in window) {
+ var observer = new IntersectionObserver(function (entries) {
+ entries.forEach(function (entry) {
+ if (entry.isIntersecting && !entry.target.dataset.rlMlTracked) {
+ entry.target.dataset.rlMlTracked = '1';
+ _send('turn', experimentId, armId);
+ }
+ });
+ }, { threshold: 0.1 });
+ observer.observe(anchor);
+ }
+ else {
+ _send('turn', experimentId, armId);
+ }
+
+ // Reward tracking on click. No sessionStorage cap; each click is a
+ // valid reward signal. The data attribute prevents double-counting
+ // a single click event.
+ anchor.addEventListener('click', function () {
+ if (anchor.dataset.rlMlClicked) {
+ return;
+ }
+ anchor.dataset.rlMlClicked = '1';
+ _send('reward', experimentId, armId);
+ // Clear the flag after the click event finishes propagating, so a
+ // second click later in the same page load can also be recorded.
+ window.setTimeout(function () {
+ delete anchor.dataset.rlMlClicked;
+ }, 0);
+ });
+ });
+
+ function _send(action, experimentId, armId) {
+ var data = new FormData();
+ data.append('action', action);
+ data.append('experiment_id', experimentId);
+ data.append('arm_id', armId);
+ navigator.sendBeacon(endpointUrl, data);
+ }
+ }
+ };
+
+})(Drupal, drupalSettings, once);
diff --git a/modules/rl_menu_link/rl_menu_link.info.yml b/modules/rl_menu_link/rl_menu_link.info.yml
new file mode 100644
index 0000000..bb883ef
--- /dev/null
+++ b/modules/rl_menu_link/rl_menu_link.info.yml
@@ -0,0 +1,7 @@
+name: 'Reinforcement Learning Menu Link'
+type: module
+description: 'A/B test menu link labels for any menu link using Thompson Sampling. Works for menu_link_content entities and YAML-defined links.'
+core_version_requirement: ^10.3 | ^11
+package: Custom
+dependencies:
+ - rl:rl
diff --git a/modules/rl_menu_link/rl_menu_link.libraries.yml b/modules/rl_menu_link/rl_menu_link.libraries.yml
new file mode 100644
index 0000000..91c14e2
--- /dev/null
+++ b/modules/rl_menu_link/rl_menu_link.libraries.yml
@@ -0,0 +1,7 @@
+tracking:
+ js:
+ js/menu-tracking.js: {}
+ dependencies:
+ - core/drupal
+ - core/drupalSettings
+ - core/once
diff --git a/modules/rl_menu_link/rl_menu_link.links.action.yml b/modules/rl_menu_link/rl_menu_link.links.action.yml
new file mode 100644
index 0000000..ef39c80
--- /dev/null
+++ b/modules/rl_menu_link/rl_menu_link.links.action.yml
@@ -0,0 +1,5 @@
+rl_menu_link.add:
+ route_name: entity.rl_menu_link_experiment.add_form
+ title: 'Add experiment'
+ appears_on:
+ - view.rl_menu_link_experiment.page_1
diff --git a/modules/rl_menu_link/rl_menu_link.module b/modules/rl_menu_link/rl_menu_link.module
new file mode 100644
index 0000000..e40b47b
--- /dev/null
+++ b/modules/rl_menu_link/rl_menu_link.module
@@ -0,0 +1,474 @@
+' . t('A/B test menu link titles using Thompson Sampling. Works for menu_link_content entities and YAML-defined links. Manage experiments at Reinforcement Learning menu link experiments.', [
+ ':url' => Url::fromRoute('view.rl_menu_link_experiment.page_1')->toString(),
+ ]) . '
';
+ }
+}
+
+/**
+ * Implements hook_preprocess_menu().
+ *
+ * Walks the menu tree and replaces link titles with the winning variant.
+ * Tracking is wired entirely through data attributes injected onto the
+ * rendered anchors; the JS reads them directly from the DOM.
+ */
+function rl_menu_link_preprocess_menu(array &$variables) {
+ if (empty($variables['items'])) {
+ return;
+ }
+
+ $touched_plugin_ids = [];
+ _rl_menu_link_process_items($variables['items'], $touched_plugin_ids);
+
+ // Always attach the rl_menu_link:all tag so any future experiment creation
+ // can invalidate cached menu output even for menus that did not have an
+ // experiment when they were first cached.
+ $variables['#cache']['tags'][] = 'rl_menu_link:all';
+
+ if (!empty($touched_plugin_ids)) {
+ $rl_path = \Drupal::service('extension.list.module')->getPath('rl');
+ $base_path = \Drupal::request()->getBasePath();
+ $variables['#attached']['library'][] = 'rl_menu_link/tracking';
+ $variables['#attached']['drupalSettings']['rlMenuLink'] = [
+ 'rlEndpointUrl' => $base_path . '/' . $rl_path . '/rl.php',
+ ];
+ // Add per-plugin-id tags so each experiment can invalidate the menus
+ // that contain its specific link.
+ foreach ($touched_plugin_ids as $plugin_id) {
+ $variables['#cache']['tags'][] = 'rl_menu_link:' . $plugin_id;
+ }
+ }
+}
+
+/**
+ * Recursively process menu items, swapping titles for winning variants.
+ *
+ * @param array $items
+ * The menu items render array (passed by reference).
+ * @param array $touched_plugin_ids
+ * Plugin IDs that received a variant override during this walk, indexed
+ * by themselves for fast deduplication. Passed by reference.
+ */
+function _rl_menu_link_process_items(array &$items, array &$touched_plugin_ids) {
+ /** @var \Drupal\rl_menu_link\Service\MenuLinkVariantSelector $selector */
+ $selector = \Drupal::service('rl_menu_link.variant_selector');
+
+ foreach ($items as &$item) {
+ $plugin_id = NULL;
+ if (isset($item['original_link']) && is_object($item['original_link']) && method_exists($item['original_link'], 'getPluginId')) {
+ $plugin_id = $item['original_link']->getPluginId();
+ }
+
+ if ($plugin_id) {
+ $result = $selector->selectForPluginId($plugin_id);
+ if ($result) {
+ if ($result['text'] !== NULL) {
+ $item['title'] = $result['text'];
+ }
+ // Attach data attributes to the rendered anchor so the tracking JS
+ // can find this link and report turns/rewards for the correct
+ // experiment.
+ if (isset($item['url']) && $item['url'] instanceof Url) {
+ $existing_attributes = $item['url']->getOption('attributes') ?: [];
+ $item['url']->setOption('attributes', $existing_attributes + [
+ 'data-rl-ml-experiment-id' => $result['experiment_id'],
+ 'data-rl-ml-arm-id' => $result['arm_id'],
+ ]);
+ }
+ $touched_plugin_ids[$plugin_id] = $plugin_id;
+ }
+ }
+
+ if (!empty($item['below'])) {
+ _rl_menu_link_process_items($item['below'], $touched_plugin_ids);
+ }
+ }
+}
+
+/**
+ * Implements hook_form_alter().
+ *
+ * Adds the title variants vertical tab to menu_link_content edit forms.
+ */
+function rl_menu_link_form_alter(array &$form, FormStateInterface $form_state, $form_id) {
+ $form_object = $form_state->getFormObject();
+ if (!$form_object instanceof ContentEntityFormInterface) {
+ return;
+ }
+ $operation = $form_object->getOperation();
+ if (!in_array($operation, ['default', 'edit'], TRUE)) {
+ return;
+ }
+ $entity = $form_object->getEntity();
+ if (!$entity || $entity->isNew()) {
+ return;
+ }
+ if ($entity->getEntityTypeId() !== 'menu_link_content') {
+ return;
+ }
+ if (!method_exists($entity, 'getPluginId')) {
+ return;
+ }
+ if (!\Drupal::currentUser()->hasPermission('administer rl menu link experiments')) {
+ return;
+ }
+
+ $plugin_id = $entity->getPluginId();
+ // Use the entity's current translation language for the experiment scope.
+ $entity_langcode = $entity->language()->getId() ?: LanguageInterface::LANGCODE_NOT_SPECIFIED;
+
+ /** @var \Drupal\rl_menu_link\Entity\MenuLinkExperiment|null $existing */
+ $existing = NULL;
+ $matches = \Drupal::entityTypeManager()
+ ->getStorage('rl_menu_link_experiment')
+ ->loadByProperties([
+ 'menu_link_plugin_id' => $plugin_id,
+ 'langcode' => $entity_langcode,
+ ]);
+ if ($matches) {
+ $candidate = reset($matches);
+ if ($candidate instanceof MenuLinkExperiment) {
+ $existing = $candidate;
+ }
+ }
+
+ $is_multilingual = \Drupal::languageManager()->isMultilingual();
+ $language_name = $entity_langcode === LanguageInterface::LANGCODE_NOT_SPECIFIED
+ ? t('all languages')
+ : (\Drupal::languageManager()->getLanguage($entity_langcode)?->getName() ?? $entity_langcode);
+
+ $baseline_title = (string) ($entity->label() ?? '');
+
+ $form['rl_menu_link'] = [
+ '#type' => 'details',
+ '#title' => t('A/B test menu link title'),
+ '#group' => 'advanced',
+ '#open' => FALSE,
+ '#weight' => 50,
+ '#tree' => TRUE,
+ ];
+
+ $form['rl_menu_link']['baseline'] = [
+ '#type' => 'item',
+ '#title' => t('Current menu link title'),
+ '#markup' => '',
+ ];
+
+ if ($existing) {
+ $rl_experiment_id = $existing->getRlExperimentId();
+ $experiment_manager = \Drupal::service('rl.experiment_manager');
+ $total_turns = $experiment_manager->getTotalTurns($rl_experiment_id);
+ $arms = $experiment_manager->getAllArmsData($rl_experiment_id);
+ $total_rewards = 0;
+ $leading_arm = NULL;
+ $leading_rate = -1.0;
+ // Require at least this many turns per arm before the arm is eligible
+ // to be displayed as the current leader. Below this threshold the
+ // observed rate is dominated by sampling noise.
+ $min_per_arm_for_leader = 10;
+ foreach ($arms as $arm) {
+ $turns = (int) $arm->turns;
+ $rewards = (int) $arm->rewards;
+ $total_rewards += $rewards;
+ if ($turns >= $min_per_arm_for_leader) {
+ $rate = $rewards / $turns;
+ if ($rate > $leading_rate) {
+ $leading_rate = $rate;
+ $leading_arm = (string) $arm->arm_id;
+ }
+ }
+ }
+
+ $leading_text = NULL;
+ if ($leading_arm !== NULL) {
+ if ($leading_arm === 'v0') {
+ $leading_text = $baseline_title;
+ }
+ else {
+ $index = (int) substr($leading_arm, 1) - 1;
+ $variants = $existing->getVariants();
+ if (isset($variants[$index])) {
+ $leading_text = (string) $variants[$index];
+ }
+ }
+ }
+
+ $state_line = $existing->isPublished()
+ ? t('This test is running.')
+ : t('This test is paused. It will not show variants to visitors until you turn it back on.');
+
+ if ($is_multilingual) {
+ $state_line = $existing->isPublished()
+ ? t('This test is running in @lang.', ['@lang' => $language_name])
+ : t('This test is paused in @lang. It will not show variants to visitors until you turn it back on.', ['@lang' => $language_name]);
+ }
+
+ if ($total_turns === 0) {
+ $progress_line = t('No visitors have seen this test yet. Come back after some traffic arrives.');
+ }
+ elseif ($total_turns < 10 || $leading_text === NULL) {
+ $progress_line = t('Still collecting data. @turns visitors have seen a variant so far.', [
+ '@turns' => number_format($total_turns),
+ ]);
+ }
+ else {
+ if ($leading_arm === 'v0') {
+ $progress_line = t('Your original title is currently getting the most clicks. @turns impressions, @rewards clicks.', [
+ '@turns' => number_format($total_turns),
+ '@rewards' => number_format($total_rewards),
+ ]);
+ }
+ else {
+ $progress_line = t('"@alt" is currently out-clicking your original. @turns impressions, @rewards clicks.', [
+ '@alt' => $leading_text,
+ '@turns' => number_format($total_turns),
+ '@rewards' => number_format($total_rewards),
+ ]);
+ }
+ }
+
+ $form['rl_menu_link']['state'] = [
+ '#type' => 'item',
+ '#markup' => '' . $state_line . '
' . $progress_line . '
',
+ ];
+
+ if ($total_turns > 0) {
+ $form['rl_menu_link']['report'] = [
+ '#type' => 'link',
+ '#title' => t('See the full report'),
+ '#url' => Url::fromRoute('rl.reports.experiment_detail', ['experiment_id' => $rl_experiment_id]),
+ '#attributes' => ['target' => '_blank'],
+ ];
+ }
+ }
+ else {
+ $intro = $is_multilingual
+ ? t('Not sure if this menu link title is pulling its weight? Add a few alternatives below and we will rotate them through for visitors in @lang, then tell you which one gets the most clicks.', [
+ '@lang' => $language_name,
+ ])
+ : t('Not sure if this menu link title is pulling its weight? Add a few alternatives below and we will rotate them through for visitors, then tell you which one gets the most clicks.');
+
+ $form['rl_menu_link']['intro'] = [
+ '#type' => 'item',
+ '#markup' => '' . $intro . '
',
+ ];
+ }
+
+ $form['rl_menu_link']['variants'] = [
+ '#type' => 'textarea',
+ '#title' => $existing ? t('Alternatives you are testing') : t('Try these alternative titles'),
+ '#description' => t('Enter one alternative per line. Your current menu link title (shown above) always stays in the test.'),
+ '#default_value' => $existing ? implode("\n", $existing->getVariants()) : '',
+ '#rows' => 4,
+ ];
+
+ $form['rl_menu_link']['enabled'] = [
+ '#type' => 'checkbox',
+ '#title' => t('Run this test'),
+ '#description' => t('Turn off to pause. Your collected data is kept safe.'),
+ '#default_value' => $existing ? (bool) $existing->isPublished() : TRUE,
+ ];
+
+ if ($existing) {
+ $form['rl_menu_link']['remove_link'] = [
+ '#type' => 'link',
+ '#title' => t('Stop testing and delete collected data'),
+ '#url' => $existing->toUrl('delete-form'),
+ '#attributes' => ['class' => ['button', 'button--small', 'button--danger']],
+ '#prefix' => '',
+ ];
+ }
+
+ $form['rl_menu_link']['_plugin_id'] = [
+ '#type' => 'value',
+ '#value' => $plugin_id,
+ ];
+ $form['rl_menu_link']['_langcode'] = [
+ '#type' => 'value',
+ '#value' => $entity_langcode,
+ ];
+ if ($existing) {
+ $form['rl_menu_link']['_existing_id'] = [
+ '#type' => 'value',
+ '#value' => $existing->id(),
+ ];
+ }
+
+ // Attach our submit handler to all submit-style actions.
+ if (isset($form['actions']) && is_array($form['actions'])) {
+ foreach (Element::children($form['actions']) as $action_key) {
+ if (isset($form['actions'][$action_key]['#type']) && $form['actions'][$action_key]['#type'] === 'submit') {
+ $form['actions'][$action_key]['#submit'][] = '_rl_menu_link_form_submit';
+ }
+ }
+ }
+}
+
+/**
+ * Submit handler for the vertical tab on menu link content forms.
+ */
+function _rl_menu_link_form_submit(array &$form, FormStateInterface $form_state) {
+ $values = $form_state->getValue('rl_menu_link');
+ if (empty($values)) {
+ return;
+ }
+
+ $plugin_id = $values['_plugin_id'] ?? NULL;
+ $langcode = $values['_langcode'] ?? LanguageInterface::LANGCODE_NOT_SPECIFIED;
+ if (!$plugin_id) {
+ return;
+ }
+
+ $variants = VariantParser::parse((string) ($values['variants'] ?? ''));
+ $storage = \Drupal::entityTypeManager()->getStorage('rl_menu_link_experiment');
+ $existing_id = $values['_existing_id'] ?? NULL;
+ $experiment = NULL;
+ if ($existing_id) {
+ $loaded = $storage->load($existing_id);
+ if ($loaded instanceof MenuLinkExperiment) {
+ $experiment = $loaded;
+ }
+ }
+
+ try {
+ if (empty($variants)) {
+ // A blank textarea no longer deletes. Deletion is a deliberate
+ // action through the dedicated "Remove this experiment" link, so
+ // an accidentally cleared field can never destroy collected data.
+ if ($experiment !== NULL) {
+ \Drupal::messenger()->addWarning(t('Menu link title variants were left unchanged because no alternatives were provided. To stop this experiment, use "Stop testing and delete collected data".'));
+ }
+ return;
+ }
+
+ if ($experiment === NULL) {
+ $form_object = $form_state->getFormObject();
+ $label = $plugin_id;
+ if ($form_object instanceof EntityFormInterface) {
+ $parent = $form_object->getEntity();
+ $label = $parent->label() ?? $plugin_id;
+ }
+ $created = $storage->create([
+ 'label' => $label,
+ 'menu_link_plugin_id' => $plugin_id,
+ 'langcode' => $langcode,
+ ]);
+ assert($created instanceof MenuLinkExperiment);
+ $experiment = $created;
+ }
+
+ $experiment->setVariants($variants);
+ if (!empty($values['enabled'])) {
+ $experiment->setPublished();
+ }
+ else {
+ $experiment->setUnpublished();
+ }
+ $experiment->save();
+
+ \Drupal::service('rl.experiment_registry')->register(
+ $experiment->getRlExperimentId(),
+ 'rl_menu_link',
+ $experiment->label()
+ );
+
+ // Invalidate menu caches so the new variants take effect immediately.
+ Cache::invalidateTags(['rl_menu_link:all', 'rl_menu_link:' . $experiment->getMenuLinkPluginId()]);
+
+ \Drupal::messenger()->addStatus(t('Menu link title variants saved. @n alternatives will be rotated against your original title.', [
+ '@n' => count($variants),
+ ]));
+ }
+ catch (\Exception $e) {
+ \Drupal::logger('rl_menu_link')->error('Failed to save menu link title variants: @message', ['@message' => $e->getMessage()]);
+ \Drupal::messenger()->addError(t('Menu link title variants could not be saved: @message', ['@message' => $e->getMessage()]));
+ }
+}
+
+/**
+ * Implements hook_entity_predelete().
+ *
+ * Cleans up menu link experiments when the menu link content entity is
+ * deleted, and purges associated RL analytics.
+ */
+function rl_menu_link_entity_predelete(EntityInterface $entity) {
+ if ($entity->getEntityTypeId() !== 'menu_link_content') {
+ return;
+ }
+
+ $plugin_id = method_exists($entity, 'getPluginId') ? $entity->getPluginId() : NULL;
+ if (!$plugin_id) {
+ return;
+ }
+
+ $storage = \Drupal::entityTypeManager()->getStorage('rl_menu_link_experiment');
+ $matches = $storage->loadByProperties(['menu_link_plugin_id' => $plugin_id]);
+ if (!$matches) {
+ return;
+ }
+ $manager = \Drupal::service('rl.experiment_manager');
+ foreach ($matches as $experiment) {
+ if (!$experiment instanceof MenuLinkExperiment) {
+ continue;
+ }
+ $rl_id = $experiment->getRlExperimentId();
+ // Purge analytics first; if it fails, the config entity is still around
+ // for a retry.
+ $manager->purgeExperiment($rl_id);
+ $experiment->delete();
+ }
+}
+
+/**
+ * Implements hook_preprocess_views_view_field().
+ *
+ * Rewrites the Label cell in the menu link experiment admin Views table
+ * so it links to the experiment report instead of the entity edit form.
+ * The report is the more useful destination for someone scanning the
+ * list: it shows per-arm statistics. The Operations column still
+ * provides Edit/Delete for entity-level actions.
+ */
+function rl_menu_link_preprocess_views_view_field(array &$variables): void {
+ if ($variables['view']->id() !== 'rl_menu_link_experiment') {
+ return;
+ }
+ if ($variables['field']->field !== 'label') {
+ return;
+ }
+ $entity = $variables['row']->_entity ?? NULL;
+ if (!$entity instanceof MenuLinkExperiment) {
+ return;
+ }
+ $variables['output'] = [
+ '#type' => 'link',
+ '#title' => $entity->label(),
+ '#url' => Url::fromRoute('rl.reports.experiment_detail', [
+ 'experiment_id' => $entity->getRlExperimentId(),
+ ]),
+ ];
+}
diff --git a/modules/rl_menu_link/rl_menu_link.permissions.yml b/modules/rl_menu_link/rl_menu_link.permissions.yml
new file mode 100644
index 0000000..4f50fa2
--- /dev/null
+++ b/modules/rl_menu_link/rl_menu_link.permissions.yml
@@ -0,0 +1,4 @@
+administer rl menu link experiments:
+ title: 'Administer Reinforcement Learning menu link experiments'
+ description: 'Create, edit, and delete menu link A/B test experiments.'
+ restrict access: true
diff --git a/modules/rl_menu_link/rl_menu_link.services.yml b/modules/rl_menu_link/rl_menu_link.services.yml
new file mode 100644
index 0000000..ef0332f
--- /dev/null
+++ b/modules/rl_menu_link/rl_menu_link.services.yml
@@ -0,0 +1,17 @@
+services:
+ rl_menu_link.variant_selector:
+ class: Drupal\rl_menu_link\Service\MenuLinkVariantSelector
+ arguments:
+ - '@entity_type.manager'
+ - '@rl.experiment_manager'
+ - '@rl.cache_manager'
+ - '@rl.experiment_registry'
+ - '@language_manager'
+
+ rl_menu_link.decorator:
+ class: Drupal\rl_menu_link\Decorator\MenuLinkDecorator
+ arguments:
+ - '@entity_type.manager'
+ - '@plugin.manager.menu.link'
+ tags:
+ - { name: rl_experiment_decorator }
diff --git a/modules/rl_menu_link/src/Decorator/MenuLinkDecorator.php b/modules/rl_menu_link/src/Decorator/MenuLinkDecorator.php
new file mode 100644
index 0000000..a8395fa
--- /dev/null
+++ b/modules/rl_menu_link/src/Decorator/MenuLinkDecorator.php
@@ -0,0 +1,104 @@
+menuLinkManager = $menu_link_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function experimentIdPrefix(): string {
+ return 'rl_menu_link-';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function entityTypeId(): string {
+ return 'rl_menu_link_experiment';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function entityClass(): string {
+ return MenuLinkExperiment::class;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function buildExperimentDisplay(VariantExperimentInterface $experiment): array {
+ assert($experiment instanceof MenuLinkExperiment);
+ $plugin_id = $experiment->getMenuLinkPluginId();
+ $original_label = $this->getOriginalLabel($plugin_id) ?? $plugin_id;
+ $langcode = $experiment->language()->getId();
+ $lang_label = $langcode === LanguageInterface::LANGCODE_NOT_SPECIFIED
+ ? (string) t('all languages')
+ : $langcode;
+ return [
+ '#type' => 'inline_template',
+ '#template' => '{{ label }} ({{ plugin_id }}, {{ lang }})',
+ '#context' => [
+ 'label' => $experiment->label() ?: $original_label,
+ 'plugin_id' => $plugin_id,
+ 'lang' => $lang_label,
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function buildOriginalArmDisplay(VariantExperimentInterface $experiment): array {
+ assert($experiment instanceof MenuLinkExperiment);
+ $original = $this->getOriginalLabel($experiment->getMenuLinkPluginId());
+ return [
+ '#type' => 'inline_template',
+ '#template' => '{{ label }}',
+ '#context' => ['label' => $original ?: (string) t('(original)')],
+ ];
+ }
+
+ /**
+ * Get the original label of a menu link by plugin ID.
+ */
+ protected function getOriginalLabel(string $plugin_id): ?string {
+ if (!$this->menuLinkManager->hasDefinition($plugin_id)) {
+ return NULL;
+ }
+ try {
+ $link = $this->menuLinkManager->createInstance($plugin_id);
+ return (string) $link->getTitle();
+ }
+ catch (\Exception $e) {
+ return NULL;
+ }
+ }
+
+}
diff --git a/modules/rl_menu_link/src/Drush/Commands/RlMenuLinkCommands.php b/modules/rl_menu_link/src/Drush/Commands/RlMenuLinkCommands.php
new file mode 100644
index 0000000..9b26074
--- /dev/null
+++ b/modules/rl_menu_link/src/Drush/Commands/RlMenuLinkCommands.php
@@ -0,0 +1,400 @@
+menuLinkManager->hasDefinition($plugin_id)) {
+ return NULL;
+ }
+ try {
+ $title = (string) $this->menuLinkManager->createInstance($plugin_id)->getTitle();
+ return $title !== '' ? $title : NULL;
+ }
+ catch (\Exception $e) {
+ return NULL;
+ }
+ }
+
+ /**
+ * Lists all menu link experiments.
+ */
+ #[CLI\Command(name: 'rl:menu-link:list', aliases: ['rl-mll'])]
+ #[CLI\Help(description: '[YAML] List all menu link experiments with their plugin IDs and language scope.')]
+ #[CLI\Option(name: 'enabled', description: 'Filter by enabled status: yes, no, all')]
+ public function list(array $options = ['enabled' => 'all']): string {
+ $this->switchToAdmin();
+
+ $storage = $this->entityTypeManager->getStorage('rl_menu_link_experiment');
+ $properties = [];
+ if ($options['enabled'] === 'yes') {
+ $properties['enabled'] = TRUE;
+ }
+ elseif ($options['enabled'] === 'no') {
+ $properties['enabled'] = FALSE;
+ }
+
+ $entities = $properties ? $storage->loadByProperties($properties) : $storage->loadMultiple();
+ $items = [];
+ foreach ($entities as $entity) {
+ if (!$entity instanceof MenuLinkExperiment) {
+ continue;
+ }
+ $rl_id = $entity->getRlExperimentId();
+ $items[] = [
+ 'id' => $entity->id(),
+ 'label' => $entity->label(),
+ 'menu_link_plugin_id' => $entity->getMenuLinkPluginId(),
+ 'langcode' => $entity->language()->getId(),
+ 'variants' => count($entity->getVariants()) + 1,
+ 'enabled' => (bool) $entity->isPublished(),
+ 'rl_experiment_id' => $rl_id,
+ 'impressions' => $this->experimentManager->getTotalTurns($rl_id),
+ ];
+ }
+
+ return $this->successList($items);
+ }
+
+ /**
+ * Shows full details for one menu link experiment.
+ */
+ #[CLI\Command(name: 'rl:menu-link:get', aliases: ['rl-mlg'])]
+ #[CLI\Help(description: '[YAML] Show full details for one menu link experiment, including variants and live RL stats.')]
+ #[CLI\Argument(name: 'experimentId', description: 'The menu link experiment entity ID')]
+ public function get(string $experimentId): string {
+ $this->switchToAdmin();
+
+ $entity = $this->entityTypeManager->getStorage('rl_menu_link_experiment')->load($experimentId);
+ if (!$entity instanceof MenuLinkExperiment) {
+ return $this->error(sprintf('Menu link experiment "%s" not found.', $experimentId));
+ }
+
+ $rl_id = $entity->getRlExperimentId();
+ $arms = [];
+ foreach ($this->experimentManager->getAllArmsData($rl_id) as $arm) {
+ $arms[$arm->arm_id] = [
+ 'turns' => (int) $arm->turns,
+ 'rewards' => (int) $arm->rewards,
+ 'rate' => $arm->turns > 0 ? round(($arm->rewards / $arm->turns) * 100, 2) : 0.0,
+ ];
+ }
+
+ $original_label = NULL;
+ if ($this->menuLinkManager->hasDefinition($entity->getMenuLinkPluginId())) {
+ try {
+ $original_label = (string) $this->menuLinkManager
+ ->createInstance($entity->getMenuLinkPluginId())
+ ->getTitle();
+ }
+ catch (\Exception $e) {
+ // Ignore.
+ }
+ }
+
+ return $this->yaml([
+ 'id' => $entity->id(),
+ 'label' => $entity->label(),
+ 'menu_link_plugin_id' => $entity->getMenuLinkPluginId(),
+ 'original_label' => $original_label,
+ 'langcode' => $entity->language()->getId(),
+ 'enabled' => (bool) $entity->isPublished(),
+ 'variants' => $entity->getVariants(),
+ 'rl_experiment_id' => $rl_id,
+ 'analytics' => [
+ 'total_turns' => $this->experimentManager->getTotalTurns($rl_id),
+ 'arms' => $arms,
+ ],
+ ]);
+ }
+
+ /**
+ * Creates a new menu link experiment.
+ */
+ #[CLI\Command(name: 'rl:menu-link:create', aliases: ['rl-mlc'])]
+ #[CLI\Help(description: '[YAML] Create a new menu link A/B test experiment.')]
+ #[CLI\Argument(name: 'pluginId', description: 'Menu link plugin ID (e.g. menu_link_content:abc-uuid or system.admin_content)')]
+ #[CLI\Option(name: 'variants', description: 'Comma-separated alternative labels, OR multiple --variants flags')]
+ #[CLI\Option(name: 'label', description: 'Human-readable experiment label (default: derived from plugin ID)')]
+ #[CLI\Option(name: 'langcode', description: 'Language code, or "und" for all languages (default: und)')]
+ #[CLI\Option(name: 'disabled', description: 'Create as disabled')]
+ #[CLI\Option(name: 'dry-run', description: 'Preview without creating')]
+ #[CLI\Usage(name: 'drush rl:menu-link:create system.admin_content --variants="Content,Manage Content"', description: 'Test core admin link')]
+ #[CLI\Usage(name: 'drush rl-mlc menu_link_content:abc-uuid --variants="Services,What We Do" --langcode=en', description: 'English-only experiment')]
+ public function create(
+ string $pluginId,
+ array $options = [
+ 'variants' => NULL,
+ 'label' => NULL,
+ 'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
+ 'disabled' => FALSE,
+ 'dry-run' => FALSE,
+ ],
+ ): string {
+ $this->switchToAdmin();
+
+ $variants = $this->parseVariants($options['variants'] ?? NULL);
+ if (empty($variants)) {
+ return $this->error('At least one variant is required.', ['Use --variants="Alt 1,Alt 2" or --variants=Alt1 --variants=Alt2']);
+ }
+
+ $pluginId = trim($pluginId);
+ if (!$this->menuLinkManager->hasDefinition($pluginId)) {
+ return $this->error(sprintf('No menu link plugin "%s" is registered.', $pluginId));
+ }
+
+ $langcode = (string) ($options['langcode'] ?? LanguageInterface::LANGCODE_NOT_SPECIFIED);
+
+ $duplicates = $this->entityTypeManager
+ ->getStorage('rl_menu_link_experiment')
+ ->loadByProperties([
+ 'menu_link_plugin_id' => $pluginId,
+ 'langcode' => $langcode,
+ ]);
+ if ($duplicates) {
+ $existing = reset($duplicates);
+ return $this->error(
+ sprintf('An experiment for "%s" (%s) already exists: %s.', $pluginId, $langcode, $existing->id()),
+ ['Use rl:menu-link:update to modify it.']
+ );
+ }
+
+ $label = $options['label'] ?? $this->resolvePluginLabel($pluginId) ?? $pluginId;
+
+ if ($options['dry-run']) {
+ return $this->yaml([
+ 'dry_run' => TRUE,
+ 'action' => 'create',
+ 'experiment' => [
+ 'menu_link_plugin_id' => $pluginId,
+ 'langcode' => $langcode,
+ 'label' => $label,
+ 'variants' => $variants,
+ 'enabled' => !$options['disabled'],
+ ],
+ ]);
+ }
+
+ $entity = $this->entityTypeManager->getStorage('rl_menu_link_experiment')->create([
+ 'label' => $label,
+ 'menu_link_plugin_id' => $pluginId,
+ 'langcode' => $langcode,
+ ]);
+ assert($entity instanceof MenuLinkExperiment);
+ $entity->setVariants($variants);
+ if ($options['disabled']) {
+ $entity->setUnpublished();
+ }
+ else {
+ $entity->setPublished();
+ }
+ $entity->save();
+
+ $this->experimentRegistry->register($entity->getRlExperimentId(), 'rl_menu_link', $label);
+
+ return $this->success(
+ sprintf('Created menu link experiment "%s".', $entity->id()),
+ [
+ 'experiment' => [
+ 'id' => $entity->id(),
+ 'label' => $label,
+ 'menu_link_plugin_id' => $pluginId,
+ 'langcode' => $langcode,
+ 'variants' => $variants,
+ 'rl_experiment_id' => $entity->getRlExperimentId(),
+ ],
+ ]
+ );
+ }
+
+ /**
+ * Updates an existing menu link experiment.
+ */
+ #[CLI\Command(name: 'rl:menu-link:update', aliases: ['rl-mlu'])]
+ #[CLI\Help(description: '[YAML] Update an existing menu link experiment.')]
+ #[CLI\Argument(name: 'experimentId', description: 'The menu link experiment entity ID')]
+ #[CLI\Option(name: 'label', description: 'New human-readable label')]
+ #[CLI\Option(name: 'variants', description: 'Replace variants list (comma-separated, or multiple --variants flags)')]
+ #[CLI\Option(name: 'enable', description: 'Set enabled state to true')]
+ #[CLI\Option(name: 'disable', description: 'Set enabled state to false')]
+ #[CLI\Option(name: 'dry-run', description: 'Preview without saving')]
+ public function update(
+ string $experimentId,
+ array $options = [
+ 'label' => NULL,
+ 'variants' => NULL,
+ 'enable' => FALSE,
+ 'disable' => FALSE,
+ 'dry-run' => FALSE,
+ ],
+ ): string {
+ $this->switchToAdmin();
+
+ $entity = $this->entityTypeManager->getStorage('rl_menu_link_experiment')->load($experimentId);
+ if (!$entity instanceof MenuLinkExperiment) {
+ return $this->error(sprintf('Menu link experiment "%s" not found.', $experimentId));
+ }
+
+ $changes = [];
+ if ($options['label'] !== NULL) {
+ $changes['label'] = $options['label'];
+ }
+ if ($options['variants'] !== NULL) {
+ $variants = $this->parseVariants($options['variants']);
+ if (empty($variants)) {
+ return $this->error('At least one variant is required when --variants is provided.');
+ }
+ $changes['variants'] = $variants;
+ }
+ if ($options['enable']) {
+ $changes['enabled'] = TRUE;
+ }
+ elseif ($options['disable']) {
+ $changes['enabled'] = FALSE;
+ }
+
+ if (empty($changes)) {
+ return $this->error('Nothing to update. Provide --label, --variants, --enable, or --disable.');
+ }
+
+ if ($options['dry-run']) {
+ return $this->yaml([
+ 'dry_run' => TRUE,
+ 'action' => 'update',
+ 'experiment_id' => $experimentId,
+ 'changes' => $changes,
+ ]);
+ }
+
+ if (isset($changes['label'])) {
+ $entity->set('label', $changes['label']);
+ }
+ if (isset($changes['variants'])) {
+ $entity->setVariants($changes['variants']);
+ }
+ if (array_key_exists('enabled', $changes)) {
+ if ($changes['enabled']) {
+ $entity->setPublished();
+ }
+ else {
+ $entity->setUnpublished();
+ }
+ }
+ $entity->save();
+
+ $this->experimentRegistry->register($entity->getRlExperimentId(), 'rl_menu_link', $entity->label());
+
+ return $this->success(
+ sprintf('Updated menu link experiment "%s".', $experimentId),
+ ['changes' => $changes]
+ );
+ }
+
+ /**
+ * Deletes a menu link experiment and purges its analytics.
+ */
+ #[CLI\Command(name: 'rl:menu-link:delete', aliases: ['rl-mld'])]
+ #[CLI\Help(description: '[YAML] Delete a menu link experiment AND purge its RL analytics.')]
+ #[CLI\Argument(name: 'experimentId', description: 'The menu link experiment entity ID')]
+ #[CLI\Option(name: 'dry-run', description: 'Preview what would be deleted')]
+ public function delete(
+ string $experimentId,
+ array $options = [
+ 'dry-run' => FALSE,
+ ],
+ ): string {
+ $this->switchToAdmin();
+
+ $entity = $this->entityTypeManager->getStorage('rl_menu_link_experiment')->load($experimentId);
+ if (!$entity instanceof MenuLinkExperiment) {
+ return $this->error(sprintf('Menu link experiment "%s" not found.', $experimentId));
+ }
+
+ $rl_id = $entity->getRlExperimentId();
+ $turns = $this->experimentManager->getTotalTurns($rl_id);
+
+ if ($options['dry-run']) {
+ return $this->yaml([
+ 'dry_run' => TRUE,
+ 'action' => 'delete',
+ 'experiment' => [
+ 'id' => $entity->id(),
+ 'menu_link_plugin_id' => $entity->getMenuLinkPluginId(),
+ 'langcode' => $entity->language()->getId(),
+ 'rl_experiment_id' => $rl_id,
+ 'analytics_turns' => $turns,
+ ],
+ ]);
+ }
+
+ $this->experimentManager->purgeExperiment($rl_id);
+ $entity->delete();
+
+ return $this->success(
+ sprintf('Deleted menu link experiment "%s" and purged %d turns.', $experimentId, $turns),
+ );
+ }
+
+ /**
+ * Parse variants from --variants option.
+ *
+ * @param mixed $raw
+ * The raw option value (string or array).
+ *
+ * @return string[]
+ * Trimmed, non-empty variant strings.
+ */
+ protected function parseVariants(mixed $raw): array {
+ if ($raw === NULL || $raw === '') {
+ return [];
+ }
+ if (is_array($raw)) {
+ $combined = implode("\n", $raw);
+ }
+ else {
+ $combined = str_replace(',', "\n", (string) $raw);
+ }
+ return VariantParser::parse($combined);
+ }
+
+}
diff --git a/modules/rl_menu_link/src/Entity/MenuLinkExperiment.php b/modules/rl_menu_link/src/Entity/MenuLinkExperiment.php
new file mode 100644
index 0000000..42517aa
--- /dev/null
+++ b/modules/rl_menu_link/src/Entity/MenuLinkExperiment.php
@@ -0,0 +1,189 @@
+setLabel(t('Experiment name'))
+ ->setDescription(t('A short name you will recognize later in reports. If left blank, the name of the menu link is used.'))
+ ->setRequired(TRUE)
+ ->setSetting('max_length', 255)
+ ->setDisplayOptions('form', [
+ 'type' => 'string_textfield',
+ 'weight' => -10,
+ ])
+ ->setDisplayConfigurable('form', TRUE);
+
+ $fields['menu_link_plugin_id'] = BaseFieldDefinition::create('string')
+ ->setLabel(t('Menu link'))
+ ->setDescription(t('The internal identifier of the menu link you want to test. The easiest way to set this is to open the menu link from Structure › Menus and use the "A/B test menu link title" tab on its edit form instead of this page.', [
+ ':menu_admin' => '/admin/structure/menu',
+ ]))
+ ->setRequired(TRUE)
+ ->setSetting('max_length', 255)
+ ->setDisplayOptions('form', [
+ 'type' => 'string_textfield',
+ 'weight' => -5,
+ ])
+ ->setDisplayConfigurable('form', TRUE)
+ ->addConstraint('NotBlank');
+
+ $fields['variants_data'] = BaseFieldDefinition::create('string_long')
+ ->setLabel(t('Alternative titles (JSON)'))
+ ->setDescription(t('JSON-encoded list of alternative menu link title strings.'))
+ ->setRequired(TRUE);
+
+ // The 'enabled' field comes from publishedBaseFieldDefinitions(); we
+ // narrow the type so the chained mutators are PHPStan-clean.
+ $enabled = $fields['enabled'];
+ assert($enabled instanceof BaseFieldDefinition);
+ $enabled
+ ->setLabel(t('Serve variants to visitors'))
+ ->setDescription(t('Uncheck to pause the experiment without losing any collected data.'))
+ ->setDefaultValue(TRUE)
+ ->setDisplayOptions('form', [
+ 'type' => 'boolean_checkbox',
+ 'weight' => 5,
+ ])
+ ->setDisplayConfigurable('form', TRUE);
+
+ $fields['created'] = BaseFieldDefinition::create('created')
+ ->setLabel(t('Created'));
+
+ $fields['changed'] = BaseFieldDefinition::create('changed')
+ ->setLabel(t('Changed'));
+
+ return $fields;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function preSave(EntityStorageInterface $storage) {
+ parent::preSave($storage);
+ $this->set('menu_link_plugin_id', trim($this->getMenuLinkPluginId()));
+ }
+
+ /**
+ * Get the menu link plugin ID.
+ */
+ public function getMenuLinkPluginId(): string {
+ $value = $this->get('menu_link_plugin_id')->value;
+ return $value !== NULL ? (string) $value : '';
+ }
+
+ /**
+ * Set the menu link plugin ID.
+ */
+ public function setMenuLinkPluginId(string $plugin_id): static {
+ $this->set('menu_link_plugin_id', trim($plugin_id));
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getVariants(): array {
+ $value = $this->get('variants_data')->value;
+ if ($value === NULL || $value === '') {
+ return [];
+ }
+ $decoded = json_decode((string) $value, TRUE);
+ return is_array($decoded) ? array_values($decoded) : [];
+ }
+
+ /**
+ * Set variant labels.
+ *
+ * @param string[] $variants
+ * List of alternative labels to test against the original.
+ *
+ * @return $this
+ */
+ public function setVariants(array $variants): static {
+ $this->set('variants_data', json_encode(array_values($variants)));
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getRlExperimentId(): string {
+ $langcode = $this->language()->getId() ?: LanguageInterface::LANGCODE_NOT_SPECIFIED;
+ return self::buildRlExperimentId($this->getMenuLinkPluginId(), $langcode);
+ }
+
+ /**
+ * Build a deterministic RL experiment ID from a plugin ID and langcode.
+ */
+ public static function buildRlExperimentId(string $plugin_id, string $langcode = LanguageInterface::LANGCODE_NOT_SPECIFIED): string {
+ $key = trim($plugin_id) . '|' . $langcode;
+ return self::buildVariantExperimentId('rl_menu_link', $key);
+ }
+
+}
diff --git a/modules/rl_menu_link/src/Form/MenuLinkExperimentDeleteForm.php b/modules/rl_menu_link/src/Form/MenuLinkExperimentDeleteForm.php
new file mode 100644
index 0000000..0da59d4
--- /dev/null
+++ b/modules/rl_menu_link/src/Form/MenuLinkExperimentDeleteForm.php
@@ -0,0 +1,14 @@
+menuLinkManager = $container->get('plugin.manager.menu.link');
+ $instance->experimentRegistry = $container->get('rl.experiment_registry');
+ $instance->experimentManager = $container->get('rl.experiment_manager');
+ $instance->languageManager = $container->get('language_manager');
+ return $instance;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function form(array $form, FormStateInterface $form_state) {
+ $form = parent::form($form, $form_state);
+
+ $entity = $this->entity;
+ assert($entity instanceof MenuLinkExperiment);
+
+ $request = $this->getRequest();
+ if ($entity->isNew()) {
+ if ($entity->getMenuLinkPluginId() === '' && $request->query->has('menu_link_plugin_id')) {
+ $entity->setMenuLinkPluginId((string) $request->query->get('menu_link_plugin_id'));
+ $form['menu_link_plugin_id']['widget'][0]['value']['#default_value'] = $entity->getMenuLinkPluginId();
+ }
+ }
+
+ // Hide the JSON storage field; the textarea is the user-facing surface.
+ $form['variants_data']['#access'] = FALSE;
+ $form['variants'] = [
+ '#type' => 'textarea',
+ '#title' => $this->t('Alternative menu link titles'),
+ '#description' => $this->t('Enter one alternative per line. The original menu link title always stays in the test as the control, and each line below is rotated in for visitors and measured against it.'),
+ '#default_value' => implode("\n", $entity->getVariants()),
+ '#rows' => 6,
+ '#required' => TRUE,
+ '#weight' => 0,
+ ];
+
+ // Language selector with "all languages" default.
+ $languages = $this->languageManager->getLanguages(LanguageInterface::STATE_CONFIGURABLE);
+ $language_options = [LanguageInterface::LANGCODE_NOT_SPECIFIED => $this->t('- All languages -')];
+ foreach ($languages as $language) {
+ $language_options[$language->getId()] = $language->getName();
+ }
+ $form['langcode']['widget'][0]['value'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Language'),
+ '#description' => $this->t('Restrict this experiment to visitors in one language, or apply it to all languages. Each language tracks its own results independently.'),
+ '#options' => $language_options,
+ '#default_value' => $entity->language()->getId(),
+ ];
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state) {
+ parent::validateForm($form, $form_state);
+
+ $plugin_id = trim((string) $form_state->getValue(['menu_link_plugin_id', 0, 'value']));
+ if ($plugin_id === '') {
+ $form_state->setErrorByName('menu_link_plugin_id', $this->t('Menu link is required.'));
+ return;
+ }
+ if (!$this->menuLinkManager->hasDefinition($plugin_id)) {
+ $form_state->setErrorByName('menu_link_plugin_id', $this->t('No menu link with identifier %id was found. Try editing the link from Structure › Menus and using its "Label variants" tab instead.', ['%id' => $plugin_id]));
+ return;
+ }
+
+ $langcode = (string) ($form_state->getValue(['langcode', 0, 'value']) ?? LanguageInterface::LANGCODE_NOT_SPECIFIED);
+
+ $entity = $this->entity;
+ assert($entity instanceof MenuLinkExperiment);
+ $duplicates = $this->entityTypeManager
+ ->getStorage('rl_menu_link_experiment')
+ ->loadByProperties([
+ 'menu_link_plugin_id' => $plugin_id,
+ 'langcode' => $langcode,
+ ]);
+ foreach ($duplicates as $duplicate) {
+ if ((string) $duplicate->id() !== (string) $entity->id()) {
+ $form_state->setErrorByName('menu_link_plugin_id', $this->t('An experiment named "@label" already tests this menu link in the same language. Edit it instead.', [
+ '@label' => $duplicate->label(),
+ ':url' => $duplicate->toUrl('edit-form')->toString(),
+ ]));
+ break;
+ }
+ }
+
+ if (empty(VariantParser::parse((string) $form_state->getValue('variants', '')))) {
+ $form_state->setErrorByName('variants', $this->t('Enter at least one alternative menu link title, one per line.'));
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ parent::submitForm($form, $form_state);
+
+ $entity = $this->entity;
+ assert($entity instanceof MenuLinkExperiment);
+ $new_plugin_id = trim((string) $form_state->getValue(['menu_link_plugin_id', 0, 'value']));
+
+ $this->pendingPurgeRlExperimentId = NULL;
+ if (!$entity->isNew()) {
+ $original = $this->entityTypeManager
+ ->getStorage('rl_menu_link_experiment')
+ ->loadUnchanged($entity->id());
+ if ($original instanceof MenuLinkExperiment) {
+ $original_id = $original->getRlExperimentId();
+ $new_id = MenuLinkExperiment::buildRlExperimentId(
+ $new_plugin_id,
+ (string) ($form_state->getValue(['langcode', 0, 'value']) ?? LanguageInterface::LANGCODE_NOT_SPECIFIED)
+ );
+ if ($original_id !== $new_id) {
+ $this->pendingPurgeRlExperimentId = $original_id;
+ }
+ }
+ }
+
+ $entity->setMenuLinkPluginId($new_plugin_id);
+ $entity->setVariants(VariantParser::parse((string) $form_state->getValue('variants', '')));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function save(array $form, FormStateInterface $form_state) {
+ $entity = $this->entity;
+ assert($entity instanceof MenuLinkExperiment);
+ $status = $entity->save();
+
+ $this->experimentRegistry->register(
+ $entity->getRlExperimentId(),
+ 'rl_menu_link',
+ $entity->label()
+ );
+
+ if ($this->pendingPurgeRlExperimentId !== NULL) {
+ try {
+ $this->experimentManager->purgeExperiment($this->pendingPurgeRlExperimentId);
+ }
+ catch (\Exception $e) {
+ $this->messenger()->addWarning($this->t('Experiment retargeted, but old analytics could not be purged: @message. The previous experiment data is now orphaned and can be cleared manually from Reinforcement Learning reports.', [
+ '@message' => $e->getMessage(),
+ ':url' => '/admin/reports/rl',
+ ]));
+ }
+ $this->pendingPurgeRlExperimentId = NULL;
+ }
+
+ Cache::invalidateTags(['rl_menu_link:all', 'rl_menu_link:' . $entity->getMenuLinkPluginId()]);
+
+ if ($status === SAVED_NEW) {
+ $this->messenger()->addStatus($this->t('Created experiment %label.', ['%label' => $entity->label()]));
+ }
+ else {
+ $this->messenger()->addStatus($this->t('Updated experiment %label.', ['%label' => $entity->label()]));
+ }
+ $form_state->setRedirectUrl(Url::fromRoute('view.rl_menu_link_experiment.page_1'));
+ return $status;
+ }
+
+}
diff --git a/modules/rl_menu_link/src/Service/MenuLinkVariantSelector.php b/modules/rl_menu_link/src/Service/MenuLinkVariantSelector.php
new file mode 100644
index 0000000..7b60432
--- /dev/null
+++ b/modules/rl_menu_link/src/Service/MenuLinkVariantSelector.php
@@ -0,0 +1,79 @@
+languageManager = $language_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function ownerModule(): string {
+ return 'rl_menu_link';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function entityTypeId(): string {
+ return 'rl_menu_link_experiment';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function entityClass(): string {
+ return MenuLinkExperiment::class;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function targetProperty(): string {
+ return 'menu_link_plugin_id';
+ }
+
+ /**
+ * Select the winning variant for a menu link plugin ID.
+ */
+ public function selectForPluginId(string $plugin_id): ?array {
+ $langcode = $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_INTERFACE)->getId();
+ return $this->selectForTarget(trim($plugin_id), $langcode);
+ }
+
+}
diff --git a/modules/rl_page_title/.agents/skills/rl_page_title/SKILL.md b/modules/rl_page_title/.agents/skills/rl_page_title/SKILL.md
new file mode 100644
index 0000000..8e6cc4a
--- /dev/null
+++ b/modules/rl_page_title/.agents/skills/rl_page_title/SKILL.md
@@ -0,0 +1,54 @@
+# RL Page Title — A/B test page titles via Drush CLI
+
+A/B test Drupal page titles for any page (nodes, Views displays, custom
+controllers, any path) using Thompson Sampling. Per-language scoping.
+
+## Commands
+
+### Discovery
+- `drush rl:page-title:list` — List experiments (`--enabled=yes|no|all`)
+- `drush rl:page-title:get ` — Full details + live arm stats
+
+### Lifecycle
+- `drush rl:page-title:create --variants="A,B,C"` — Create
+- `drush rl:page-title:update --label="X"` — Update label/variants/enable/disable
+- `drush rl:page-title:delete ` — Delete + purge analytics
+
+### Common options
+- `--variants="A,B,C"` or `--variants=A --variants=B` — Alternative titles
+- `--label="..."` — Human-readable label
+- `--langcode=es` — Per-language scope (default `und` = all languages)
+- `--disabled` — Create as disabled
+- `--dry-run` — Preview without applying
+
+All commands output YAML. All state-changing commands support `--dry-run`.
+
+## Concepts
+
+- The original title is always tested as variant `v0` (read live).
+- Stored variants are `v1`, `v2`, etc.
+- Each `(path, langcode)` pair is its own experiment with independent
+ Thompson Sampling state.
+- Reward signal: user stayed on page 10 seconds (bounce-rate proxy).
+- Lookups are indexed; tested at 10K+ experiments per site.
+
+## Example
+
+```bash
+# A/B test the /blog page title with two alternatives.
+drush rl:page-title:create /blog \
+ --variants="News & Insights,Latest Articles" \
+ --label="Blog index test"
+
+# Check progress.
+drush rl:page-title:get
+
+# Disable temporarily.
+drush rl:page-title:update --disable
+
+# Delete + purge.
+drush rl:page-title:delete
+```
+
+The same admin UI is available at /admin/config/services/rl-page-title
+via Views.
diff --git a/modules/rl_page_title/.agents/skills/rl_page_title/agents/openai.yaml b/modules/rl_page_title/.agents/skills/rl_page_title/agents/openai.yaml
new file mode 100644
index 0000000..aa82a60
--- /dev/null
+++ b/modules/rl_page_title/.agents/skills/rl_page_title/agents/openai.yaml
@@ -0,0 +1,4 @@
+display_name: "RL Page Title"
+description: "A/B test Drupal page titles for any page (nodes, Views, custom controllers) using Thompson Sampling. Per-language scoped, manage via Drush CLI."
+default_prompt: "List all page title experiments and their current status."
+allow_implicit_invocation: true
diff --git a/modules/rl_page_title/.claude/skills/rl_page_title/SKILL.md b/modules/rl_page_title/.claude/skills/rl_page_title/SKILL.md
new file mode 100644
index 0000000..772b934
--- /dev/null
+++ b/modules/rl_page_title/.claude/skills/rl_page_title/SKILL.md
@@ -0,0 +1,133 @@
+---
+name: rl_page_title
+version: 1.0.0
+description: >
+ A/B test page titles for any Drupal page (nodes, Views displays, custom
+ controllers, any path) using Thompson Sampling. Per-language scoping.
+ Manage experiments via Drush CLI.
+triggers:
+ - /rl-page-title
+ - page title test
+ - title experiment
+ - title variant
+ - a/b test page title
+ - test page title
+ - rl_page_title
+---
+
+# RL Page Title — Drush CLI
+
+You are managing A/B testing experiments for Drupal page titles. The
+rl_page_title module is a content-entity-backed integration on top of
+the parent rl module's Thompson Sampling engine.
+
+## Preamble — Auto-discover Current State
+
+```bash
+# List all page title experiments with stats.
+drush rl:page-title:list --format=yaml
+
+# Filter to active experiments only.
+drush rl:page-title:list --enabled=yes --format=yaml
+```
+
+## Commands Reference
+
+| Command | Alias | Purpose |
+|---|---|---|
+| `rl:page-title:list` | `rl-ptl` | List experiments (`--enabled=yes\|no\|all`) |
+| `rl:page-title:get ` | `rl-ptg` | Show full details + live RL stats per arm |
+| `rl:page-title:create ` | `rl-ptc` | Create experiment (`--variants`, `--label`, `--langcode`, `--disabled`, `--dry-run`) |
+| `rl:page-title:update ` | `rl-ptu` | Update label / variants / enable / disable (`--dry-run`) |
+| `rl:page-title:delete ` | `rl-ptd` | Delete experiment AND purge RL analytics (`--dry-run`) |
+
+All state-changing commands support `--dry-run`. All commands output YAML.
+
+## Concepts
+
+- **Path**: the internal Drupal path to test (e.g., `/node/42`, `/blog`,
+ `/user/login`). Aliases are accepted and resolved on save.
+- **Variant**: an alternative title text. The original title (whatever
+ Drupal renders normally) is always tested as variant `v0`. Stored
+ variants are `v1`, `v2`, etc.
+- **Langcode**: experiments are scoped per language. Use `und` (the
+ default, `LANGCODE_NOT_SPECIFIED`) for "all languages". Lookup tries
+ language-specific match first, falls back to "all languages".
+- **Reward**: a successful outcome (the user stayed on the page for 10
+ seconds, default reward strategy). Tracked client-side via the
+ bundled JS.
+
+## Workflow Examples
+
+### Test alternatives for a node title
+
+```bash
+# Create with two alternatives. The original /node/42 title is v0.
+drush rl:page-title:create /node/42 \
+ --variants="Learn About Our Team,Meet ACME"
+
+# Wait for traffic, then check progress.
+drush rl:page-title:get
+
+# Pause without deleting if results are inconclusive.
+drush rl:page-title:update --disable
+
+# Delete and purge analytics when done.
+drush rl:page-title:delete
+```
+
+### Test a Views page title in Spanish only
+
+```bash
+drush rl:page-title:create /blog \
+ --variants="Nuestro Blog,Últimos Artículos" \
+ --langcode=es \
+ --label="Blog index (Spanish)"
+```
+
+### Use multiple --variants flags instead of comma-separated
+
+```bash
+drush rl:page-title:create /user/login \
+ --variants="Sign In" \
+ --variants="Log In to Continue" \
+ --variants="Welcome Back"
+```
+
+### Test a custom module page (the form page itself)
+
+```bash
+drush rl:page-title:create /node/add/article \
+ --variants="Create New Article,Write a New Story"
+```
+
+### Preview before applying
+
+```bash
+drush rl:page-title:create /blog \
+ --variants="One,Two" \
+ --dry-run
+```
+
+## How variants reach end users
+
+1. The module's preprocess_page_title hook intercepts the rendered title
+ for the matching path on every request.
+2. The Thompson Sampling selector picks a winning variant based on
+ accumulated turns and rewards.
+3. The bundled tracking JS records a turn on page load and a reward if
+ the user stays past the threshold (10s default).
+4. Tens of thousands of experiments scale via indexed `(path, langcode)`
+ lookups (content entities, not config).
+
+## Notes
+
+- The same page can have separate experiments per language, with
+ independent Thompson Sampling state.
+- Vertical tabs on entity edit forms also create page title experiments;
+ the Drush commands operate on the same content entities.
+- The admin UI is at `/admin/config/services/rl-page-title` (Views).
+- Analytics are managed by the parent `rl` module; use `drush rl:list`
+ and the `rl:experiment:*` commands to inspect raw turn/reward data.
+- Deleting an experiment via this command purges the RL analytics
+ tables (turns, rewards, snapshots, registry) atomically.
diff --git a/modules/rl_page_title/README.md b/modules/rl_page_title/README.md
new file mode 100644
index 0000000..0a95a31
--- /dev/null
+++ b/modules/rl_page_title/README.md
@@ -0,0 +1,140 @@
+# RL Page Title
+
+A/B test page titles for any page using Thompson Sampling. Source-agnostic:
+works for nodes, Views displays, custom controllers, and any path Drupal serves.
+
+## What it does
+
+You give it alternative page titles for a path. It rotates them across visits
+using Thompson Sampling, records impressions and engagement, and converges on
+the best-performing title. The A/B testing math lives in the parent `rl`
+module; this module is the integration layer that hooks into Drupal's title
+rendering and stores the variant configuration.
+
+## How it works
+
+### Storage
+
+A **content entity** per experiment, `rl_page_title_experiment`, stores:
+
+- `path` - the internal path being tested (e.g., `/node/42`, `/blog`,
+ `/user/login`). Aliases are resolved to internal paths on save.
+- `langcode` - the language scope (specific language code, or
+ `LANGCODE_NOT_SPECIFIED` for "all languages")
+- `variants_data` - JSON-encoded list of alternative title strings
+- `enabled` - whether the experiment is currently running
+
+Indexed lookups on `(path, langcode)` keep selector latency constant
+regardless of how many experiments exist on the site. Tested for sites with
+tens of thousands of experiments.
+
+The original title (whatever Drupal would normally render at that path) is
+always tested as **arm v0** and is read live - it is **not** stored on the
+experiment entity. Stored variants are arms v1, v2, ... vN.
+
+The RL experiment ID is a deterministic hash:
+`rl_page_title-{12-char-sha1-of-path-pipe-langcode}`. The hash includes the
+langcode so each language gets its own Thompson Sampling state -- an English
+experiment for `/blog` and a Spanish experiment for `/blog` accumulate
+separate turns and rewards.
+
+### Multilingual
+
+Each (path, langcode) pair is its own experiment row, mirroring the Redirect
+module's per-language redirects. The "all languages" fallback is langcode
+`LANGCODE_NOT_SPECIFIED`. Lookup at runtime tries the current request
+language first, then falls back to "all languages" if no language-specific
+experiment exists.
+
+We do **not** use Drupal's translation framework. Each language gets its
+own row with its own variants list, its own enabled flag, and its own
+analytics. This matches how Redirect handles multilingual.
+
+### Runtime
+
+1. Every page render is tagged with `rl_page_title:{path}` so creating an
+ experiment can invalidate cached pages immediately, even pages cached
+ before the experiment existed.
+2. `hook_preprocess_page_title()` and `hook_preprocess_html()` look up an
+ active experiment for the current internal path, ask the parent RL module
+ for Thompson Sampling scores, and override the title with the winning
+ variant. If the winning arm is `v0` (original), nothing is touched.
+3. `hook_page_attachments()` attaches the tracking JavaScript when an
+ experiment is active.
+4. `js/title-tracking.js` records a turn (impression) on page load and a
+ reward 10 seconds later (a bounce-rate proxy: if the user is still on the
+ page after 10 seconds, the variant kept them).
+5. Both events are POSTed via `navigator.sendBeacon()` to `rl.php`, the
+ parent RL module's tracking endpoint.
+
+### UX flows
+
+- **Node titles**: vertical tab "Title variants" on node edit forms, in the
+ advanced tabs group, with inline textarea editing.
+- **Any path** (Views displays, `/user/login`, `/admin/content`, custom
+ controllers): standalone admin form at
+ `/admin/config/services/rl-page-title/add` accepting any path.
+- **Editing**: same vertical tab on the entity form, or the admin list at
+ `/admin/config/services/rl-page-title`.
+- **Deletion**: dedicated delete confirmation form that purges the RL
+ analytics tables (turns, rewards, totals, snapshots, registry) before
+ removing the config entity. Purge happens first so a failure leaves the
+ config entity intact as a recovery anchor.
+
+The pattern is consistent with how the Redirect module handles in-context vs
+standalone editing - inline UX where there is an obvious entity context,
+standalone form for arbitrary paths.
+
+## Configuration
+
+There is no settings form. The reward strategy (10-second time-on-page) and
+the page cache TTL override (60 seconds while an experiment is active) are
+hardcoded as class constants. They can be promoted to module config in a
+follow-up if site builders need tuning.
+
+## Permissions
+
+- `administer rl page title experiments` - create, edit, delete experiments.
+ Restricted access.
+
+## Trash module compatibility
+
+On sites with the [Trash](https://www.drupal.org/project/trash) module
+enabled, deleting a node sends it to the trash rather than removing it
+from the database. The `hook_entity_predelete` cleanup that this module
+relies on for orphan removal **only fires when the trashed node is
+purged**, not on the initial soft-delete. This is correct semantically:
+the trashed node still has the same internal path, so the experiment
+remains valid until the node is permanently removed. Restored nodes
+keep their experiment intact. Purged nodes trigger the standard
+cleanup. Site builders running Trash should be aware that experiment
+rows linger in the admin list for as long as their target node sits in
+the trash bin.
+
+## Known limitations
+
+- **State divergence on the vertical tab path**: when the inline submit
+ handler runs (after the parent entity has been saved), an experiment write
+ failure is logged and surfaced to the user but does not roll back the
+ parent save. This is acceptable because experiment writes are
+ near-instantaneous and the failure is visible. A full fix would use the
+ entity-builder pattern.
+- **Vertical-tab retarget gap**: the inline form keys experiments by the
+ entity's *current* canonical URL. If a node's canonical URL changes
+ (Pathauto, manual alias edits) and you then re-edit the node via the
+ vertical tab, you may see a fresh experiment for the new path while the
+ old experiment for the old path becomes orphaned. The standalone
+ admin form correctly purges on retarget via `loadUnchanged()`. Use the
+ standalone form when retargeting an existing experiment.
+- **First-time experiment cache invalidation**: pages that have been served
+ before the rl_page_title module was installed will not carry the
+ `rl_page_title:{path}` cache tag and will not invalidate when an
+ experiment is created for them. They will refresh naturally when the page
+ cache expires. After installation, all subsequent renders carry the tag.
+
+## Tests
+
+Coverage is provided by the parent rl module's e2e tests under
+`scripts/e2e/`, which exercise install, experiment CRUD, and analytics
+end-to-end against a real Drupal site. Run via
+`docker compose --profile test run e2e-test` from the rl module root.
diff --git a/modules/rl_page_title/config/install/views.view.rl_page_title_experiment.yml b/modules/rl_page_title/config/install/views.view.rl_page_title_experiment.yml
new file mode 100644
index 0000000..c8f4799
--- /dev/null
+++ b/modules/rl_page_title/config/install/views.view.rl_page_title_experiment.yml
@@ -0,0 +1,558 @@
+langcode: en
+status: true
+dependencies:
+ module:
+ - rl_page_title
+ - user
+id: rl_page_title_experiment
+label: 'Reinforcement Learning page title experiments'
+module: views
+description: 'Admin list of page title experiments.'
+tag: ''
+base_table: rl_page_title_experiment
+base_field: id
+display:
+ default:
+ id: default
+ display_title: Default
+ display_plugin: default
+ position: 0
+ display_options:
+ access:
+ type: perm
+ options:
+ perm: 'administer rl page title experiments'
+ cache:
+ type: tag
+ options: { }
+ query:
+ type: views_query
+ options:
+ query_comment: ''
+ disable_sql_rewrite: false
+ distinct: false
+ replica: false
+ query_tags: { }
+ exposed_form:
+ type: basic
+ options:
+ submit_button: Filter
+ reset_button: true
+ reset_button_label: Reset
+ exposed_sorts_label: 'Sort by'
+ expose_sort_order: true
+ sort_asc_label: Asc
+ sort_desc_label: Desc
+ pager:
+ type: full
+ options:
+ items_per_page: 50
+ offset: 0
+ id: 0
+ total_pages: null
+ tags:
+ previous: ‹ Previous
+ next: 'Next ›'
+ first: '« First'
+ last: 'Last »'
+ expose:
+ items_per_page: false
+ items_per_page_label: 'Items per page'
+ items_per_page_options: '5, 10, 25, 50'
+ items_per_page_options_all: false
+ items_per_page_options_all_label: '- All -'
+ offset: false
+ offset_label: Offset
+ quantity: 9
+ style:
+ type: table
+ options:
+ grouping: { }
+ row_class: ''
+ default_row_class: true
+ columns:
+ label: label
+ path: path
+ langcode: langcode
+ enabled: enabled
+ operations: operations
+ default: '-1'
+ info:
+ label:
+ sortable: true
+ default_sort_order: asc
+ align: ''
+ separator: ''
+ empty_column: false
+ responsive: ''
+ path:
+ sortable: true
+ default_sort_order: asc
+ align: ''
+ separator: ''
+ empty_column: false
+ responsive: ''
+ langcode:
+ sortable: true
+ default_sort_order: asc
+ align: ''
+ separator: ''
+ empty_column: false
+ responsive: ''
+ enabled:
+ sortable: true
+ default_sort_order: asc
+ align: ''
+ separator: ''
+ empty_column: false
+ responsive: ''
+ operations:
+ sortable: false
+ default_sort_order: asc
+ align: ''
+ separator: ''
+ empty_column: false
+ responsive: ''
+ override: true
+ sticky: false
+ summary: ''
+ empty_table: true
+ caption: ''
+ description: ''
+ row:
+ type: fields
+ fields:
+ label:
+ id: label
+ table: rl_page_title_experiment
+ field: label
+ relationship: none
+ group_type: group
+ admin_label: ''
+ entity_type: rl_page_title_experiment
+ entity_field: label
+ plugin_id: field
+ label: Label
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: string
+ settings:
+ link_to_entity: false
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ path:
+ id: path
+ table: rl_page_title_experiment
+ field: path
+ relationship: none
+ group_type: group
+ admin_label: ''
+ entity_type: rl_page_title_experiment
+ entity_field: path
+ plugin_id: field
+ label: 'Page URL'
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: string
+ settings:
+ link_to_entity: false
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ langcode:
+ id: langcode
+ table: rl_page_title_experiment
+ field: langcode
+ relationship: none
+ group_type: group
+ admin_label: ''
+ entity_type: rl_page_title_experiment
+ entity_field: langcode
+ plugin_id: field_language
+ label: Language
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: language
+ settings:
+ link_to_entity: false
+ native_language: false
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ enabled:
+ id: enabled
+ table: rl_page_title_experiment
+ field: enabled
+ relationship: none
+ group_type: group
+ admin_label: ''
+ entity_type: rl_page_title_experiment
+ entity_field: enabled
+ plugin_id: field
+ label: Status
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ click_sort_column: value
+ type: boolean
+ settings:
+ format: custom
+ format_custom_false: Paused
+ format_custom_true: Active
+ group_column: value
+ group_columns: { }
+ group_rows: true
+ delta_limit: 0
+ delta_offset: 0
+ delta_reversed: false
+ delta_first_last: false
+ multi_type: separator
+ separator: ', '
+ field_api_classes: false
+ operations:
+ id: operations
+ table: rl_page_title_experiment
+ field: operations
+ relationship: none
+ group_type: group
+ admin_label: ''
+ entity_type: rl_page_title_experiment
+ plugin_id: entity_operations
+ label: Operations
+ exclude: false
+ alter:
+ alter_text: false
+ text: ''
+ make_link: false
+ path: ''
+ absolute: false
+ external: false
+ replace_spaces: false
+ path_case: none
+ trim_whitespace: false
+ alt: ''
+ rel: ''
+ link_class: ''
+ prefix: ''
+ suffix: ''
+ target: ''
+ nl2br: false
+ max_length: 0
+ word_boundary: true
+ ellipsis: true
+ more_link: false
+ more_link_text: ''
+ more_link_path: ''
+ strip_tags: false
+ trim: false
+ preserve_tags: ''
+ html: false
+ element_type: ''
+ element_class: ''
+ element_label_type: ''
+ element_label_class: ''
+ element_label_colon: false
+ element_wrapper_type: ''
+ element_wrapper_class: ''
+ element_default_classes: true
+ empty: ''
+ hide_empty: false
+ empty_zero: false
+ hide_alter_empty: true
+ destination: true
+ filters:
+ label:
+ id: label
+ table: rl_page_title_experiment
+ field: label
+ relationship: none
+ group_type: group
+ admin_label: ''
+ plugin_id: string
+ operator: contains
+ expose:
+ operator_id: label_op
+ label: Label
+ description: ''
+ use_operator: false
+ operator: label_op
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: label
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ placeholder: ''
+ is_grouped: false
+ path:
+ id: path
+ table: rl_page_title_experiment
+ field: path
+ relationship: none
+ group_type: group
+ admin_label: ''
+ plugin_id: string
+ operator: contains
+ expose:
+ operator_id: path_op
+ label: Path
+ description: ''
+ use_operator: false
+ operator: path_op
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: path
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ placeholder: ''
+ is_grouped: false
+ enabled:
+ id: enabled
+ table: rl_page_title_experiment
+ field: enabled
+ relationship: none
+ group_type: group
+ admin_label: ''
+ plugin_id: boolean
+ value: '1'
+ group: 1
+ expose:
+ operator_id: enabled_op
+ label: Status
+ description: ''
+ use_operator: false
+ operator: enabled_op
+ operator_limit_selection: false
+ operator_list: { }
+ identifier: enabled
+ required: false
+ remember: false
+ multiple: false
+ remember_roles:
+ authenticated: authenticated
+ is_grouped: false
+ sorts:
+ label:
+ id: label
+ table: rl_page_title_experiment
+ field: label
+ relationship: none
+ group_type: group
+ admin_label: ''
+ plugin_id: standard
+ order: ASC
+ exposed: false
+ expose:
+ label: ''
+ field_identifier: label
+ title: 'Reinforcement Learning page title experiments'
+ empty:
+ area_text_custom:
+ id: area_text_custom
+ table: views
+ field: area_text_custom
+ relationship: none
+ group_type: group
+ admin_label: ''
+ plugin_id: text_custom
+ empty: true
+ tokenize: false
+ content: "No experiments yet. The easiest way to start one is to edit any page and open the \"A/B test title\" tab on its edit form, or use \"Add experiment\" above to create one from scratch."
+ page_1:
+ id: page_1
+ display_title: 'Admin page'
+ display_plugin: page
+ position: 1
+ display_options:
+ display_extenders: { }
+ path: admin/config/services/rl-page-title
+ menu:
+ type: 'normal'
+ title: 'Reinforcement Learning page title experiments'
+ description: 'A/B test page titles using Thompson Sampling.'
+ weight: 0
+ expanded: false
+ parent: 'system.admin_config_services'
+ context: '0'
+ menu_name: 'admin'
diff --git a/modules/rl_page_title/drush.services.yml b/modules/rl_page_title/drush.services.yml
new file mode 100644
index 0000000..034e889
--- /dev/null
+++ b/modules/rl_page_title/drush.services.yml
@@ -0,0 +1,12 @@
+services:
+ rl_page_title.drush.commands:
+ class: Drupal\rl_page_title\Drush\Commands\RlPageTitleCommands
+ arguments:
+ - '@entity_type.manager'
+ - '@rl.experiment_registry'
+ - '@rl.experiment_manager'
+ - '@path_alias.manager'
+ - '@router.no_access_checks'
+ - '@title_resolver'
+ tags:
+ - { name: drush.command }
diff --git a/modules/rl_page_title/js/title-tracking.js b/modules/rl_page_title/js/title-tracking.js
new file mode 100644
index 0000000..8af4d30
--- /dev/null
+++ b/modules/rl_page_title/js/title-tracking.js
@@ -0,0 +1,62 @@
+/**
+ * @file
+ * Client-side tracking for RL Page Title experiments.
+ *
+ * Records a turn (impression) on page load and a reward after the user has
+ * stayed on the page for 10 seconds (a bounce-rate proxy).
+ *
+ * Each page load records exactly one turn and (if the user stays long enough)
+ * one reward. There is no per-session dedupe - every visit is its own event.
+ * That keeps the conversion rate observed by Thompson Sampling unbiased
+ * across repeated visits.
+ *
+ * Per-page-load dedupe is provided by once() and a window-scoped flag so we
+ * cannot record more than one event for the same arm on the same page load
+ * even if Drupal.attachBehaviors is invoked multiple times.
+ */
+
+(function (Drupal, drupalSettings, once) {
+
+ 'use strict';
+
+ Drupal.behaviors.rlPageTitleTracking = {
+ attach: function (context) {
+ if (!drupalSettings.rlPageTitle) {
+ return;
+ }
+
+ once('rl-page-title-tracking', 'body', context).forEach(function () {
+ var settings = drupalSettings.rlPageTitle;
+ var experimentId = settings.experimentId;
+ var armId = settings.armId;
+ var endpointUrl = settings.rlEndpointUrl;
+
+ // Record turn (impression).
+ var turnData = new FormData();
+ turnData.append('action', 'turn');
+ turnData.append('experiment_id', experimentId);
+ turnData.append('arm_id', armId);
+ navigator.sendBeacon(endpointUrl, turnData);
+
+ // Record reward after 10 seconds (bounce-rate proxy). No
+ // sessionStorage gate: every page load that crosses the threshold
+ // emits a reward, which is the correct signal for Thompson Sampling.
+ // The window-scoped flag below only prevents duplicate rewards from
+ // the same page load (e.g., if attachBehaviors fires twice).
+ var pageLoadFlag = '__rl_pt_rewarded_' + experimentId + '_' + armId;
+ setTimeout(function () {
+ if (window[pageLoadFlag]) {
+ return;
+ }
+ window[pageLoadFlag] = true;
+ var rewardData = new FormData();
+ rewardData.append('action', 'reward');
+ rewardData.append('experiment_id', experimentId);
+ rewardData.append('arm_id', armId);
+ navigator.sendBeacon(endpointUrl, rewardData);
+ }, 10000);
+ });
+ }
+ };
+
+})(Drupal, drupalSettings, once);
diff --git a/modules/rl_page_title/rl_page_title.info.yml b/modules/rl_page_title/rl_page_title.info.yml
new file mode 100644
index 0000000..677fd47
--- /dev/null
+++ b/modules/rl_page_title/rl_page_title.info.yml
@@ -0,0 +1,7 @@
+name: 'Reinforcement Learning Page Title'
+type: module
+description: 'A/B test page titles for any page using Thompson Sampling. Source-agnostic: works for nodes, Views pages, custom controllers, and any path.'
+core_version_requirement: ^10.3 | ^11
+package: Custom
+dependencies:
+ - rl:rl
diff --git a/modules/rl_page_title/rl_page_title.libraries.yml b/modules/rl_page_title/rl_page_title.libraries.yml
new file mode 100644
index 0000000..e78c6bd
--- /dev/null
+++ b/modules/rl_page_title/rl_page_title.libraries.yml
@@ -0,0 +1,7 @@
+tracking:
+ js:
+ js/title-tracking.js: {}
+ dependencies:
+ - core/drupal
+ - core/drupalSettings
+ - core/once
diff --git a/modules/rl_page_title/rl_page_title.links.action.yml b/modules/rl_page_title/rl_page_title.links.action.yml
new file mode 100644
index 0000000..4a1b921
--- /dev/null
+++ b/modules/rl_page_title/rl_page_title.links.action.yml
@@ -0,0 +1,5 @@
+rl_page_title.add:
+ route_name: entity.rl_page_title_experiment.add_form
+ title: 'Add experiment'
+ appears_on:
+ - view.rl_page_title_experiment.page_1
diff --git a/modules/rl_page_title/rl_page_title.module b/modules/rl_page_title/rl_page_title.module
new file mode 100644
index 0000000..09ce9d1
--- /dev/null
+++ b/modules/rl_page_title/rl_page_title.module
@@ -0,0 +1,631 @@
+toString();
+ return '' . t('A/B test page titles for any page using Thompson Sampling. Source-agnostic: works for nodes, Views displays, custom controllers, and any path. Manage experiments at Reinforcement Learning page title experiments.', [
+ ':url' => $url,
+ ]) . '
';
+ }
+}
+
+/**
+ * Implements hook_preprocess_page_title().
+ *
+ * Captures the original title text in a per-request static so the
+ * preprocess_html hook can do a targeted substitution within whatever
+ * structure the head_title has been built into (which may already have
+ * been mutated by metatag, dxpr_seo, etc.).
+ */
+function rl_page_title_preprocess_page_title(array &$variables) {
+ $result = _rl_page_title_get_active_variant();
+ if (!$result || $result['text'] === NULL) {
+ return;
+ }
+ $original = (string) $variables['title'];
+ if ($original !== '') {
+ $captured = &drupal_static('rl_page_title_original', NULL);
+ $captured = $original;
+ }
+ $variables['title'] = $result['text'];
+}
+
+/**
+ * Implements hook_preprocess_html().
+ *
+ * Replaces the page-name portion of the tag with the winning
+ * variant while preserving the site name suffix and any other content
+ * upstream modules added.
+ *
+ * Why a targeted substitution rather than overwriting head_title.title:
+ *
+ * - In a vanilla Drupal site, head_title is a multi-key array like
+ * `['title' => 'Home', 'name' => 'DXPR CMS']` joined by ` | `.
+ * Replacing the `title` value gives "Welcome | DXPR CMS" naturally.
+ * - With Metatag installed, metatag_preprocess_html collapses the array
+ * into a single key with the FULL consolidated string (including the
+ * site name), e.g. `['title' => 'Home | DXPR CMS']`. Replacing the
+ * value here would drop the site name.
+ *
+ * Doing a substring replacement of the original page title within
+ * whatever string the upstream module produced handles both cases:
+ * "Home" -> "Welcome" inside "Home" yields "Welcome", and the same
+ * substitution inside "Home | DXPR CMS" yields "Welcome | DXPR CMS".
+ */
+function rl_page_title_preprocess_html(array &$variables) {
+ $result = _rl_page_title_get_active_variant();
+ if (!$result || $result['text'] === NULL) {
+ return;
+ }
+ if (!isset($variables['head_title']['title'])) {
+ return;
+ }
+
+ // Resolve the original title text. Prefer the value captured during
+ // preprocess_page_title; fall back to the title resolver for pages
+ // without a rendered page title block.
+ $original = drupal_static('rl_page_title_original', NULL);
+ if ($original === NULL || $original === '') {
+ $original = _rl_page_title_resolve_route_title();
+ }
+ if ($original === NULL || $original === '') {
+ return;
+ }
+
+ $variables['head_title']['title'] = _rl_page_title_replace_head_title(
+ $variables['head_title']['title'],
+ $original,
+ $result['text']
+ );
+}
+
+/**
+ * Replace the page-name portion of the head title structure.
+ *
+ * @param mixed $existing
+ * The current head_title.title value (string, render array, MarkupInterface).
+ * @param string $original
+ * The original page title text to look for inside $existing.
+ * @param string $replacement
+ * The variant text to substitute for $original.
+ *
+ * @return mixed
+ * A value of the same general shape as $existing.
+ */
+function _rl_page_title_replace_head_title($existing, string $original, string $replacement) {
+ // Render array case: keep the structure but replace the inner text where
+ // we can find it. Fall back to a plain_text element if the array does
+ // not expose a known text field.
+ if (is_array($existing)) {
+ if (isset($existing['#markup'])) {
+ $existing['#markup'] = _rl_page_title_substitute((string) $existing['#markup'], $original, $replacement);
+ return $existing;
+ }
+ if (isset($existing['#plain_text'])) {
+ $existing['#plain_text'] = _rl_page_title_substitute((string) $existing['#plain_text'], $original, $replacement);
+ return $existing;
+ }
+ return ['#plain_text' => $replacement];
+ }
+
+ // MarkupInterface case (TranslatableMarkup, FormattableMarkup, etc.). We
+ // cannot mutate these objects in place; the safe replacement is a Markup
+ // wrapper around the substituted string.
+ if ($existing instanceof MarkupInterface) {
+ return Markup::create(_rl_page_title_substitute((string) $existing, $original, $replacement));
+ }
+
+ // Plain string case.
+ return _rl_page_title_substitute((string) $existing, $original, $replacement);
+}
+
+/**
+ * Substitute the first occurrence of $original with $replacement.
+ *
+ * Falls back to the replacement value if the original cannot be found
+ * in the haystack (defensive default rather than leaving the title
+ * untouched, since the caller has already determined that an
+ * experiment is active for this page).
+ */
+function _rl_page_title_substitute(string $haystack, string $original, string $replacement): string {
+ $position = strpos($haystack, $original);
+ if ($position === FALSE) {
+ return $replacement;
+ }
+ return substr_replace($haystack, $replacement, $position, strlen($original));
+}
+
+/**
+ * Resolve the original route title for the current request.
+ *
+ * Used as a fallback when preprocess_page_title did not run (e.g., the
+ * page title block was not placed on the page).
+ */
+function _rl_page_title_resolve_route_title(): ?string {
+ try {
+ $request = \Drupal::request();
+ $route = \Drupal::routeMatch()->getRouteObject();
+ if (!$route) {
+ return NULL;
+ }
+ $title = \Drupal::service('title_resolver')->getTitle($request, $route);
+ if ($title === NULL) {
+ return NULL;
+ }
+ return is_string($title) ? $title : (string) $title;
+ }
+ catch (\Exception $e) {
+ return NULL;
+ }
+}
+
+/**
+ * Implements hook_page_attachments().
+ *
+ * Always attaches a path-scoped cache tag, regardless of whether an
+ * experiment exists yet. If a page is cached BEFORE an experiment is created
+ * for it, the tag must already be there for invalidation to work.
+ */
+function rl_page_title_page_attachments(array &$attachments) {
+ $internal_path = _rl_page_title_current_internal_path();
+ $attachments['#cache']['tags'][] = 'rl_page_title:' . $internal_path;
+ $attachments['#cache']['tags'][] = 'rl_page_title:all';
+
+ $result = _rl_page_title_get_active_variant();
+ if (!$result) {
+ return;
+ }
+
+ $rl_path = \Drupal::service('extension.list.module')->getPath('rl');
+ $base_path = \Drupal::request()->getBasePath();
+
+ $attachments['#attached']['library'][] = 'rl_page_title/tracking';
+ $attachments['#attached']['drupalSettings']['rlPageTitle'] = [
+ 'experimentId' => $result['experiment_id'],
+ 'armId' => $result['arm_id'],
+ 'rlEndpointUrl' => $base_path . '/' . $rl_path . '/rl.php',
+ ];
+}
+
+/**
+ * Get the active variant for the current page (cached per request).
+ */
+function _rl_page_title_get_active_variant(): ?array {
+ return \Drupal::service('rl_page_title.variant_selector')->selectForCurrentPage();
+}
+
+/**
+ * Get the canonical internal path for the current request.
+ */
+function _rl_page_title_current_internal_path(): string {
+ return PageTitleExperiment::normalizePath(\Drupal::service('path.current')->getPath());
+}
+
+/**
+ * Implements hook_form_alter().
+ *
+ * Adds the page title variants vertical tab to content entity edit forms
+ * for entities that have a canonical link template. Uses the entity's
+ * current translation language so the experiment is scoped to that
+ * language; an English node and its Spanish translation get separate
+ * experiments via the same vertical tab.
+ */
+function rl_page_title_form_alter(array &$form, FormStateInterface $form_state, $form_id) {
+ $form_object = $form_state->getFormObject();
+ if (!($form_object instanceof ContentEntityFormInterface)) {
+ return;
+ }
+ $operation = $form_object->getOperation();
+ if (!in_array($operation, ['default', 'edit'], TRUE)) {
+ return;
+ }
+
+ $entity = $form_object->getEntity();
+ if (!$entity instanceof ContentEntityInterface || $entity->isNew()) {
+ return;
+ }
+ if (!$entity->hasLinkTemplate('canonical')) {
+ return;
+ }
+ if ($entity->getEntityTypeId() === 'rl_page_title_experiment') {
+ return;
+ }
+ // Skip entities whose "canonical" URL is actually their edit form (e.g.
+ // menu_link_content). For those entities there is no public page whose
+ // title could meaningfully be tested, and without this check the tab
+ // would appear on the menu link edit form next to the rl_menu_link tab
+ // and testing a nonsensical admin path.
+ if ($entity->hasLinkTemplate('edit-form')) {
+ try {
+ $canonical_url = $entity->toUrl('canonical')->toString();
+ $edit_url = $entity->toUrl('edit-form')->toString();
+ if ($canonical_url === $edit_url) {
+ return;
+ }
+ }
+ catch (\Exception $e) {
+ return;
+ }
+ }
+ if (!\Drupal::currentUser()->hasPermission('administer rl page title experiments')) {
+ return;
+ }
+
+ try {
+ $internal_path = PageTitleExperiment::normalizePath('/' . $entity->toUrl('canonical')->getInternalPath());
+ }
+ catch (\Exception $e) {
+ return;
+ }
+
+ // Use the entity's current translation language for the experiment scope.
+ $entity_langcode = $entity->language()->getId() ?: LanguageInterface::LANGCODE_NOT_SPECIFIED;
+
+ $existing = NULL;
+ $matches = \Drupal::entityTypeManager()
+ ->getStorage('rl_page_title_experiment')
+ ->loadByProperties([
+ 'path' => $internal_path,
+ 'langcode' => $entity_langcode,
+ ]);
+ if ($matches) {
+ $candidate = reset($matches);
+ if ($candidate instanceof PageTitleExperiment) {
+ $existing = $candidate;
+ }
+ }
+
+ $is_multilingual = \Drupal::languageManager()->isMultilingual();
+ $language_name = $entity_langcode === LanguageInterface::LANGCODE_NOT_SPECIFIED
+ ? t('all languages')
+ : (\Drupal::languageManager()->getLanguage($entity_langcode)?->getName() ?? $entity_langcode);
+
+ $baseline_title = (string) ($entity->label() ?? '');
+
+ $form['rl_page_title'] = [
+ '#type' => 'details',
+ '#title' => t('A/B test title'),
+ '#group' => 'advanced',
+ '#open' => FALSE,
+ '#weight' => 50,
+ '#tree' => TRUE,
+ '#access' => TRUE,
+ ];
+
+ $form['rl_page_title']['baseline'] = [
+ '#type' => 'item',
+ '#title' => t('Current title'),
+ '#markup' => '' . Html::escape($baseline_title) . '
',
+ ];
+
+ if ($existing) {
+ $rl_experiment_id = $existing->getRlExperimentId();
+ $experiment_manager = \Drupal::service('rl.experiment_manager');
+ $total_turns = $experiment_manager->getTotalTurns($rl_experiment_id);
+ $arms = $experiment_manager->getAllArmsData($rl_experiment_id);
+ $total_rewards = 0;
+ $leading_arm = NULL;
+ $leading_rate = -1.0;
+ // Require at least this many turns per arm before the arm is eligible
+ // to be displayed as the current leader. Below this threshold the
+ // observed rate is dominated by sampling noise (a lucky 2-for-2 start
+ // reads as 100 % but says nothing about the true rate).
+ $min_per_arm_for_leader = 10;
+ foreach ($arms as $arm) {
+ $turns = (int) $arm->turns;
+ $rewards = (int) $arm->rewards;
+ $total_rewards += $rewards;
+ if ($turns >= $min_per_arm_for_leader) {
+ $rate = $rewards / $turns;
+ if ($rate > $leading_rate) {
+ $leading_rate = $rate;
+ $leading_arm = (string) $arm->arm_id;
+ }
+ }
+ }
+
+ $leading_text = NULL;
+ if ($leading_arm !== NULL) {
+ if ($leading_arm === 'v0') {
+ $leading_text = $baseline_title;
+ }
+ else {
+ $index = (int) substr($leading_arm, 1) - 1;
+ $variants = $existing->getVariants();
+ if (isset($variants[$index])) {
+ $leading_text = (string) $variants[$index];
+ }
+ }
+ }
+
+ $state_line = $existing->isPublished()
+ ? t('This test is running.')
+ : t('This test is paused. It will not show variants to visitors until you turn it back on.');
+
+ if ($is_multilingual) {
+ $state_line = $existing->isPublished()
+ ? t('This test is running in @lang.', ['@lang' => $language_name])
+ : t('This test is paused in @lang. It will not show variants to visitors until you turn it back on.', ['@lang' => $language_name]);
+ }
+
+ if ($total_turns === 0) {
+ $progress_line = t('No visitors have seen this test yet. Save the page and come back after some traffic arrives.');
+ }
+ elseif ($total_turns < 10 || $leading_text === NULL) {
+ $progress_line = t('Still collecting data. @turns visitors have seen a variant so far.', [
+ '@turns' => number_format($total_turns),
+ ]);
+ }
+ else {
+ if ($leading_arm === 'v0') {
+ $progress_line = t('Your original title is currently ahead. @turns visitors, @rewards read the page.', [
+ '@turns' => number_format($total_turns),
+ '@rewards' => number_format($total_rewards),
+ ]);
+ }
+ else {
+ $progress_line = t('"@alt" is currently ahead of your original. @turns visitors, @rewards read the page.', [
+ '@alt' => $leading_text,
+ '@turns' => number_format($total_turns),
+ '@rewards' => number_format($total_rewards),
+ ]);
+ }
+ }
+
+ $form['rl_page_title']['state'] = [
+ '#type' => 'item',
+ '#markup' => '' . $state_line . '
' . $progress_line . '
',
+ ];
+
+ if ($total_turns > 0) {
+ $form['rl_page_title']['report'] = [
+ '#type' => 'link',
+ '#title' => t('See the full report'),
+ '#url' => Url::fromRoute('rl.reports.experiment_detail', ['experiment_id' => $rl_experiment_id]),
+ '#attributes' => ['target' => '_blank'],
+ ];
+ }
+ }
+ else {
+ $intro = $is_multilingual
+ ? t('Not sure if your title is pulling its weight? Add a few alternatives below and we will show each one to a share of visitors reading this page in @lang, then tell you which one brings in the most engaged readers.', [
+ '@lang' => $language_name,
+ ])
+ : t('Not sure if your title is pulling its weight? Add a few alternatives below and we will show each one to a share of visitors, then tell you which one brings in the most engaged readers.');
+
+ $form['rl_page_title']['intro'] = [
+ '#type' => 'item',
+ '#markup' => '' . $intro . '
',
+ ];
+ }
+
+ $form['rl_page_title']['variants'] = [
+ '#type' => 'textarea',
+ '#title' => $existing ? t('Alternatives you are testing') : t('Try these alternative titles'),
+ '#description' => t('Enter one alternative per line. Your current title (shown above) always stays in the test.'),
+ '#default_value' => $existing ? implode("\n", $existing->getVariants()) : '',
+ '#rows' => 4,
+ ];
+
+ $form['rl_page_title']['enabled'] = [
+ '#type' => 'checkbox',
+ '#title' => t('Run this test'),
+ '#description' => t('Turn off to pause. Your collected data is kept safe.'),
+ '#default_value' => $existing ? (bool) $existing->isPublished() : TRUE,
+ ];
+
+ // Explicit "Stop testing" affordance for an existing experiment. This
+ // replaces the old "leave the textarea empty to silently delete"
+ // shortcut, which was a foot-gun: a user clearing the field by accident
+ // would destroy all their collected data on the next save.
+ if ($existing) {
+ $form['rl_page_title']['remove_link'] = [
+ '#type' => 'link',
+ '#title' => t('Stop testing and delete collected data'),
+ '#url' => $existing->toUrl('delete-form'),
+ '#attributes' => ['class' => ['button', 'button--small', 'button--danger']],
+ '#prefix' => '',
+ '#suffix' => '
',
+ ];
+ }
+
+ $form['rl_page_title']['_path'] = [
+ '#type' => 'value',
+ '#value' => $internal_path,
+ ];
+ $form['rl_page_title']['_langcode'] = [
+ '#type' => 'value',
+ '#value' => $entity_langcode,
+ ];
+ if ($existing) {
+ $form['rl_page_title']['_existing_id'] = [
+ '#type' => 'value',
+ '#value' => $existing->id(),
+ ];
+ }
+
+ if (isset($form['actions']) && is_array($form['actions'])) {
+ foreach (Element::children($form['actions']) as $action_key) {
+ if (isset($form['actions'][$action_key]['#type']) && $form['actions'][$action_key]['#type'] === 'submit') {
+ $form['actions'][$action_key]['#submit'][] = '_rl_page_title_entity_form_submit';
+ }
+ }
+ }
+}
+
+/**
+ * Submit handler for the vertical tab on entity forms.
+ */
+function _rl_page_title_entity_form_submit(array &$form, FormStateInterface $form_state) {
+ $values = $form_state->getValue('rl_page_title');
+ if (empty($values)) {
+ return;
+ }
+
+ $internal_path = $values['_path'] ?? NULL;
+ $langcode = $values['_langcode'] ?? LanguageInterface::LANGCODE_NOT_SPECIFIED;
+ if (!$internal_path) {
+ return;
+ }
+
+ $variants = VariantParser::parse((string) ($values['variants'] ?? ''));
+ $storage = \Drupal::entityTypeManager()->getStorage('rl_page_title_experiment');
+ $existing_id = $values['_existing_id'] ?? NULL;
+ $experiment = NULL;
+ if ($existing_id) {
+ $loaded = $storage->load($existing_id);
+ if ($loaded instanceof PageTitleExperiment) {
+ $experiment = $loaded;
+ }
+ }
+
+ try {
+ if (empty($variants)) {
+ // A blank textarea does not delete: deletion happens through the
+ // dedicated "Stop testing and delete collected data" link so an
+ // accidentally cleared field can never destroy collected data. For
+ // a new experiment (no existing row) we simply do nothing. For an
+ // existing experiment we warn that the parent entity was saved but
+ // the title variants were left unchanged.
+ if ($experiment !== NULL) {
+ \Drupal::messenger()->addWarning(t('Title variants were left unchanged because no alternatives were provided. To stop this experiment, use "Stop testing and delete collected data".'));
+ }
+ return;
+ }
+
+ if ($experiment === NULL) {
+ $form_object = $form_state->getFormObject();
+ $label = $internal_path;
+ if ($form_object instanceof EntityFormInterface) {
+ $parent = $form_object->getEntity();
+ $label = $parent->label() ?? $internal_path;
+ }
+ $created = $storage->create([
+ 'label' => $label,
+ 'path' => $internal_path,
+ 'langcode' => $langcode,
+ ]);
+ assert($created instanceof PageTitleExperiment);
+ $experiment = $created;
+ }
+
+ $experiment->setVariants($variants);
+ if (!empty($values['enabled'])) {
+ $experiment->setPublished();
+ }
+ else {
+ $experiment->setUnpublished();
+ }
+ $experiment->save();
+
+ \Drupal::service('rl.experiment_registry')->register(
+ $experiment->getRlExperimentId(),
+ 'rl_page_title',
+ $experiment->label()
+ );
+
+ Cache::invalidateTags(['rl_page_title:' . $experiment->getPath()]);
+
+ \Drupal::messenger()->addStatus(t('Title variants saved. @n alternatives will be rotated against your original title.', [
+ '@n' => count($variants),
+ ]));
+ }
+ catch (\Exception $e) {
+ \Drupal::logger('rl_page_title')->error('Failed to save title variants from entity form: @message', ['@message' => $e->getMessage()]);
+ \Drupal::messenger()->addError(t('Title variants could not be saved: @message', ['@message' => $e->getMessage()]));
+ }
+}
+
+/**
+ * Implements hook_entity_predelete().
+ *
+ * Cleans up page title experiments when the target entity is deleted, and
+ * also purges the corresponding RL analytics tables. All language scopes
+ * for the deleted entity's path are cleaned up at once.
+ */
+function rl_page_title_entity_predelete(EntityInterface $entity) {
+ if (!$entity instanceof ContentEntityInterface) {
+ return;
+ }
+ if (!$entity->hasLinkTemplate('canonical')) {
+ return;
+ }
+ if ($entity->getEntityTypeId() === 'rl_page_title_experiment') {
+ return;
+ }
+
+ try {
+ $internal_path = PageTitleExperiment::normalizePath('/' . $entity->toUrl('canonical')->getInternalPath());
+ }
+ catch (\Exception $e) {
+ return;
+ }
+
+ $storage = \Drupal::entityTypeManager()->getStorage('rl_page_title_experiment');
+ $matches = $storage->loadByProperties(['path' => $internal_path]);
+ if (!$matches) {
+ return;
+ }
+ $manager = \Drupal::service('rl.experiment_manager');
+ foreach ($matches as $experiment) {
+ if (!$experiment instanceof PageTitleExperiment) {
+ continue;
+ }
+ $rl_id = $experiment->getRlExperimentId();
+ $manager->purgeExperiment($rl_id);
+ $experiment->delete();
+ }
+}
+
+/**
+ * Implements hook_preprocess_views_view_field().
+ *
+ * Rewrites the Label cell in the page title experiment admin Views table
+ * so it links to the experiment report instead of the entity edit form.
+ * The report is the more useful destination for someone scanning the
+ * list: it shows per-arm statistics. The Operations column still
+ * provides Edit/Delete for entity-level actions.
+ */
+function rl_page_title_preprocess_views_view_field(array &$variables): void {
+ if ($variables['view']->id() !== 'rl_page_title_experiment') {
+ return;
+ }
+ if ($variables['field']->field !== 'label') {
+ return;
+ }
+ $entity = $variables['row']->_entity ?? NULL;
+ if (!$entity instanceof PageTitleExperiment) {
+ return;
+ }
+ $variables['output'] = [
+ '#type' => 'link',
+ '#title' => $entity->label(),
+ '#url' => Url::fromRoute('rl.reports.experiment_detail', [
+ 'experiment_id' => $entity->getRlExperimentId(),
+ ]),
+ ];
+}
diff --git a/modules/rl_page_title/rl_page_title.permissions.yml b/modules/rl_page_title/rl_page_title.permissions.yml
new file mode 100644
index 0000000..37541d5
--- /dev/null
+++ b/modules/rl_page_title/rl_page_title.permissions.yml
@@ -0,0 +1,4 @@
+administer rl page title experiments:
+ title: 'Administer Reinforcement Learning page title experiments'
+ description: 'Create, edit, and delete page title A/B test experiments.'
+ restrict access: true
diff --git a/modules/rl_page_title/rl_page_title.services.yml b/modules/rl_page_title/rl_page_title.services.yml
new file mode 100644
index 0000000..9f5cdc5
--- /dev/null
+++ b/modules/rl_page_title/rl_page_title.services.yml
@@ -0,0 +1,17 @@
+services:
+ rl_page_title.variant_selector:
+ class: Drupal\rl_page_title\Service\TitleVariantSelector
+ arguments:
+ - '@entity_type.manager'
+ - '@rl.experiment_manager'
+ - '@rl.cache_manager'
+ - '@rl.experiment_registry'
+ - '@path.current'
+ - '@language_manager'
+
+ rl_page_title.decorator:
+ class: Drupal\rl_page_title\Decorator\PageTitleDecorator
+ arguments:
+ - '@entity_type.manager'
+ tags:
+ - { name: rl_experiment_decorator }
diff --git a/modules/rl_page_title/src/Decorator/PageTitleDecorator.php b/modules/rl_page_title/src/Decorator/PageTitleDecorator.php
new file mode 100644
index 0000000..75dec8b
--- /dev/null
+++ b/modules/rl_page_title/src/Decorator/PageTitleDecorator.php
@@ -0,0 +1,66 @@
+language()->getId();
+ $lang_label = $langcode === LanguageInterface::LANGCODE_NOT_SPECIFIED
+ ? (string) t('all languages')
+ : $langcode;
+ return [
+ '#type' => 'inline_template',
+ '#template' => '{{ label }} ({{ path }}, {{ lang }})',
+ '#context' => [
+ 'label' => $experiment->label() ?: $experiment->getPath(),
+ 'path' => $experiment->getPath(),
+ 'lang' => $lang_label,
+ ],
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function buildOriginalArmDisplay(VariantExperimentInterface $experiment): array {
+ return [
+ '#type' => 'inline_template',
+ '#template' => '{{ "(original title)"|t }}',
+ ];
+ }
+
+}
diff --git a/modules/rl_page_title/src/Drush/Commands/RlPageTitleCommands.php b/modules/rl_page_title/src/Drush/Commands/RlPageTitleCommands.php
new file mode 100644
index 0000000..bf6c205
--- /dev/null
+++ b/modules/rl_page_title/src/Drush/Commands/RlPageTitleCommands.php
@@ -0,0 +1,419 @@
+router->match($internal_path);
+ }
+ catch (\Exception $e) {
+ return NULL;
+ }
+
+ // Prefer entity label when the route binds a content entity parameter.
+ foreach ($match as $value) {
+ if ($value instanceof ContentEntityInterface) {
+ $label = $value->label();
+ if ($label !== NULL && $label !== '') {
+ return (string) $label;
+ }
+ }
+ }
+
+ // Otherwise resolve the route's title (covers /admin, /user/login, etc.).
+ try {
+ $route = $match['_route_object'] ?? NULL;
+ if ($route === NULL) {
+ return NULL;
+ }
+ $request = Request::create($internal_path);
+ $title = $this->titleResolver->getTitle($request, $route);
+ if ($title === NULL || $title === '') {
+ return NULL;
+ }
+ return (string) $title;
+ }
+ catch (\Exception $e) {
+ return NULL;
+ }
+ }
+
+ /**
+ * Lists all page title experiments.
+ */
+ #[CLI\Command(name: 'rl:page-title:list', aliases: ['rl-ptl'])]
+ #[CLI\Help(description: '[YAML] List all page title experiments with their target paths and language scope.')]
+ #[CLI\Option(name: 'enabled', description: 'Filter by enabled status: yes, no, all')]
+ #[CLI\Usage(name: 'drush rl:page-title:list', description: 'List all experiments')]
+ #[CLI\Usage(name: 'drush rl-ptl --enabled=yes', description: 'List only active experiments')]
+ public function list(array $options = ['enabled' => 'all']): string {
+ $this->switchToAdmin();
+
+ $storage = $this->entityTypeManager->getStorage('rl_page_title_experiment');
+ $properties = [];
+ if ($options['enabled'] === 'yes') {
+ $properties['enabled'] = TRUE;
+ }
+ elseif ($options['enabled'] === 'no') {
+ $properties['enabled'] = FALSE;
+ }
+
+ $entities = $properties ? $storage->loadByProperties($properties) : $storage->loadMultiple();
+ $items = [];
+ foreach ($entities as $entity) {
+ if (!$entity instanceof PageTitleExperiment) {
+ continue;
+ }
+ $rl_id = $entity->getRlExperimentId();
+ $items[] = [
+ 'id' => $entity->id(),
+ 'label' => $entity->label(),
+ 'path' => $entity->getPath(),
+ 'langcode' => $entity->language()->getId(),
+ 'variants' => count($entity->getVariants()) + 1,
+ 'enabled' => (bool) $entity->isPublished(),
+ 'rl_experiment_id' => $rl_id,
+ 'impressions' => $this->experimentManager->getTotalTurns($rl_id),
+ ];
+ }
+
+ return $this->successList($items);
+ }
+
+ /**
+ * Shows full details for one page title experiment.
+ */
+ #[CLI\Command(name: 'rl:page-title:get', aliases: ['rl-ptg'])]
+ #[CLI\Help(description: '[YAML] Show full details for one page title experiment, including variants and live RL stats.')]
+ #[CLI\Argument(name: 'experimentId', description: 'The page title experiment entity ID')]
+ #[CLI\Usage(name: 'drush rl:page-title:get 42', description: 'Show experiment 42')]
+ public function get(string $experimentId): string {
+ $this->switchToAdmin();
+
+ $entity = $this->entityTypeManager->getStorage('rl_page_title_experiment')->load($experimentId);
+ if (!$entity instanceof PageTitleExperiment) {
+ return $this->error(sprintf('Page title experiment "%s" not found.', $experimentId));
+ }
+
+ $rl_id = $entity->getRlExperimentId();
+ $arms = [];
+ foreach ($this->experimentManager->getAllArmsData($rl_id) as $arm) {
+ $arms[$arm->arm_id] = [
+ 'turns' => (int) $arm->turns,
+ 'rewards' => (int) $arm->rewards,
+ 'rate' => $arm->turns > 0 ? round(($arm->rewards / $arm->turns) * 100, 2) : 0.0,
+ ];
+ }
+
+ return $this->yaml([
+ 'id' => $entity->id(),
+ 'label' => $entity->label(),
+ 'path' => $entity->getPath(),
+ 'langcode' => $entity->language()->getId(),
+ 'enabled' => (bool) $entity->isPublished(),
+ 'variants' => $entity->getVariants(),
+ 'rl_experiment_id' => $rl_id,
+ 'analytics' => [
+ 'total_turns' => $this->experimentManager->getTotalTurns($rl_id),
+ 'arms' => $arms,
+ ],
+ ]);
+ }
+
+ /**
+ * Creates a new page title experiment.
+ */
+ #[CLI\Command(name: 'rl:page-title:create', aliases: ['rl-ptc'])]
+ #[CLI\Help(description: '[YAML] Create a new page title A/B test experiment.')]
+ #[CLI\Argument(name: 'path', description: 'Internal path or alias to test (e.g. /node/42, /blog, /user/login)')]
+ #[CLI\Option(name: 'variants', description: 'Comma-separated alternative titles, OR multiple --variants=text flags')]
+ #[CLI\Option(name: 'label', description: 'Human-readable label (default: derived from path)')]
+ #[CLI\Option(name: 'langcode', description: 'Language code, or "und" for all languages (default: und)')]
+ #[CLI\Option(name: 'disabled', description: 'Create as disabled instead of active')]
+ #[CLI\Option(name: 'dry-run', description: 'Preview without creating')]
+ #[CLI\Usage(name: 'drush rl:page-title:create /blog --variants="News & Insights,Latest Articles"', description: 'Create with two variants')]
+ #[CLI\Usage(name: 'drush rl-ptc /node/42 --variants="Alt One" --variants="Alt Two" --langcode=es', description: 'Create Spanish-only experiment')]
+ public function create(
+ string $path,
+ array $options = [
+ 'variants' => NULL,
+ 'label' => NULL,
+ 'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED,
+ 'disabled' => FALSE,
+ 'dry-run' => FALSE,
+ ],
+ ): string {
+ $this->switchToAdmin();
+
+ $variants = $this->parseVariants($options['variants'] ?? NULL);
+ if (empty($variants)) {
+ return $this->error('At least one variant is required.', ['Use --variants="Alt 1,Alt 2" or --variants=Alt1 --variants=Alt2']);
+ }
+
+ $resolved = $this->aliasManager->getPathByAlias($path);
+ $internal_path = PageTitleExperiment::normalizePath($resolved);
+ $langcode = (string) ($options['langcode'] ?? LanguageInterface::LANGCODE_NOT_SPECIFIED);
+
+ $duplicates = $this->entityTypeManager
+ ->getStorage('rl_page_title_experiment')
+ ->loadByProperties(['path' => $internal_path, 'langcode' => $langcode]);
+ if ($duplicates) {
+ $existing = reset($duplicates);
+ return $this->error(
+ sprintf('An experiment for "%s" (%s) already exists: %s.', $internal_path, $langcode, $existing->id()),
+ ['Use rl:page-title:update to modify it.']
+ );
+ }
+
+ $label = $options['label']
+ ?? $this->resolvePathLabel($internal_path)
+ ?? $internal_path;
+
+ if ($options['dry-run']) {
+ return $this->yaml([
+ 'dry_run' => TRUE,
+ 'action' => 'create',
+ 'experiment' => [
+ 'path' => $internal_path,
+ 'langcode' => $langcode,
+ 'label' => $label,
+ 'variants' => $variants,
+ 'enabled' => !$options['disabled'],
+ ],
+ ]);
+ }
+
+ $entity = $this->entityTypeManager->getStorage('rl_page_title_experiment')->create([
+ 'label' => $label,
+ 'path' => $internal_path,
+ 'langcode' => $langcode,
+ ]);
+ assert($entity instanceof PageTitleExperiment);
+ $entity->setVariants($variants);
+ if ($options['disabled']) {
+ $entity->setUnpublished();
+ }
+ else {
+ $entity->setPublished();
+ }
+ $entity->save();
+
+ $this->experimentRegistry->register($entity->getRlExperimentId(), 'rl_page_title', $label);
+
+ return $this->success(
+ sprintf('Created page title experiment "%s".', $entity->id()),
+ [
+ 'experiment' => [
+ 'id' => $entity->id(),
+ 'label' => $label,
+ 'path' => $internal_path,
+ 'langcode' => $langcode,
+ 'variants' => $variants,
+ 'rl_experiment_id' => $entity->getRlExperimentId(),
+ ],
+ ]
+ );
+ }
+
+ /**
+ * Updates an existing page title experiment.
+ */
+ #[CLI\Command(name: 'rl:page-title:update', aliases: ['rl-ptu'])]
+ #[CLI\Help(description: '[YAML] Update an existing page title experiment.')]
+ #[CLI\Argument(name: 'experimentId', description: 'The page title experiment entity ID')]
+ #[CLI\Option(name: 'label', description: 'New human-readable label')]
+ #[CLI\Option(name: 'variants', description: 'Replace variants list (comma-separated, or multiple --variants flags)')]
+ #[CLI\Option(name: 'enable', description: 'Set enabled state to true')]
+ #[CLI\Option(name: 'disable', description: 'Set enabled state to false')]
+ #[CLI\Option(name: 'dry-run', description: 'Preview without saving')]
+ public function update(
+ string $experimentId,
+ array $options = [
+ 'label' => NULL,
+ 'variants' => NULL,
+ 'enable' => FALSE,
+ 'disable' => FALSE,
+ 'dry-run' => FALSE,
+ ],
+ ): string {
+ $this->switchToAdmin();
+
+ $entity = $this->entityTypeManager->getStorage('rl_page_title_experiment')->load($experimentId);
+ if (!$entity instanceof PageTitleExperiment) {
+ return $this->error(sprintf('Page title experiment "%s" not found.', $experimentId));
+ }
+
+ $changes = [];
+ if ($options['label'] !== NULL) {
+ $changes['label'] = $options['label'];
+ }
+ if ($options['variants'] !== NULL) {
+ $variants = $this->parseVariants($options['variants']);
+ if (empty($variants)) {
+ return $this->error('At least one variant is required when --variants is provided.');
+ }
+ $changes['variants'] = $variants;
+ }
+ if ($options['enable']) {
+ $changes['enabled'] = TRUE;
+ }
+ elseif ($options['disable']) {
+ $changes['enabled'] = FALSE;
+ }
+
+ if (empty($changes)) {
+ return $this->error('Nothing to update. Provide --label, --variants, --enable, or --disable.');
+ }
+
+ if ($options['dry-run']) {
+ return $this->yaml([
+ 'dry_run' => TRUE,
+ 'action' => 'update',
+ 'experiment_id' => $experimentId,
+ 'changes' => $changes,
+ ]);
+ }
+
+ if (isset($changes['label'])) {
+ $entity->set('label', $changes['label']);
+ }
+ if (isset($changes['variants'])) {
+ $entity->setVariants($changes['variants']);
+ }
+ if (array_key_exists('enabled', $changes)) {
+ if ($changes['enabled']) {
+ $entity->setPublished();
+ }
+ else {
+ $entity->setUnpublished();
+ }
+ }
+ $entity->save();
+
+ $this->experimentRegistry->register($entity->getRlExperimentId(), 'rl_page_title', $entity->label());
+
+ return $this->success(
+ sprintf('Updated page title experiment "%s".', $experimentId),
+ ['changes' => $changes]
+ );
+ }
+
+ /**
+ * Deletes a page title experiment and purges its analytics.
+ */
+ #[CLI\Command(name: 'rl:page-title:delete', aliases: ['rl-ptd'])]
+ #[CLI\Help(description: '[YAML] Delete a page title experiment AND purge its RL analytics (turns, rewards, snapshots, registry).')]
+ #[CLI\Argument(name: 'experimentId', description: 'The page title experiment entity ID')]
+ #[CLI\Option(name: 'dry-run', description: 'Preview what would be deleted')]
+ public function delete(
+ string $experimentId,
+ array $options = [
+ 'dry-run' => FALSE,
+ ],
+ ): string {
+ $this->switchToAdmin();
+
+ $entity = $this->entityTypeManager->getStorage('rl_page_title_experiment')->load($experimentId);
+ if (!$entity instanceof PageTitleExperiment) {
+ return $this->error(sprintf('Page title experiment "%s" not found.', $experimentId));
+ }
+
+ $rl_id = $entity->getRlExperimentId();
+ $turns = $this->experimentManager->getTotalTurns($rl_id);
+
+ if ($options['dry-run']) {
+ return $this->yaml([
+ 'dry_run' => TRUE,
+ 'action' => 'delete',
+ 'experiment' => [
+ 'id' => $entity->id(),
+ 'path' => $entity->getPath(),
+ 'langcode' => $entity->language()->getId(),
+ 'rl_experiment_id' => $rl_id,
+ 'analytics_turns' => $turns,
+ ],
+ ]);
+ }
+
+ // Purge analytics first; if it fails, the entity is left intact for retry.
+ $this->experimentManager->purgeExperiment($rl_id);
+ $entity->delete();
+
+ return $this->success(
+ sprintf('Deleted page title experiment "%s" and purged %d turns.', $experimentId, $turns),
+ );
+ }
+
+ /**
+ * Parse variants from --variants option.
+ *
+ * Accepts a comma-separated string OR an array (Drush passes multiple
+ * --variants flags as an array).
+ *
+ * @param mixed $raw
+ * The raw option value.
+ *
+ * @return string[]
+ * Trimmed, non-empty variant strings.
+ */
+ protected function parseVariants(mixed $raw): array {
+ if ($raw === NULL || $raw === '') {
+ return [];
+ }
+ if (is_array($raw)) {
+ $combined = implode("\n", $raw);
+ }
+ else {
+ $combined = str_replace(',', "\n", (string) $raw);
+ }
+ return VariantParser::parse($combined);
+ }
+
+}
diff --git a/modules/rl_page_title/src/Entity/PageTitleExperiment.php b/modules/rl_page_title/src/Entity/PageTitleExperiment.php
new file mode 100644
index 0000000..5aa5ed4
--- /dev/null
+++ b/modules/rl_page_title/src/Entity/PageTitleExperiment.php
@@ -0,0 +1,216 @@
+setLabel(t('Experiment name'))
+ ->setDescription(t('A short name you will recognize later in reports. If left blank, the name of the page being tested is used.'))
+ ->setRequired(TRUE)
+ ->setSetting('max_length', 255)
+ ->setDisplayOptions('form', [
+ 'type' => 'string_textfield',
+ 'weight' => -10,
+ ])
+ ->setDisplayConfigurable('form', TRUE);
+
+ $fields['path'] = BaseFieldDefinition::create('string')
+ ->setLabel(t('Page URL or path'))
+ ->setDescription(t('The page you want to test. Enter the URL or path, for example /blog/my-article or /node/42. URL aliases are resolved automatically.'))
+ ->setRequired(TRUE)
+ ->setSetting('max_length', 2048)
+ ->setDisplayOptions('form', [
+ 'type' => 'string_textfield',
+ 'weight' => -5,
+ ])
+ ->setDisplayConfigurable('form', TRUE)
+ ->addConstraint('NotBlank');
+
+ $fields['variants_data'] = BaseFieldDefinition::create('string_long')
+ ->setLabel(t('Variant titles (JSON)'))
+ ->setDescription(t('JSON-encoded list of variant title strings.'))
+ ->setRequired(TRUE);
+
+ // The 'enabled' field comes from publishedBaseFieldDefinitions(); we
+ // narrow the type so the chained mutators are PHPStan-clean.
+ $enabled = $fields['enabled'];
+ assert($enabled instanceof BaseFieldDefinition);
+ $enabled
+ ->setLabel(t('Serve variants to visitors'))
+ ->setDescription(t('Uncheck to pause the experiment without losing any collected data.'))
+ ->setDefaultValue(TRUE)
+ ->setDisplayOptions('form', [
+ 'type' => 'boolean_checkbox',
+ 'weight' => 5,
+ ])
+ ->setDisplayConfigurable('form', TRUE);
+
+ $fields['created'] = BaseFieldDefinition::create('created')
+ ->setLabel(t('Created'));
+
+ $fields['changed'] = BaseFieldDefinition::create('changed')
+ ->setLabel(t('Changed'));
+
+ return $fields;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function preSave(EntityStorageInterface $storage) {
+ parent::preSave($storage);
+ // Always normalize the path before saving so runtime lookups match.
+ $this->set('path', self::normalizePath($this->getPath()));
+ }
+
+ /**
+ * Get the internal path being tested.
+ */
+ public function getPath(): string {
+ $value = $this->get('path')->value;
+ return $value !== NULL ? (string) $value : '';
+ }
+
+ /**
+ * Set the internal path. Normalizes to leading slash, no trailing slash.
+ */
+ public function setPath(string $path): static {
+ $this->set('path', self::normalizePath($path));
+ return $this;
+ }
+
+ /**
+ * Normalize a path to the canonical form used for storage and matching.
+ *
+ * Ensures a leading slash and removes any trailing slash so that runtime
+ * lookup matches save-time storage exactly.
+ */
+ public static function normalizePath(string $path): string {
+ $path = trim($path);
+ if ($path === '' || $path === '/') {
+ return '/';
+ }
+ if ($path[0] !== '/') {
+ $path = '/' . $path;
+ }
+ return rtrim($path, '/');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getVariants(): array {
+ $value = $this->get('variants_data')->value;
+ if ($value === NULL || $value === '') {
+ return [];
+ }
+ $decoded = json_decode((string) $value, TRUE);
+ return is_array($decoded) ? array_values($decoded) : [];
+ }
+
+ /**
+ * Set the variant titles.
+ *
+ * @param string[] $variants
+ * List of alternative titles to test against the original.
+ *
+ * @return $this
+ */
+ public function setVariants(array $variants): static {
+ $this->set('variants_data', json_encode(array_values($variants)));
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getRlExperimentId(): string {
+ $langcode = $this->language()->getId() ?: LanguageInterface::LANGCODE_NOT_SPECIFIED;
+ return self::buildRlExperimentId($this->getPath(), $langcode);
+ }
+
+ /**
+ * Build a deterministic RL experiment ID from a path and langcode.
+ *
+ * Including the langcode in the hash means each language scopes its
+ * own Thompson Sampling state: an English experiment for /blog and a
+ * Spanish experiment for /blog get separate analytics rows.
+ */
+ public static function buildRlExperimentId(string $path, string $langcode = LanguageInterface::LANGCODE_NOT_SPECIFIED): string {
+ $key = self::normalizePath($path) . '|' . $langcode;
+ return self::buildVariantExperimentId('rl_page_title', $key);
+ }
+
+}
diff --git a/modules/rl_page_title/src/Form/PageTitleExperimentDeleteForm.php b/modules/rl_page_title/src/Form/PageTitleExperimentDeleteForm.php
new file mode 100644
index 0000000..9e8a9d7
--- /dev/null
+++ b/modules/rl_page_title/src/Form/PageTitleExperimentDeleteForm.php
@@ -0,0 +1,14 @@
+pathValidator = $container->get('path.validator');
+ $instance->aliasManager = $container->get('path_alias.manager');
+ $instance->experimentRegistry = $container->get('rl.experiment_registry');
+ $instance->experimentManager = $container->get('rl.experiment_manager');
+ $instance->languageManager = $container->get('language_manager');
+ return $instance;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function form(array $form, FormStateInterface $form_state) {
+ $form = parent::form($form, $form_state);
+
+ $entity = $this->entity;
+ assert($entity instanceof PageTitleExperiment);
+
+ // Pre-populate from query parameters (for deep links from contextual UIs).
+ $request = $this->getRequest();
+ if ($entity->isNew()) {
+ if ($entity->getPath() === '' && $request->query->has('path')) {
+ $entity->setPath((string) $request->query->get('path'));
+ $form['path']['widget'][0]['value']['#default_value'] = $entity->getPath();
+ }
+ if ($entity->label() === NULL && $request->query->has('label')) {
+ $form['label']['widget'][0]['value']['#default_value'] = (string) $request->query->get('label');
+ }
+ }
+
+ // Convert variants_data (JSON internal) to a textarea for editing. The
+ // base field is hidden because it stores JSON; the textarea is the
+ // user-facing surface.
+ $form['variants_data']['#access'] = FALSE;
+ $form['variants'] = [
+ '#type' => 'textarea',
+ '#title' => $this->t('Alternative titles'),
+ '#description' => $this->t('Enter one alternative per line. The original page title always stays in the test as the control, and each line below is rotated in for visitors and measured against it.'),
+ '#default_value' => implode("\n", $entity->getVariants()),
+ '#rows' => 6,
+ '#required' => TRUE,
+ '#weight' => 0,
+ ];
+
+ // Language selector. Default to LANGCODE_NOT_SPECIFIED ("all languages")
+ // to mirror Redirect's behavior. Show all configured languages plus the
+ // "all languages" option.
+ $languages = $this->languageManager->getLanguages(LanguageInterface::STATE_CONFIGURABLE);
+ $language_options = [LanguageInterface::LANGCODE_NOT_SPECIFIED => $this->t('- All languages -')];
+ foreach ($languages as $language) {
+ $language_options[$language->getId()] = $language->getName();
+ }
+ $form['langcode']['widget'][0]['value'] = [
+ '#type' => 'select',
+ '#title' => $this->t('Language'),
+ '#description' => $this->t('Restrict this experiment to visitors in one language, or apply it to all languages. Each language tracks its own results independently.'),
+ '#options' => $language_options,
+ '#default_value' => $entity->language()->getId(),
+ ];
+
+ return $form;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validateForm(array &$form, FormStateInterface $form_state) {
+ parent::validateForm($form, $form_state);
+
+ $raw_path = trim((string) $form_state->getValue(['path', 0, 'value']));
+ if ($raw_path === '') {
+ $form_state->setErrorByName('path', $this->t('Please enter the page URL or path you want to test.'));
+ return;
+ }
+ // Quietly add a leading slash if the user left it off. Forcing users
+ // to type "/" is unnecessary friction and they get it wrong constantly.
+ if ($raw_path[0] !== '/') {
+ $raw_path = '/' . $raw_path;
+ $form_state->setValue(['path', 0, 'value'], $raw_path);
+ }
+ if (!$this->pathValidator->isValid($raw_path)) {
+ $form_state->setErrorByName('path', $this->t('No page was found at %path. Check the URL and try again.', ['%path' => $raw_path]));
+ }
+
+ // Resolve to canonical internal path and check for duplicates.
+ $resolved = $this->aliasManager->getPathByAlias($raw_path);
+ $internal_path = PageTitleExperiment::normalizePath($resolved);
+ $form_state->setValue('_resolved_path', $internal_path);
+
+ // Determine target language from the form.
+ $langcode = (string) ($form_state->getValue(['langcode', 0, 'value']) ?? LanguageInterface::LANGCODE_NOT_SPECIFIED);
+
+ $entity = $this->entity;
+ assert($entity instanceof PageTitleExperiment);
+ $duplicates = $this->entityTypeManager
+ ->getStorage('rl_page_title_experiment')
+ ->loadByProperties([
+ 'path' => $internal_path,
+ 'langcode' => $langcode,
+ ]);
+ foreach ($duplicates as $duplicate) {
+ if ((string) $duplicate->id() !== (string) $entity->id()) {
+ $form_state->setErrorByName('path', $this->t('An experiment named "@label" already tests this page in the same language. Edit it instead.', [
+ '@label' => $duplicate->label(),
+ ':url' => $duplicate->toUrl('edit-form')->toString(),
+ ]));
+ break;
+ }
+ }
+
+ if (empty(VariantParser::parse((string) $form_state->getValue('variants', '')))) {
+ $form_state->setErrorByName('variants', $this->t('Enter at least one alternative title, one per line.'));
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ parent::submitForm($form, $form_state);
+
+ $entity = $this->entity;
+ assert($entity instanceof PageTitleExperiment);
+
+ // Detect retarget: capture the old RL experiment ID for save() to purge
+ // AFTER the entity is successfully written.
+ $this->pendingPurgeRlExperimentId = NULL;
+ if (!$entity->isNew()) {
+ $original = $this->entityTypeManager
+ ->getStorage('rl_page_title_experiment')
+ ->loadUnchanged($entity->id());
+ if ($original instanceof PageTitleExperiment) {
+ $original_id = $original->getRlExperimentId();
+ $new_id = PageTitleExperiment::buildRlExperimentId(
+ (string) $form_state->getValue('_resolved_path'),
+ (string) ($form_state->getValue(['langcode', 0, 'value']) ?? LanguageInterface::LANGCODE_NOT_SPECIFIED)
+ );
+ if ($original_id !== $new_id) {
+ $this->pendingPurgeRlExperimentId = $original_id;
+ }
+ }
+ }
+
+ $entity->setPath((string) $form_state->getValue('_resolved_path'));
+ $entity->setVariants(VariantParser::parse((string) $form_state->getValue('variants', '')));
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function save(array $form, FormStateInterface $form_state) {
+ $entity = $this->entity;
+ assert($entity instanceof PageTitleExperiment);
+ $status = $entity->save();
+
+ // Register with the RL experiment registry. Idempotent.
+ $this->experimentRegistry->register(
+ $entity->getRlExperimentId(),
+ 'rl_page_title',
+ $entity->label()
+ );
+
+ // Now that the new entity is safely written, purge analytics for the
+ // old RL ID if this was a retarget. Doing this AFTER save() means a
+ // failed save leaves the original analytics intact for retry.
+ if ($this->pendingPurgeRlExperimentId !== NULL) {
+ try {
+ $this->experimentManager->purgeExperiment($this->pendingPurgeRlExperimentId);
+ }
+ catch (\Exception $e) {
+ $this->messenger()->addWarning($this->t('Experiment retargeted, but old analytics could not be purged: @message. The previous experiment data is now orphaned and can be cleared manually from Reinforcement Learning reports.', [
+ '@message' => $e->getMessage(),
+ ':url' => '/admin/reports/rl',
+ ]));
+ }
+ $this->pendingPurgeRlExperimentId = NULL;
+ }
+
+ // Invalidate page cache for the target path so the new variants take
+ // effect immediately.
+ Cache::invalidateTags(['rl_page_title:' . $entity->getPath()]);
+
+ if ($status === SAVED_NEW) {
+ $this->messenger()->addStatus($this->t('Created experiment %label.', ['%label' => $entity->label()]));
+ }
+ else {
+ $this->messenger()->addStatus($this->t('Updated experiment %label.', ['%label' => $entity->label()]));
+ }
+ $form_state->setRedirectUrl(Url::fromRoute('view.rl_page_title_experiment.page_1'));
+ return $status;
+ }
+
+}
diff --git a/modules/rl_page_title/src/Service/TitleVariantSelector.php b/modules/rl_page_title/src/Service/TitleVariantSelector.php
new file mode 100644
index 0000000..8432190
--- /dev/null
+++ b/modules/rl_page_title/src/Service/TitleVariantSelector.php
@@ -0,0 +1,126 @@
+currentPath = $current_path;
+ $this->languageManager = $language_manager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function ownerModule(): string {
+ return 'rl_page_title';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function entityTypeId(): string {
+ return 'rl_page_title_experiment';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function entityClass(): string {
+ return PageTitleExperiment::class;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function targetProperty(): string {
+ return 'path';
+ }
+
+ /**
+ * Select the winning variant for the current request.
+ *
+ * Uses the resolved internal path and the current interface language.
+ * Falls back to LANGCODE_NOT_SPECIFIED via the base class if no
+ * language-specific experiment exists.
+ */
+ public function selectForCurrentPage(): ?array {
+ return $this->selectForPath(
+ $this->getCurrentInternalPath(),
+ $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_INTERFACE)->getId()
+ );
+ }
+
+ /**
+ * Select the winning variant for a specific internal path and language.
+ *
+ * @param string $internal_path
+ * The path to look up. Will be normalized.
+ * @param string $langcode
+ * The language code. Defaults to LANGCODE_NOT_SPECIFIED if omitted.
+ *
+ * @return array|null
+ * The selection result or NULL.
+ */
+ public function selectForPath(string $internal_path, string $langcode = LanguageInterface::LANGCODE_NOT_SPECIFIED): ?array {
+ return $this->selectForTarget(
+ PageTitleExperiment::normalizePath($internal_path),
+ $langcode
+ );
+ }
+
+ /**
+ * Get the resolved internal path for the current request.
+ */
+ protected function getCurrentInternalPath(): string {
+ return PageTitleExperiment::normalizePath($this->currentPath->getPath());
+ }
+
+}
diff --git a/scripts/e2e/run-e2e-tests.sh b/scripts/e2e/run-e2e-tests.sh
index 52976ae..9f87099 100755
--- a/scripts/e2e/run-e2e-tests.sh
+++ b/scripts/e2e/run-e2e-tests.sh
@@ -46,9 +46,9 @@ composer require drush/drush --quiet
--yes \
--quiet
-# Enable the RL module.
+# Enable the RL ecosystem modules (parent + submodules).
DRUSH="$SITE_DIR/vendor/bin/drush"
-$DRUSH en rl --yes --quiet
+$DRUSH en rl rl_page_title rl_menu_link --yes --quiet
# Rebuild cache after enabling module.
$DRUSH cr --quiet
diff --git a/scripts/e2e/test-menu-link-crud.sh b/scripts/e2e/test-menu-link-crud.sh
new file mode 100755
index 0000000..c604521
--- /dev/null
+++ b/scripts/e2e/test-menu-link-crud.sh
@@ -0,0 +1,110 @@
+#!/usr/bin/env bash
+# E2E tests for rl:menu-link:* commands.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+source "$SCRIPT_DIR/_helpers.sh"
+
+DRUSH="${DRUSH:-drush}"
+
+section "rl:menu-link:create"
+
+# Create against a known core menu link.
+output=$($DRUSH rl:menu-link:create system.admin_content --variants="Content,Manage Content" --label="E2E test" 2>&1)
+assert_has "create returns success" "success: true" "$output"
+assert_has "create returns rl experiment id" "rl_menu_link-" "$output"
+
+ENTITY_ID=$(echo "$output" | yq -e '.experiment.id' 2>/dev/null || true)
+assert_not_empty "create returned entity id" "$ENTITY_ID"
+
+# Duplicate (same plugin id + langcode) should fail.
+output=$($DRUSH rl:menu-link:create system.admin_content --variants="Other" 2>&1)
+assert_has "duplicate create returns error" "already exists" "$output"
+
+# Different language for same plugin id should succeed.
+output=$($DRUSH rl:menu-link:create system.admin_content --variants="Contenido" --langcode=es 2>&1)
+assert_has "different language create succeeds" "success: true" "$output"
+
+# Multiple --variants flags work.
+output=$($DRUSH rl:menu-link:create system.admin_structure --variants="One" --variants="Two" 2>&1)
+assert_has "multiple --variants flags accepted" "success: true" "$output"
+
+# Unknown plugin id should fail.
+output=$($DRUSH rl:menu-link:create not.a.real.plugin --variants="A" 2>&1)
+assert_has "unknown plugin id returns error" "No menu link plugin" "$output"
+
+# Empty variants list should fail.
+output=$($DRUSH rl:menu-link:create system.admin_config --variants="" 2>&1)
+assert_has "empty variants returns error" "At least one variant" "$output"
+
+# Dry run.
+output=$($DRUSH rl:menu-link:create user.page --variants="Preview" --dry-run 2>&1)
+assert_dry_run "create dry-run" "$output"
+
+section "rl:menu-link:list"
+
+output=$($DRUSH rl:menu-link:list 2>&1)
+assert_has "list returns success" "success: true" "$output"
+assert_has "list contains created experiment" "system.admin_content" "$output"
+
+# Filter by enabled status.
+output=$($DRUSH rl:menu-link:list --enabled=yes 2>&1)
+assert_has "filter by enabled=yes" "success: true" "$output"
+
+section "rl:menu-link:get"
+
+output=$($DRUSH rl:menu-link:get "$ENTITY_ID" 2>&1)
+assert_has "get returns label" "E2E test" "$output"
+assert_has "get returns variants" "Content" "$output"
+assert_has "get includes original_label from menu manager" "original_label" "$output"
+assert_has "get returns analytics block" "analytics:" "$output"
+
+# Get nonexistent.
+output=$($DRUSH rl:menu-link:get 999999 2>&1)
+assert_has "get nonexistent returns error" "not found" "$output"
+
+section "rl:menu-link:update"
+
+output=$($DRUSH rl:menu-link:update "$ENTITY_ID" --label="Renamed" 2>&1)
+assert_has "update label returns success" "success: true" "$output"
+
+# Update variants.
+output=$($DRUSH rl:menu-link:update "$ENTITY_ID" --variants="New A,New B,New C" 2>&1)
+assert_has "update variants returns success" "success: true" "$output"
+
+# Disable.
+output=$($DRUSH rl:menu-link:update "$ENTITY_ID" --disable 2>&1)
+assert_has "disable returns success" "success: true" "$output"
+
+# Re-enable.
+output=$($DRUSH rl:menu-link:update "$ENTITY_ID" --enable 2>&1)
+assert_has "enable returns success" "success: true" "$output"
+
+# Update with no options.
+output=$($DRUSH rl:menu-link:update "$ENTITY_ID" 2>&1)
+assert_has "update with no options returns error" "Nothing to update" "$output"
+
+# Dry run.
+output=$($DRUSH rl:menu-link:update "$ENTITY_ID" --label="Preview" --dry-run 2>&1)
+assert_dry_run "update dry-run" "$output"
+
+section "rl:menu-link:delete"
+
+# Dry run.
+output=$($DRUSH rl:menu-link:delete "$ENTITY_ID" --dry-run 2>&1)
+assert_dry_run "delete dry-run" "$output"
+
+# Actual delete.
+output=$($DRUSH rl:menu-link:delete "$ENTITY_ID" 2>&1)
+assert_has "delete returns success" "success: true" "$output"
+assert_has "delete reports purge count" "purged" "$output"
+
+# Verify it's gone.
+output=$($DRUSH rl:menu-link:get "$ENTITY_ID" 2>&1)
+assert_has "deleted experiment not found by get" "not found" "$output"
+
+# Delete nonexistent.
+output=$($DRUSH rl:menu-link:delete 999999 2>&1)
+assert_has "delete nonexistent returns error" "not found" "$output"
+
+print_summary
diff --git a/scripts/e2e/test-page-title-crud.sh b/scripts/e2e/test-page-title-crud.sh
new file mode 100755
index 0000000..3888d6f
--- /dev/null
+++ b/scripts/e2e/test-page-title-crud.sh
@@ -0,0 +1,114 @@
+#!/usr/bin/env bash
+# E2E tests for rl:page-title:* commands.
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+source "$SCRIPT_DIR/_helpers.sh"
+
+DRUSH="${DRUSH:-drush}"
+
+section "rl:page-title:create"
+
+# Create with comma-separated variants.
+output=$($DRUSH rl:page-title:create /admin --variants="Alt One,Alt Two" --label="E2E test" 2>&1)
+assert_has "create returns success" "success: true" "$output"
+assert_has "create returns rl experiment id" "rl_page_title-" "$output"
+assert_has "create stores normalized path" "path: /admin" "$output"
+
+# Capture the entity ID for later commands.
+ENTITY_ID=$(echo "$output" | yq -e '.experiment.id' 2>/dev/null || true)
+assert_not_empty "create returned entity id" "$ENTITY_ID"
+
+# Duplicate (same path + langcode) should fail.
+output=$($DRUSH rl:page-title:create /admin --variants="Other" 2>&1)
+assert_has "duplicate create returns error" "already exists" "$output"
+
+# Different language for same path should succeed.
+output=$($DRUSH rl:page-title:create /admin --variants="Spanish Alt" --langcode=es 2>&1)
+assert_has "different language create succeeds" "success: true" "$output"
+
+# Multiple --variants flags work.
+output=$($DRUSH rl:page-title:create /admin/structure --variants="One" --variants="Two" --variants="Three" 2>&1)
+assert_has "multiple --variants flags accepted" "success: true" "$output"
+
+# Empty variants list should fail.
+output=$($DRUSH rl:page-title:create /admin/people --variants="" 2>&1)
+assert_has "empty variants returns error" "At least one variant" "$output"
+
+# Dry run.
+output=$($DRUSH rl:page-title:create /admin/config --variants="Preview" --dry-run 2>&1)
+assert_dry_run "create dry-run" "$output"
+
+# Path normalization: trailing slash should resolve to canonical form.
+$DRUSH rl:page-title:create /admin/reports/ --variants="Trailing" 2>&1 > /dev/null
+output=$($DRUSH rl:page-title:create /admin/reports --variants="Same path" 2>&1)
+assert_has "trailing slash duplicate detected" "already exists" "$output"
+
+section "rl:page-title:list"
+
+output=$($DRUSH rl:page-title:list 2>&1)
+assert_has "list returns success" "success: true" "$output"
+assert_has "list contains created experiment" "/admin" "$output"
+
+# Filter by enabled status.
+output=$($DRUSH rl:page-title:list --enabled=yes 2>&1)
+assert_has "filter by enabled=yes" "success: true" "$output"
+
+section "rl:page-title:get"
+
+output=$($DRUSH rl:page-title:get "$ENTITY_ID" 2>&1)
+assert_has "get returns label" "E2E test" "$output"
+assert_has "get returns variants" "Alt One" "$output"
+assert_has "get returns analytics block" "analytics:" "$output"
+
+# Get nonexistent.
+output=$($DRUSH rl:page-title:get 999999 2>&1)
+assert_has "get nonexistent returns error" "not found" "$output"
+
+section "rl:page-title:update"
+
+output=$($DRUSH rl:page-title:update "$ENTITY_ID" --label="Renamed" 2>&1)
+assert_has "update label returns success" "success: true" "$output"
+assert_has "update reflects new label in changes" "Renamed" "$output"
+
+# Update variants.
+output=$($DRUSH rl:page-title:update "$ENTITY_ID" --variants="Brand New A,Brand New B,Brand New C" 2>&1)
+assert_has "update variants returns success" "success: true" "$output"
+assert_has "update variants shows new list" "Brand New" "$output"
+
+# Disable.
+output=$($DRUSH rl:page-title:update "$ENTITY_ID" --disable 2>&1)
+assert_has "disable returns success" "success: true" "$output"
+
+# Re-enable.
+output=$($DRUSH rl:page-title:update "$ENTITY_ID" --enable 2>&1)
+assert_has "enable returns success" "success: true" "$output"
+
+# Update with no options.
+output=$($DRUSH rl:page-title:update "$ENTITY_ID" 2>&1)
+assert_has "update with no options returns error" "Nothing to update" "$output"
+
+# Dry run.
+output=$($DRUSH rl:page-title:update "$ENTITY_ID" --label="Preview" --dry-run 2>&1)
+assert_dry_run "update dry-run" "$output"
+
+section "rl:page-title:delete"
+
+# Dry run delete.
+output=$($DRUSH rl:page-title:delete "$ENTITY_ID" --dry-run 2>&1)
+assert_dry_run "delete dry-run" "$output"
+
+# Actual delete.
+output=$($DRUSH rl:page-title:delete "$ENTITY_ID" 2>&1)
+assert_has "delete returns success" "success: true" "$output"
+assert_has "delete reports purge count" "purged" "$output"
+
+# Verify it's gone.
+output=$($DRUSH rl:page-title:get "$ENTITY_ID" 2>&1)
+assert_has "deleted experiment not found by get" "not found" "$output"
+
+# Delete nonexistent.
+output=$($DRUSH rl:page-title:delete 999999 2>&1)
+assert_has "delete nonexistent returns error" "not found" "$output"
+
+print_summary
diff --git a/scripts/e2e/test-setup-ai.sh b/scripts/e2e/test-setup-ai.sh
index 5b4ec91..8a461d5 100755
--- a/scripts/e2e/test-setup-ai.sh
+++ b/scripts/e2e/test-setup-ai.sh
@@ -34,4 +34,14 @@ assert_has "invalid host returns error" "Invalid --host" "$output"
output=$($DRUSH rl:setup-ai --host=agents 2>&1)
assert_has "agents install returns success" "success: true" "$output"
+# Submodules: with rl_page_title and rl_menu_link enabled, the install
+# should include their skill files in the actions list.
+output=$($DRUSH rl:setup-ai 2>&1)
+assert_has "install includes rl_page_title submodule" "rl_page_title" "$output"
+assert_has "install includes rl_menu_link submodule" "rl_menu_link" "$output"
+
+# Check mode after submodule install.
+output=$($DRUSH rl:setup-ai --check 2>&1)
+assert_has "check confirms submodule files" "rl_page_title" "$output"
+
print_summary
diff --git a/src/Controller/ReportsController.php b/src/Controller/ReportsController.php
index 3f3de61..3334daf 100644
--- a/src/Controller/ReportsController.php
+++ b/src/Controller/ReportsController.php
@@ -11,6 +11,7 @@
use Drupal\Core\Url;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Drupal\rl\Decorator\ExperimentDecoratorManager;
+use Drupal\rl\Registry\ExperimentRegistryInterface;
use Drupal\rl\Service\ArmDataValidator;
use Drupal\rl\Storage\ExperimentDataStorageInterface;
use Drupal\rl\Storage\SnapshotStorageInterface;
@@ -79,26 +80,26 @@ class ReportsController extends ControllerBase {
protected LibrariesDirectoryFileFinder $libraryFinder;
/**
- * Constructs a ReportsController object.
+ * The experiment registry.
*
- * @param \Drupal\rl\Storage\ExperimentDataStorageInterface $experiment_storage
- * The experiment data storage.
- * @param \Drupal\Core\Datetime\DateFormatterInterface $date_formatter
- * The date formatter service.
- * @param \Drupal\rl\Decorator\ExperimentDecoratorManager $decorator_manager
- * The experiment decorator manager.
- * @param \Drupal\Core\Render\RendererInterface $renderer
- * The renderer service.
- * @param \Drupal\rl\Service\ArmDataValidator $arm_data_validator
- * The arm data validator.
- * @param \Drupal\rl\Storage\SnapshotStorageInterface $snapshot_storage
- * The snapshot storage.
- * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
- * The request stack.
- * @param \Drupal\Core\Asset\LibrariesDirectoryFileFinder $library_finder
- * The libraries directory file finder.
+ * @var \Drupal\rl\Registry\ExperimentRegistryInterface
+ */
+ protected ExperimentRegistryInterface $experimentRegistry;
+
+ /**
+ * Constructs a ReportsController object.
*/
- public function __construct(ExperimentDataStorageInterface $experiment_storage, DateFormatterInterface $date_formatter, ExperimentDecoratorManager $decorator_manager, RendererInterface $renderer, ArmDataValidator $arm_data_validator, SnapshotStorageInterface $snapshot_storage, RequestStack $request_stack, LibrariesDirectoryFileFinder $library_finder) {
+ public function __construct(
+ ExperimentDataStorageInterface $experiment_storage,
+ DateFormatterInterface $date_formatter,
+ ExperimentDecoratorManager $decorator_manager,
+ RendererInterface $renderer,
+ ArmDataValidator $arm_data_validator,
+ SnapshotStorageInterface $snapshot_storage,
+ RequestStack $request_stack,
+ LibrariesDirectoryFileFinder $library_finder,
+ ExperimentRegistryInterface $experiment_registry,
+ ) {
$this->experimentStorage = $experiment_storage;
$this->dateFormatter = $date_formatter;
$this->decoratorManager = $decorator_manager;
@@ -107,6 +108,7 @@ public function __construct(ExperimentDataStorageInterface $experiment_storage,
$this->snapshotStorage = $snapshot_storage;
$this->requestStack = $request_stack;
$this->libraryFinder = $library_finder;
+ $this->experimentRegistry = $experiment_registry;
}
/**
@@ -126,7 +128,8 @@ public static function create(ContainerInterface $container): static {
$container->get('rl.arm_data_validator'),
$container->get('rl.snapshot_storage'),
$container->get('request_stack'),
- $container->get('library.libraries_directory_file_finder')
+ $container->get('library.libraries_directory_file_finder'),
+ $container->get('rl.experiment_registry')
);
}
@@ -240,8 +243,13 @@ public function experimentsOverview() {
* The page title.
*/
public function experimentDetailTitle($experiment_id) {
+ // Prefer the totals row (which carries the latest cached name), but fall
+ // back to the registry for experiments that have been registered but
+ // have not yet received any traffic. Finally, fall back to the raw ID.
$experiment_totals = $this->experimentStorage->getExperimentTotals($experiment_id);
- $experiment_name = $experiment_totals->experiment_name ?? $experiment_id;
+ $experiment_name = $experiment_totals->experiment_name
+ ?? $this->experimentRegistry->getExperimentName($experiment_id)
+ ?? $experiment_id;
return $this->t('Experiment: @name', ['@name' => $experiment_name]);
}
@@ -263,10 +271,13 @@ public function experimentDetail($experiment_id) {
]));
}
- // Get experiment totals from storage.
+ // Get experiment totals from storage. A missing totals row is expected
+ // for a newly-registered experiment that has not yet received any
+ // traffic; we still want to render the report (with an empty state)
+ // instead of 404'ing. A truly unknown experiment ID is rejected below.
$experiment_totals = $this->experimentStorage->getExperimentTotals($experiment_id);
- if (!$experiment_totals) {
+ if (!$experiment_totals && !$this->experimentRegistry->isRegistered($experiment_id)) {
throw new NotFoundHttpException();
}
diff --git a/src/Drush/Commands/RlSetupCommands.php b/src/Drush/Commands/RlSetupCommands.php
index 270a5b6..5a42f6b 100644
--- a/src/Drush/Commands/RlSetupCommands.php
+++ b/src/Drush/Commands/RlSetupCommands.php
@@ -23,12 +23,19 @@ public function __construct(
/**
* Installs AI skill files to the project root.
+ *
+ * Installs the parent rl module's skill files plus the skill files of any
+ * enabled rl_* submodule (rl_page_title, rl_menu_link, etc.). Each
+ * submodule ships its own SKILL.md inside its own module directory; this
+ * command discovers them via the module extension list and copies them
+ * to the project root so AI assistants can discover the full command
+ * surface in one shot.
*/
#[CLI\Command(name: 'rl:setup-ai', aliases: ['rl-sa'])]
- #[CLI\Help(description: '[YAML] Installs RL AI skill files so coding assistants can discover rl:* commands and A/B testing workflows.')]
+ #[CLI\Help(description: '[YAML] Installs RL AI skill files (parent module + all enabled rl_* submodules) so coding assistants can discover rl:* commands and A/B testing workflows.')]
#[CLI\Option(name: 'host', description: 'Target: claude, agents, or all (default: all)')]
#[CLI\Option(name: 'check', description: 'Check if installed files are up to date (no changes made)')]
- #[CLI\Usage(name: 'drush rl:setup-ai', description: 'Install for all AI tools')]
+ #[CLI\Usage(name: 'drush rl:setup-ai', description: 'Install for all AI tools (parent + submodules)')]
#[CLI\Usage(name: 'drush rl:setup-ai --check', description: 'Check if skill files are up to date')]
#[CLI\Usage(name: 'drush rl-sa --host=claude', description: 'Install for Claude Code only')]
#[CLI\Usage(name: 'drush rl-sa --host=agents', description: 'Install for Codex/Gemini/Copilot/Cursor')]
@@ -38,13 +45,9 @@ public function setupAi(
'check' => FALSE,
],
): string {
- $modulePath = $this->getModulePath();
$projectRoot = $this->getProjectRoot();
$host = $options['host'] ?? 'all';
- if ($modulePath === NULL) {
- return $this->error('Could not determine rl module path.');
- }
if ($projectRoot === NULL) {
return $this->error('Could not determine project root (no composer.json found).');
}
@@ -52,83 +55,141 @@ public function setupAi(
return $this->error('Invalid --host value. Use: claude, agents, or all.');
}
+ // Discover all rl-ecosystem modules with skill files: the parent rl
+ // module plus any enabled rl_* submodule.
+ $modules = $this->discoverRlModules();
+ if (empty($modules)) {
+ return $this->error('Could not locate rl module path.');
+ }
+
if ($options['check']) {
- return $this->checkSkillFiles($modulePath, $projectRoot, $host);
+ return $this->checkSkillFiles($modules, $projectRoot, $host);
}
$results = [];
+ $supportedTools = [];
$installClaude = in_array($host, ['claude', 'all']);
$installAgents = in_array($host, ['agents', 'all']);
- if ($installClaude) {
- $results = array_merge($results, $this->installFile(
- $modulePath,
- $projectRoot,
- '.claude/skills/rl/SKILL.md',
- ));
- }
-
- if ($installAgents) {
- $results = array_merge($results, $this->installFile(
- $modulePath,
- $projectRoot,
- '.agents/skills/rl/SKILL.md',
- ));
- $results = array_merge($results, $this->installFile(
- $modulePath,
- $projectRoot,
- '.agents/skills/rl/agents/openai.yaml',
- ));
+ foreach ($modules as $moduleName => $modulePath) {
+ $relativeFiles = $this->skillFilePaths($moduleName, $installClaude, $installAgents);
+ foreach ($relativeFiles as $relativePath) {
+ $results = array_merge($results, $this->installFile($modulePath, $projectRoot, $relativePath));
+ }
}
-
- $supportedTools = [];
if ($installClaude) {
- $supportedTools[] = 'Claude Code: .claude/skills/rl/SKILL.md';
+ $supportedTools[] = 'Claude Code: .claude/skills/{rl,rl_page_title,rl_menu_link}/SKILL.md';
}
if ($installAgents) {
- $supportedTools[] = 'Codex / Gemini / Copilot / Cursor: .agents/skills/rl/SKILL.md';
+ $supportedTools[] = 'Codex / Gemini / Copilot / Cursor: .agents/skills/{rl,rl_page_title,rl_menu_link}/SKILL.md';
}
return $this->yaml([
'success' => TRUE,
'message' => 'RL AI skill files installed.',
+ 'modules_processed' => array_keys($modules),
'actions' => $results,
'supported_tools' => $supportedTools,
]);
}
/**
- * Checks if installed skill files match the module source.
+ * Discover enabled RL ecosystem modules that ship skill files.
+ *
+ * @return array
+ * Map of module machine name to absolute module root path. Always
+ * includes the parent `rl` module; submodules are included only if
+ * they are enabled and have a skill file at the expected path.
*/
- protected function checkSkillFiles(string $modulePath, string $projectRoot, string $host): string {
- $files = [];
- if (in_array($host, ['claude', 'all'])) {
- $files[] = '.claude/skills/rl/SKILL.md';
+ protected function discoverRlModules(): array {
+ $modules = [];
+ try {
+ // @phpstan-ignore-next-line
+ $module_handler = \Drupal::service('module_handler');
+ $extensions = $this->moduleExtensionList->getList();
}
- if (in_array($host, ['agents', 'all'])) {
- $files[] = '.agents/skills/rl/SKILL.md';
- $files[] = '.agents/skills/rl/agents/openai.yaml';
+ catch (\Exception $e) {
+ return $modules;
}
- $results = [];
- $outdated = FALSE;
- foreach ($files as $relativePath) {
- $source = $modulePath . '/' . $relativePath;
- $dest = $projectRoot . '/' . $relativePath;
-
- if (!file_exists($dest)) {
- $results[] = sprintf('%s — NOT INSTALLED', $relativePath);
- $outdated = TRUE;
+ foreach ($extensions as $name => $extension) {
+ if ($name !== 'rl' && !str_starts_with($name, 'rl_')) {
+ continue;
}
- elseif (!file_exists($source)) {
- $results[] = sprintf('%s — source missing', $relativePath);
+ // The parent rl module is always included; submodules only if enabled.
+ if ($name !== 'rl' && !$module_handler->moduleExists($name)) {
+ continue;
}
- elseif (md5_file($source) !== md5_file($dest)) {
- $results[] = sprintf('%s — OUTDATED', $relativePath);
- $outdated = TRUE;
+ $relativePath = $this->moduleExtensionList->getPath($name);
+ if (!$relativePath) {
+ continue;
}
- else {
- $results[] = sprintf('%s — up to date', $relativePath);
+ $absolutePath = DRUPAL_ROOT . '/' . $relativePath;
+ // Only include modules that actually ship a SKILL.md.
+ $skill = $absolutePath . '/.claude/skills/' . $name . '/SKILL.md';
+ $agents = $absolutePath . '/.agents/skills/' . $name . '/SKILL.md';
+ if (file_exists($skill) || file_exists($agents)) {
+ $modules[$name] = $absolutePath;
+ }
+ }
+ return $modules;
+ }
+
+ /**
+ * Returns the relative skill file paths for a module by host filter.
+ *
+ * @return string[]
+ * Relative paths under the module root, e.g.
+ * `.claude/skills/{module}/SKILL.md`.
+ */
+ protected function skillFilePaths(string $moduleName, bool $claude, bool $agents): array {
+ $files = [];
+ if ($claude) {
+ $files[] = '.claude/skills/' . $moduleName . '/SKILL.md';
+ }
+ if ($agents) {
+ $files[] = '.agents/skills/' . $moduleName . '/SKILL.md';
+ $files[] = '.agents/skills/' . $moduleName . '/agents/openai.yaml';
+ }
+ return $files;
+ }
+
+ /**
+ * Checks if installed skill files match the module sources.
+ *
+ * @param array $modules
+ * Map of module name to absolute module path (from discoverRlModules).
+ * @param string $projectRoot
+ * Absolute path to the Composer project root where files are installed.
+ * @param string $host
+ * Host filter: "claude", "agents", or "all".
+ */
+ protected function checkSkillFiles(array $modules, string $projectRoot, string $host): string {
+ $installClaude = in_array($host, ['claude', 'all']);
+ $installAgents = in_array($host, ['agents', 'all']);
+
+ $results = [];
+ $outdated = FALSE;
+ foreach ($modules as $moduleName => $modulePath) {
+ foreach ($this->skillFilePaths($moduleName, $installClaude, $installAgents) as $relativePath) {
+ $source = $modulePath . '/' . $relativePath;
+ $dest = $projectRoot . '/' . $relativePath;
+
+ if (!file_exists($source)) {
+ // The module does not ship this particular file; skip it silently.
+ continue;
+ }
+ if (!file_exists($dest)) {
+ $results[] = sprintf('%s — NOT INSTALLED', $relativePath);
+ $outdated = TRUE;
+ }
+ elseif (md5_file($source) !== md5_file($dest)) {
+ $results[] = sprintf('%s — OUTDATED', $relativePath);
+ $outdated = TRUE;
+ }
+ else {
+ $results[] = sprintf('%s — up to date', $relativePath);
+ }
}
}
@@ -148,15 +209,17 @@ protected function checkSkillFiles(string $modulePath, string $projectRoot, stri
}
/**
- * Copies a single file from module to project root.
+ * Copies a single file from a module to project root.
+ *
+ * Silently skips when the source file does not exist (a module may ship
+ * a SKILL.md but not the agents YAML, or vice versa).
*/
protected function installFile(string $modulePath, string $projectRoot, string $relativePath): array {
- $results = [];
$source = $modulePath . '/' . $relativePath;
$dest = $projectRoot . '/' . $relativePath;
if (!file_exists($source)) {
- return [sprintf('Source not found: %s', $relativePath)];
+ return [];
}
$destDir = dirname($dest);
@@ -166,9 +229,7 @@ protected function installFile(string $modulePath, string $projectRoot, string $
$action = file_exists($dest) ? 'updated' : 'installed';
copy($source, $dest);
- $results[] = sprintf('%s %s at %s', basename($relativePath), $action, $relativePath);
-
- return $results;
+ return [sprintf('%s %s at %s', basename($relativePath), $action, $relativePath)];
}
}
diff --git a/src/Experiment/VariantArmsTrait.php b/src/Experiment/VariantArmsTrait.php
new file mode 100644
index 0000000..10a8398
--- /dev/null
+++ b/src/Experiment/VariantArmsTrait.php
@@ -0,0 +1,74 @@
+getVariants() as $i => $_unused) {
+ $arm_ids[] = 'v' . ($i + 1);
+ }
+ return $arm_ids;
+ }
+
+ /**
+ * Get the text for a specific arm ID.
+ *
+ * @param string $arm_id
+ * Arm ID like "v0", "v1", "v2".
+ *
+ * @return string|null
+ * The variant text, or NULL if the arm is v0 (original) or unknown.
+ */
+ public function getArmText(string $arm_id): ?string {
+ if ($arm_id === 'v0' || !preg_match('/^v(\d+)$/', $arm_id, $m)) {
+ return NULL;
+ }
+ $index = (int) $m[1] - 1;
+ if ($index < 0) {
+ return NULL;
+ }
+ $variants = $this->getVariants();
+ return $variants[$index] ?? NULL;
+ }
+
+ /**
+ * Build a deterministic, collision-resistant RL experiment ID.
+ *
+ * @param string $prefix
+ * Module-specific prefix, e.g. "rl_page_title".
+ * @param string $target
+ * The target identifier (path, plugin id, etc).
+ *
+ * @return string
+ * The RL experiment ID, in format: {prefix}-{12-char-sha1}.
+ */
+ public static function buildVariantExperimentId(string $prefix, string $target): string {
+ return $prefix . '-' . substr(sha1($target), 0, 12);
+ }
+
+}
diff --git a/src/Experiment/VariantExperimentDecoratorBase.php b/src/Experiment/VariantExperimentDecoratorBase.php
new file mode 100644
index 0000000..ceba547
--- /dev/null
+++ b/src/Experiment/VariantExperimentDecoratorBase.php
@@ -0,0 +1,137 @@
+
+ */
+ protected array $cache = [];
+
+ /**
+ * Constructs a VariantExperimentDecoratorBase.
+ */
+ public function __construct(EntityTypeManagerInterface $entity_type_manager) {
+ $this->entityTypeManager = $entity_type_manager;
+ }
+
+ /**
+ * The RL experiment ID prefix this decorator handles.
+ */
+ abstract protected function experimentIdPrefix(): string;
+
+ /**
+ * The entity type ID of the experiment content entity.
+ */
+ abstract protected function entityTypeId(): string;
+
+ /**
+ * The fully-qualified class name of the experiment entity.
+ */
+ abstract protected function entityClass(): string;
+
+ /**
+ * Build a render array describing the experiment for reports.
+ */
+ abstract protected function buildExperimentDisplay(VariantExperimentInterface $experiment): array;
+
+ /**
+ * Build a render array describing the v0 (original) arm.
+ */
+ abstract protected function buildOriginalArmDisplay(VariantExperimentInterface $experiment): array;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function decorateExperiment(string $experiment_id): ?array {
+ $experiment = $this->loadExperiment($experiment_id);
+ if ($experiment === NULL) {
+ return NULL;
+ }
+ return $this->buildExperimentDisplay($experiment);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function decorateArm(string $experiment_id, string $arm_id): ?array {
+ $experiment = $this->loadExperiment($experiment_id);
+ if ($experiment === NULL) {
+ return NULL;
+ }
+ $text = $experiment->getArmText($arm_id);
+ if ($text === NULL) {
+ return $this->buildOriginalArmDisplay($experiment);
+ }
+ return [
+ '#type' => 'inline_template',
+ '#template' => '{{ text }}',
+ '#context' => ['text' => $text],
+ ];
+ }
+
+ /**
+ * Load an experiment by RL experiment ID.
+ *
+ * Iterates the storage looking for one whose RL ID matches. Result is
+ * cached per-request. Reports pages typically decorate one experiment
+ * at a time, so the iteration cost is bounded by the number of distinct
+ * experiment IDs the report shows, not by the total number of
+ * experiments in the database.
+ *
+ * @return \Drupal\rl\Experiment\VariantExperimentInterface|null
+ * The matching experiment, or NULL if no entity hashes to this ID.
+ */
+ protected function loadExperiment(string $experiment_id): ?VariantExperimentInterface {
+ if (!str_starts_with($experiment_id, $this->experimentIdPrefix())) {
+ return NULL;
+ }
+ if (array_key_exists($experiment_id, $this->cache)) {
+ return $this->cache[$experiment_id] ?: NULL;
+ }
+ $storage = $this->entityTypeManager->getStorage($this->entityTypeId());
+ $class = $this->entityClass();
+ // We cannot reverse the hash, so we iterate. Reports decorate one ID
+ // at a time and cache the result, so iteration is amortized cheaply.
+ // For very large experiment counts (10K+), each iteration only loads
+ // one entity ID at a time via the storage's loadByProperties path.
+ $ids = $storage->getQuery()->accessCheck(FALSE)->execute();
+ foreach ($storage->loadMultiple($ids) as $entity) {
+ if ($entity instanceof VariantExperimentInterface && $entity instanceof $class) {
+ if ($entity->getRlExperimentId() === $experiment_id) {
+ $this->cache[$experiment_id] = $entity;
+ return $entity;
+ }
+ }
+ }
+ $this->cache[$experiment_id] = FALSE;
+ return NULL;
+ }
+
+}
diff --git a/src/Experiment/VariantExperimentDeleteFormBase.php b/src/Experiment/VariantExperimentDeleteFormBase.php
new file mode 100644
index 0000000..b053a5b
--- /dev/null
+++ b/src/Experiment/VariantExperimentDeleteFormBase.php
@@ -0,0 +1,87 @@
+experimentManager = $container->get('rl.experiment_manager');
+ return $instance;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDescription() {
+ // Surface the real stakes: if the experiment has collected any data,
+ // show the numbers so the user knows exactly what they are throwing
+ // away. A generic "permanently delete" warning does not land the same
+ // way as "7,413 impressions and 214 conversions will be deleted".
+ $entity = $this->getEntity();
+ if ($entity instanceof VariantExperimentInterface) {
+ try {
+ $rl_id = $entity->getRlExperimentId();
+ $turns = (int) $this->experimentManager->getTotalTurns($rl_id);
+ $rewards = 0;
+ foreach ($this->experimentManager->getAllArmsData($rl_id) as $arm) {
+ $rewards += (int) $arm->rewards;
+ }
+ if ($turns > 0 || $rewards > 0) {
+ return $this->t('@turns impressions and @rewards conversions will be permanently deleted along with all historical snapshots. This action cannot be undone.', [
+ '@turns' => number_format($turns),
+ '@rewards' => number_format($rewards),
+ ]);
+ }
+ }
+ catch (\Exception $e) {
+ // Fall back to the generic warning below.
+ }
+ }
+
+ return $this->t('This experiment has not collected any data yet, so nothing will be lost. This action cannot be undone.');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function submitForm(array &$form, FormStateInterface $form_state) {
+ $entity = $this->getEntity();
+
+ // Purge analytics first. If purging fails, the config entity is left
+ // intact as a recovery anchor and the deletion is aborted.
+ if ($entity instanceof VariantExperimentInterface) {
+ $this->experimentManager->purgeExperiment($entity->getRlExperimentId());
+ }
+
+ parent::submitForm($form, $form_state);
+ }
+
+}
diff --git a/src/Experiment/VariantExperimentInterface.php b/src/Experiment/VariantExperimentInterface.php
new file mode 100644
index 0000000..e36e144
--- /dev/null
+++ b/src/Experiment/VariantExperimentInterface.php
@@ -0,0 +1,73 @@
+
+ */
+ protected array $resultCache = [];
+
+ /**
+ * Per-request set of RL experiment IDs already verified as registered.
+ *
+ * Avoids hitting the registry twice for the same experiment within a
+ * single request.
+ *
+ * @var array
+ */
+ protected array $registrationVerified = [];
+
+ /**
+ * Constructs a VariantSelectorBase.
+ */
+ public function __construct(
+ EntityTypeManagerInterface $entity_type_manager,
+ ExperimentManagerInterface $experiment_manager,
+ CacheManager $cache_manager,
+ ExperimentRegistryInterface $experiment_registry,
+ ) {
+ $this->entityTypeManager = $entity_type_manager;
+ $this->experimentManager = $experiment_manager;
+ $this->cacheManager = $cache_manager;
+ $this->experimentRegistry = $experiment_registry;
+ }
+
+ /**
+ * The owner module name to use when (re-)registering an experiment.
+ *
+ * Used by self-healing registration in selectForTarget(): if the entity
+ * exists but the registry row was lost (e.g., the user purged analytics
+ * via Drush without deleting the entity), we re-register on the next
+ * page load so the rl.php tracking endpoint accepts the experiment.
+ */
+ abstract protected function ownerModule(): string;
+
+ /**
+ * Page cache TTL applied while an experiment is active on the page.
+ *
+ * Subclasses may override to expose a different TTL.
+ */
+ protected function cacheTtl(): int {
+ return 60;
+ }
+
+ /**
+ * The entity type ID of the experiment content entity to load.
+ */
+ abstract protected function entityTypeId(): string;
+
+ /**
+ * The fully-qualified class name of the experiment entity.
+ *
+ * Used for narrow type assertions on loaded entities.
+ */
+ abstract protected function entityClass(): string;
+
+ /**
+ * The property name on the experiment entity that holds the lookup target.
+ *
+ * For rl_page_title this is `path`; for rl_menu_link it is
+ * `menu_link_plugin_id`.
+ */
+ abstract protected function targetProperty(): string;
+
+ /**
+ * Look up the variant selection for a given target and language.
+ *
+ * Tries the language-specific match first, then falls back to
+ * LANGCODE_NOT_SPECIFIED ("all languages"). Returns NULL if neither
+ * lookup finds an enabled experiment.
+ *
+ * @param string $target
+ * The target value (path, plugin ID, etc.) to look up.
+ * @param string $langcode
+ * The current language code.
+ *
+ * @return array|null
+ * Array with keys experiment_id, arm_id, text (NULL means original);
+ * or NULL if no enabled experiment matches.
+ */
+ public function selectForTarget(string $target, string $langcode = LanguageInterface::LANGCODE_NOT_SPECIFIED): ?array {
+ $cache_key = $target . '|' . $langcode;
+ if (isset($this->resultCache[$cache_key])) {
+ $cached = $this->resultCache[$cache_key];
+ return $cached === FALSE ? NULL : $cached;
+ }
+
+ // Language-specific lookup first.
+ $experiment = $this->loadExperimentByTarget($target, $langcode);
+ // Fall back to "all languages" experiment if no language-specific match.
+ if ($experiment === NULL && $langcode !== LanguageInterface::LANGCODE_NOT_SPECIFIED) {
+ $experiment = $this->loadExperimentByTarget($target, LanguageInterface::LANGCODE_NOT_SPECIFIED);
+ }
+ if ($experiment === NULL) {
+ $this->resultCache[$cache_key] = FALSE;
+ return NULL;
+ }
+
+ $rl_experiment_id = $experiment->getRlExperimentId();
+
+ // Self-healing registration: the entity is the source of truth for
+ // whether an experiment exists, but the rl.php tracking endpoint
+ // checks the registry table. If a user purged analytics via Drush
+ // without deleting the entity, the registry row is gone but the
+ // entity remains, and tracking would silently no-op. Re-registering
+ // here on the next page load brings the registry back in sync.
+ // Idempotent: register() is an upsert, and we cache the verification
+ // for the rest of the request to avoid extra DB writes.
+ if (!isset($this->registrationVerified[$rl_experiment_id])) {
+ if (!$this->experimentRegistry->isRegistered($rl_experiment_id)) {
+ $this->experimentRegistry->register(
+ $rl_experiment_id,
+ $this->ownerModule(),
+ (string) $experiment->label(),
+ );
+ }
+ $this->registrationVerified[$rl_experiment_id] = TRUE;
+ }
+
+ $arm_ids = $experiment->getArmIds();
+ $scores = $this->experimentManager->getThompsonScores($rl_experiment_id, NULL, $arm_ids);
+ arsort($scores);
+ $best_arm = (string) key($scores);
+
+ // Shorten cache so variants rotate.
+ $this->cacheManager->overridePageCacheIfShorter($this->cacheTtl());
+
+ $result = [
+ 'experiment_id' => $rl_experiment_id,
+ 'arm_id' => $best_arm,
+ 'text' => $experiment->getArmText($best_arm),
+ ];
+ $this->resultCache[$cache_key] = $result;
+ return $result;
+ }
+
+ /**
+ * Load an enabled experiment by target value and langcode.
+ *
+ * @return \Drupal\rl\Experiment\VariantExperimentInterface|null
+ * The matching experiment, or NULL if none is enabled for this target.
+ */
+ protected function loadExperimentByTarget(string $target, string $langcode): ?VariantExperimentInterface {
+ $storage = $this->entityTypeManager->getStorage($this->entityTypeId());
+ $matches = $storage->loadByProperties([
+ $this->targetProperty() => $target,
+ 'langcode' => $langcode,
+ 'enabled' => TRUE,
+ ]);
+ if (!$matches) {
+ return NULL;
+ }
+ $entity = reset($matches);
+ $class = $this->entityClass();
+ if (!($entity instanceof VariantExperimentInterface) || !($entity instanceof $class)) {
+ return NULL;
+ }
+ return $entity;
+ }
+
+}
diff --git a/src/Service/ExperimentManager.php b/src/Service/ExperimentManager.php
index ebd2c8b..28ca3f7 100644
--- a/src/Service/ExperimentManager.php
+++ b/src/Service/ExperimentManager.php
@@ -172,6 +172,58 @@ public function getThompsonScores($experiment_id, $time_window_seconds = NULL, a
return $scores;
}
+ /**
+ * {@inheritdoc}
+ */
+ public function getTotalTurnsMultiple(array $experiment_ids): array {
+ return $this->storage->getTotalTurnsMultiple($experiment_ids);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAllArmsDataMultiple(array $experiment_ids): array {
+ return $this->storage->getAllArmsDataMultiple($experiment_ids);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function purgeExperiment($experiment_id) {
+ // Start a transaction. If any of the deletes throw, we let the exception
+ // propagate; the transaction manager rolls back automatically when the
+ // transaction object goes out of scope without being committed. This is
+ // the recommended pattern in Drupal 10.2+; explicit rollBack() calls are
+ // deprecated.
+ $transaction = $this->database->startTransaction();
+ try {
+ $this->database->delete('rl_arm_data')
+ ->condition('experiment_id', $experiment_id)
+ ->execute();
+ $this->database->delete('rl_experiment_totals')
+ ->condition('experiment_id', $experiment_id)
+ ->execute();
+ $this->database->delete('rl_arm_snapshots')
+ ->condition('experiment_id', $experiment_id)
+ ->execute();
+ $this->database->delete('rl_experiment_registry')
+ ->condition('experiment_id', $experiment_id)
+ ->execute();
+ }
+ catch (\Exception $e) {
+ $this->loggerFactory->get('rl')->error('Failed to purge experiment @id: @message', [
+ '@id' => $experiment_id,
+ '@message' => $e->getMessage(),
+ ]);
+ // Re-throw so the transaction is rolled back when $transaction is
+ // destructed and so callers know the operation failed.
+ throw $e;
+ }
+ // Reference $transaction to ensure it stays in scope until the deletes
+ // complete; the transaction commits when this variable goes out of scope.
+ unset($transaction);
+ }
+
/**
* Logs Thompson Sampling scores for debugging.
*
diff --git a/src/Service/ExperimentManagerInterface.php b/src/Service/ExperimentManagerInterface.php
index 9596800..1d44388 100644
--- a/src/Service/ExperimentManagerInterface.php
+++ b/src/Service/ExperimentManagerInterface.php
@@ -72,6 +72,29 @@ public function getAllArmsData($experiment_id);
*/
public function getTotalTurns($experiment_id);
+ /**
+ * Gets total turns for a batch of experiments in one query.
+ *
+ * @param string[] $experiment_ids
+ * The experiment IDs to look up.
+ *
+ * @return array
+ * Total turns keyed by experiment ID; missing experiments have value 0.
+ */
+ public function getTotalTurnsMultiple(array $experiment_ids): array;
+
+ /**
+ * Gets arms data for a batch of experiments in one query.
+ *
+ * @param string[] $experiment_ids
+ * The experiment IDs to look up.
+ *
+ * @return array
+ * Outer array keyed by experiment ID; inner array is arm data objects
+ * keyed by arm_id (same shape as getAllArmsData()).
+ */
+ public function getAllArmsDataMultiple(array $experiment_ids): array;
+
/**
* Gets Thompson Sampling scores for all arms in an experiment.
*
@@ -90,4 +113,16 @@ public function getTotalTurns($experiment_id);
*/
public function getThompsonScores($experiment_id, $time_window_seconds = NULL, array $requested_arms = []);
+ /**
+ * Purges all data for an experiment.
+ *
+ * Removes turns, rewards, totals, snapshots, and the registry entry for the
+ * given experiment ID. Used when a consumer module deletes or retargets an
+ * experiment and needs to clean up the analytics tables transactionally.
+ *
+ * @param string $experiment_id
+ * The experiment ID to purge.
+ */
+ public function purgeExperiment($experiment_id);
+
}
diff --git a/src/Storage/ExperimentDataStorage.php b/src/Storage/ExperimentDataStorage.php
index 63f7b60..2e9708f 100644
--- a/src/Storage/ExperimentDataStorage.php
+++ b/src/Storage/ExperimentDataStorage.php
@@ -189,6 +189,44 @@ public function getTotalTurns($experiment_id) {
return $result ? (int) $result : 0;
}
+ /**
+ * {@inheritdoc}
+ */
+ public function getTotalTurnsMultiple(array $experiment_ids): array {
+ if (empty($experiment_ids)) {
+ return [];
+ }
+ $rows = $this->database->select('rl_experiment_totals', 'et')
+ ->fields('et', ['experiment_id', 'total_turns'])
+ ->condition('experiment_id', $experiment_ids, 'IN')
+ ->execute()
+ ->fetchAllKeyed();
+ $result = [];
+ foreach ($experiment_ids as $id) {
+ $result[$id] = isset($rows[$id]) ? (int) $rows[$id] : 0;
+ }
+ return $result;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getAllArmsDataMultiple(array $experiment_ids): array {
+ $result = array_fill_keys($experiment_ids, []);
+ if (empty($experiment_ids)) {
+ return $result;
+ }
+ $rows = $this->database->select('rl_arm_data', 'ad')
+ ->fields('ad', ['experiment_id', 'arm_id', 'turns', 'rewards', 'created', 'updated'])
+ ->condition('experiment_id', $experiment_ids, 'IN')
+ ->execute()
+ ->fetchAll();
+ foreach ($rows as $row) {
+ $result[$row->experiment_id][$row->arm_id] = $row;
+ }
+ return $result;
+ }
+
/**
* Record snapshots for arms if event logging is enabled.
*
diff --git a/src/Storage/ExperimentDataStorageInterface.php b/src/Storage/ExperimentDataStorageInterface.php
index bfd08d0..afa3edc 100644
--- a/src/Storage/ExperimentDataStorageInterface.php
+++ b/src/Storage/ExperimentDataStorageInterface.php
@@ -75,6 +75,31 @@ public function getAllArmsData($experiment_id, $time_window_seconds = NULL);
*/
public function getTotalTurns($experiment_id);
+ /**
+ * Gets total turns for a batch of experiments in a single query.
+ *
+ * @param string[] $experiment_ids
+ * The experiment IDs to look up.
+ *
+ * @return array
+ * Total turns keyed by experiment ID. Experiments with no recorded turns
+ * are present in the result with value 0.
+ */
+ public function getTotalTurnsMultiple(array $experiment_ids): array;
+
+ /**
+ * Gets all-arms data for a batch of experiments in a single query.
+ *
+ * @param string[] $experiment_ids
+ * The experiment IDs to look up.
+ *
+ * @return array
+ * Outer array keyed by experiment ID; inner array is the arm data
+ * objects keyed by arm_id (same shape as getAllArmsData()). Experiments
+ * with no arms are present in the result with an empty inner array.
+ */
+ public function getAllArmsDataMultiple(array $experiment_ids): array;
+
/**
* Gets all experiments with their statistics for the overview page.
*