From ce2d139f110309e8af2dfe392700cd8c746caa9b Mon Sep 17 00:00:00 2001 From: Jurriaan Roelofs Date: Mon, 11 Aug 2025 07:38:47 +0200 Subject: [PATCH 1/5] lint --- .github/workflows/review.yml | 27 ++++++++ README.md | 10 ++- ai_sorting.info.yml | 2 +- ai_sorting.libraries.yml | 2 +- ai_sorting.module | 32 ++++++---- ai_sorting.permissions.yml | 2 +- ai_sorting.services.yml | 1 - docker-compose.yml | 32 ++++++++++ phpcs.xml | 22 +++++++ scripts/prepare-drupal-lint.sh | 33 ++++++++++ scripts/run-drupal-check.sh | 38 +++++++++++ scripts/run-drupal-lint-auto-fix.sh | 13 ++++ scripts/run-drupal-lint.sh | 42 ++++++++++++ .../AiSortingExperimentDecorator.php | 13 ++-- src/Plugin/views/sort/AISorting.php | 64 ++++++++++--------- src/Service/ExperimentRegistrationService.php | 2 +- src/Service/ExperimentResolver.php | 2 +- src/Service/TotalTrialsService.php | 2 +- 18 files changed, 278 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/review.yml create mode 100644 docker-compose.yml create mode 100644 phpcs.xml create mode 100755 scripts/prepare-drupal-lint.sh create mode 100755 scripts/run-drupal-check.sh create mode 100755 scripts/run-drupal-lint-auto-fix.sh create mode 100755 scripts/run-drupal-lint.sh diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml new file mode 100644 index 0000000..afc1ddf --- /dev/null +++ b/.github/workflows/review.yml @@ -0,0 +1,27 @@ +name: Review + +on: [pull_request] + +env: + TARGET_DRUPAL_CORE_VERSION: 11 + +jobs: + drupal-lint: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - name: Lint Drupal + run: | + docker compose --profile lint run drupal-lint + + drupal-check: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - name: Check Drupal compatibility + run: | + docker compose --profile lint run drupal-check diff --git a/README.md b/README.md index 7241327..4f4e665 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # AI Sorting -Intelligent content ordering for Drupal Views using machine learning. Content automatically learns which items engage users most and surfaces the best-performing content. +Intelligent content ordering for Drupal Views using machine learning. Content +automatically learns which items engage users most and surfaces the +best-performing content. ## Features @@ -36,7 +38,8 @@ Intelligent content ordering for Drupal Views using machine learning. Content au ## Configuration - **Cache Lifetime** - How often content order refreshes -- **Automatic Cache Setup** - Views cache automatically configured for optimal performance +- **Automatic Cache Setup** - Views cache automatically configured for optimal + performance ## Dependencies @@ -44,4 +47,5 @@ Intelligent content ordering for Drupal Views using machine learning. Content au ## Related Modules -- [RL module](https://www.drupal.org/project/rl) - Core Thompson Sampling algorithm and API for developers \ No newline at end of file +- [RL module](https://www.drupal.org/project/rl) - Core Thompson Sampling + algorithm and API for developers diff --git a/ai_sorting.info.yml b/ai_sorting.info.yml index 1da7996..5711118 100644 --- a/ai_sorting.info.yml +++ b/ai_sorting.info.yml @@ -5,4 +5,4 @@ core_version_requirement: ^10.3 | ^11 package: Custom dependencies: - drupal:views - - rl \ No newline at end of file + - rl diff --git a/ai_sorting.libraries.yml b/ai_sorting.libraries.yml index 88ecedb..bad7f68 100644 --- a/ai_sorting.libraries.yml +++ b/ai_sorting.libraries.yml @@ -12,4 +12,4 @@ ai_sorting_rewards: js/ai-sorting-rewards.js: {} dependencies: - core/drupal - - core/drupalSettings \ No newline at end of file + - core/drupalSettings diff --git a/ai_sorting.module b/ai_sorting.module index b44375c..1e13510 100644 --- a/ai_sorting.module +++ b/ai_sorting.module @@ -5,6 +5,7 @@ * Primary module file for AI Sorting. */ +use Drupal\node\NodeInterface; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\views\ViewExecutable; use Drupal\Core\Url; @@ -32,25 +33,28 @@ function ai_sorting_views_pre_render(ViewExecutable $view) { return; } - // Collect node IDs and URLs for JavaScript tracking + // Collect node IDs and URLs for JavaScript tracking. $nids = []; $nid_url_map = []; - + foreach ($view->result as $index => $row) { - + $nid = NULL; - - // Try different ways to get the node ID + + // Try different ways to get the node ID. if (isset($row->nid)) { $nid = $row->nid; - } elseif (isset($row->node_field_data_nid)) { + } + elseif (isset($row->node_field_data_nid)) { $nid = $row->node_field_data_nid; - } elseif (isset($row->_entity) && $row->_entity instanceof \Drupal\node\NodeInterface) { + } + elseif (isset($row->_entity) && $row->_entity instanceof NodeInterface) { $nid = $row->_entity->id(); - } elseif (isset($row->_object) && method_exists($row->_object, 'id')) { + } + elseif (isset($row->_object) && method_exists($row->_object, 'id')) { $nid = $row->_object->id(); } - + if ($nid) { $nids[] = $nid; $nid_url_map[$nid] = Url::fromRoute('entity.node.canonical', ['node' => $nid])->toString(); @@ -58,18 +62,18 @@ function ai_sorting_views_pre_render(ViewExecutable $view) { } if (!empty($nids)) { - // Generate experiment UUID from view and display + // Generate experiment UUID from view and display. $experiment_uuid = sha1($view->id() . ':' . $view->current_display); - // Register this experiment with the RL module + // Register this experiment with the RL module. $registration_service = \Drupal::service('ai_sorting.experiment_registration'); $registration_service->registerExperiment($experiment_uuid); - // Get RL module path for optimized endpoint + // Get RL module path for optimized endpoint. $rl_path = \Drupal::service('extension.list.module')->getPath('rl'); $base_path = \Drupal::request()->getBasePath(); - // Attach JavaScript libraries and settings + // Attach JavaScript libraries and settings. $view->element['#attached']['library'][] = 'ai_sorting/ai_sorting_turns'; $view->element['#attached']['library'][] = 'ai_sorting/ai_sorting_rewards'; $view->element['#attached']['drupalSettings']['aiSorting']['views'][$view->id()] = [ @@ -93,4 +97,4 @@ function ai_sorting_views_data_alter(array &$data) { 'id' => 'ai_sorting', ], ]; -} \ No newline at end of file +} diff --git a/ai_sorting.permissions.yml b/ai_sorting.permissions.yml index 89f3f43..8cf3fa3 100644 --- a/ai_sorting.permissions.yml +++ b/ai_sorting.permissions.yml @@ -1,3 +1,3 @@ administer ai sorting: title: 'Administer AI Sorting' - description: 'Configure AI sorting parameters in Views' \ No newline at end of file + description: 'Configure AI sorting parameters in Views' diff --git a/ai_sorting.services.yml b/ai_sorting.services.yml index 00e75f9..51508ef 100644 --- a/ai_sorting.services.yml +++ b/ai_sorting.services.yml @@ -15,4 +15,3 @@ services: ai_sorting.experiment_registration: class: Drupal\ai_sorting\Service\ExperimentRegistrationService arguments: ['@rl.experiment_registry'] - diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..091c2a1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + + drupal-lint: + image: composer:2.7 + profiles: ["lint"] + working_dir: /src + command: bash -c "./scripts/run-drupal-lint.sh" + environment: + TARGET_DRUPAL_CORE_VERSION: 11 + volumes: + - .:/src + + drupal-lint-auto-fix: + image: composer:2.7 + profiles: ["lint"] + working_dir: /src + command: bash -c "./scripts/run-drupal-lint-auto-fix.sh" + environment: + TARGET_DRUPAL_CORE_VERSION: 11 + volumes: + - .:/src + + drupal-check: + image: composer:2.7 + profiles: ["lint"] + working_dir: / + command: bash -c "/src/scripts/run-drupal-check.sh" + tty: true + environment: + DRUPAL_RECOMMENDED_PROJECT: 11.2.x-dev + volumes: + - .:/src diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..8ffbb22 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,22 @@ + + + Drupal 11 coding standards for module. + . + + + + */.git/* + */config/* + */css/* + */js/* + */node_modules/* + */vendor/* + */.github/* + + + + + + + + diff --git a/scripts/prepare-drupal-lint.sh b/scripts/prepare-drupal-lint.sh new file mode 100755 index 0000000..3849771 --- /dev/null +++ b/scripts/prepare-drupal-lint.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -e + +if [ -z "$TARGET_DRUPAL_CORE_VERSION" ]; then + # default to target Drupal 11, you can override this by setting the secrets value on your github repo + TARGET_DRUPAL_CORE_VERSION=11 +fi + +echo "php --version" +php --version +echo "composer --version" +composer --version + +echo "\$COMPOSER_HOME: $COMPOSER_HOME" +echo "TARGET_DRUPAL_CORE_VERSION: $TARGET_DRUPAL_CORE_VERSION" + +# Add this line to avoid the plugin prompt +composer config --global allow-plugins.dealerdirect/phpcodesniffer-composer-installer true + +composer global require drupal/coder --dev +composer global require phpcompatibility/php-compatibility --dev + +export PATH="$PATH:$COMPOSER_HOME/vendor/bin" + +composer global require dealerdirect/phpcodesniffer-composer-installer --dev + +composer global show -P +phpcs -i + +phpcs --config-set colors 1 +phpcs --config-set drupal_core_version $TARGET_DRUPAL_CORE_VERSION + +phpcs --config-show \ No newline at end of file diff --git a/scripts/run-drupal-check.sh b/scripts/run-drupal-check.sh new file mode 100755 index 0000000..41ee929 --- /dev/null +++ b/scripts/run-drupal-check.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -vo pipefail + +DRUPAL_RECOMMENDED_PROJECT=${DRUPAL_RECOMMENDED_PROJECT:-10.3.x-dev} +PHP_EXTENSIONS="gd" +DRUPAL_CHECK_TOOL="mglaman/drupal-check" + +# Install required PHP extensions +for ext in $PHP_EXTENSIONS; do + if ! php -m | grep -q $ext; then + apk update && apk add --no-cache ${ext}-dev + docker-php-ext-install $ext + fi +done + +# Create Drupal project if it doesn't exist +if [ ! -d "/drupal" ]; then + composer create-project drupal/recommended-project=$DRUPAL_RECOMMENDED_PROJECT drupal --no-interaction --stability=dev +fi + +cd drupal +mkdir -p web/modules/contrib/ + +# Symlink analyze_ai_brand_voice if not already linked +if [ ! -L "web/modules/contrib/analyze_ai_brand_voice" ]; then + ln -s /src web/modules/contrib/analyze_ai_brand_voice +fi + +# Install the statistic modules if D11 (removed from core). +if [[ $DRUPAL_RECOMMENDED_PROJECT == 11.* ]]; then + composer require drupal/statistics +fi + +# Install drupal-check +composer require $DRUPAL_CHECK_TOOL --dev + +# Run drupal-check +./vendor/bin/drupal-check --drupal-root . -ad web/modules/contrib/analyze_ai_brand_voice \ No newline at end of file diff --git a/scripts/run-drupal-lint-auto-fix.sh b/scripts/run-drupal-lint-auto-fix.sh new file mode 100755 index 0000000..f796ef5 --- /dev/null +++ b/scripts/run-drupal-lint-auto-fix.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +source scripts/prepare-drupal-lint.sh + +phpcbf --standard=Drupal \ + --extensions=php,module,inc,install,test,profile,theme,info,txt,md,yml \ + --ignore=node_modules,ai_sorting/vendor,.github,vendor \ + . + +phpcbf --standard=DrupalPractice \ + --extensions=php,module,inc,install,test,profile,theme,info,txt,md,yml \ + --ignore=node_modules,ai_sorting/vendor,.github,vendor \ + . \ No newline at end of file diff --git a/scripts/run-drupal-lint.sh b/scripts/run-drupal-lint.sh new file mode 100755 index 0000000..1624b52 --- /dev/null +++ b/scripts/run-drupal-lint.sh @@ -0,0 +1,42 @@ +#!/bin/bash +source scripts/prepare-drupal-lint.sh + +EXIT_CODE=0 + +echo "---- Checking with PHPCompatibility PHP 8.3 and up ----" +phpcs --standard=PHPCompatibility \ + --runtime-set testVersion 8.3- \ + --extensions=php,module,inc,install,test,profile,theme,info,txt,md,yml \ + --ignore=node_modules,ai_sorting/vendor,.github,vendor \ + -v \ + . +status=$? +if [ $status -ne 0 ]; then + EXIT_CODE=$status +fi + +echo "---- Checking with Drupal standard... ----" +phpcs --standard=Drupal \ + --extensions=php,module,inc,install,test,profile,theme,info,txt,md,yml \ + --ignore=node_modules,ai_sorting/vendor,.github,vendor \ + -v \ + . +status=$? +if [ $status -ne 0 ]; then + EXIT_CODE=$status +fi + +echo "---- Checking with DrupalPractice standard... ----" +phpcs --standard=DrupalPractice \ + --extensions=php,module,inc,install,test,profile,theme,info,txt,md,yml \ + --ignore=node_modules,ai_sorting/vendor,.github,vendor \ + -v \ + . + +status=$? +if [ $status -ne 0 ]; then + EXIT_CODE=$status +fi + +# Exit with failure if any of the checks failed +exit $EXIT_CODE \ No newline at end of file diff --git a/src/Decorator/AiSortingExperimentDecorator.php b/src/Decorator/AiSortingExperimentDecorator.php index c31cd29..3b035f4 100644 --- a/src/Decorator/AiSortingExperimentDecorator.php +++ b/src/Decorator/AiSortingExperimentDecorator.php @@ -6,7 +6,6 @@ use Drupal\Core\Link; use Drupal\Core\Url; use Drupal\rl\Decorator\ExperimentDecoratorInterface; -use Drupal\views\ViewEntityInterface; /** * Decorator service for AI Sorting experiments. @@ -43,10 +42,10 @@ public function decorateExperiment(string $uuid): ?array { foreach ($view->get('display') as $display_id => $display) { $test_uuid = sha1($view->id() . ':' . $display_id); if ($test_uuid === $uuid) { - // Found matching view and display + // Found matching view and display. $view_url = Url::fromRoute('entity.view.edit_form', ['view' => $view->id()]); $view_link = Link::fromTextAndUrl($view->label(), $view_url); - + return [ '#markup' => $view_link->toString() . ' (' . $display_id . ')', ]; @@ -61,25 +60,25 @@ public function decorateExperiment(string $uuid): ?array { * {@inheritdoc} */ public function decorateArm(string $experiment_uuid, string $arm_id): ?array { - // For AI Sorting, arm_id should be a node ID + // For AI Sorting, arm_id should be a node ID. if (is_numeric($arm_id)) { try { $node = $this->entityTypeManager->getStorage('node')->load($arm_id); if ($node) { $node_url = Url::fromRoute('entity.node.canonical', ['node' => $node->id()]); $node_link = Link::fromTextAndUrl($node->label(), $node_url); - + return [ '#markup' => $node_link->toString(), ]; } } catch (\Exception $e) { - // If node loading fails, fall back to raw arm_id + // If node loading fails, fall back to raw arm_id. } } return NULL; } -} \ No newline at end of file +} diff --git a/src/Plugin/views/sort/AISorting.php b/src/Plugin/views/sort/AISorting.php index 9cb28eb..5107cde 100644 --- a/src/Plugin/views/sort/AISorting.php +++ b/src/Plugin/views/sort/AISorting.php @@ -9,7 +9,6 @@ use Drupal\rl\Service\ExperimentManagerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\RequestStack; -use Symfony\Component\HttpFoundation\Response; use Drupal\Core\Logger\LoggerChannelFactoryInterface; /** @@ -82,8 +81,10 @@ public static function create(ContainerInterface $container, array $configuratio */ protected function defineOptions() { $options = parent::defineOptions(); - $options['order'] = ['default' => '']; // We don't use this, but prevents warnings. - $options['cache_max_age'] = ['default' => 60]; // Default max-age set to 60 seconds. + // We don't use this, but prevents warnings. + $options['order'] = ['default' => '']; + // Default max-age set to 60 seconds. + $options['cache_max_age'] = ['default' => 60]; return $options; } @@ -94,26 +95,26 @@ public function query() { try { $this->ensureMyTable(); - // Generate experiment UUID from view and display + // Generate experiment UUID from view and display. $experiment_uuid = sha1($this->view->id() . ':' . $this->view->current_display); - // Get Thompson Sampling scores from RL module + // Get Thompson Sampling scores from RL module. $scores = $this->experimentManager->getUCB1Scores($experiment_uuid); if (empty($scores)) { - // No data yet, fall back to random order + // No data yet, fall back to random order. $this->query->addOrderBy(NULL, 'RAND()', 'DESC', 'ai_sorting_fallback'); return; } - // Build a CASE statement to order by UCB1 scores + // Build a CASE statement to order by UCB1 scores. $case_statement = 'CASE ' . $this->tableAlias . '.nid '; foreach ($scores as $nid => $score) { $case_statement .= "WHEN " . (int) $nid . " THEN " . (float) $score . " "; } $case_statement .= 'ELSE 0 END'; - // Add small random noise to break ties + // Add small random noise to break ties. $order_formula = $case_statement . ' + (RAND() * 0.000001)'; $this->query->addOrderBy( @@ -123,10 +124,11 @@ public function query() { 'ai_sorting_score' ); - // Disable dynamic page cache for AI sorting + // Disable dynamic page cache for AI sorting. \Drupal::service('page_cache_kill_switch')->trigger(); - } catch (\Exception $e) { + } + catch (\Exception $e) { $logger = $this->loggerFactory->get('ai_sorting'); $logger->error('Error in AI Sorting query(): @message', ['@message' => $e->getMessage()]); $logger->error('Stack trace: @trace', ['@trace' => $e->getTraceAsString()]); @@ -134,14 +136,13 @@ public function query() { } } - /** * {@inheritdoc} */ public function buildOptionsForm(&$form, FormStateInterface $form_state) { parent::buildOptionsForm($form, $form_state); - - // Remove the order selector since we always use DESC for UCB1 scores + + // Remove the order selector since we always use DESC for UCB1 scores. unset($form['order']); $form['ai_sorting_settings'] = [ @@ -172,7 +173,6 @@ public function buildOptionsForm(&$form, FormStateInterface $form_state) { '#open' => FALSE, ]; - $form['ai_sorting_settings']['advanced']['cache_max_age'] = [ '#type' => 'select', '#title' => $this->t('Browser and proxy cache maximum age'), @@ -198,38 +198,39 @@ public function submitOptionsForm(&$form, FormStateInterface $form_state) { $options = &$form_state->getValue('options'); - // Save the cache_max_age value + // Save the cache_max_age value. if (isset($options['ai_sorting_settings']['advanced']['cache_max_age'])) { $this->options['cache_max_age'] = $options['ai_sorting_settings']['advanced']['cache_max_age']; } - // Auto-configure views cache to match AI sorting settings + // Auto-configure views cache to match AI sorting settings. $cache_max_age = $this->options['cache_max_age'] ?? 60; $current_cache = $this->view->display_handler->getOption('cache'); - + if ($cache_max_age > 0) { - // Set time-based cache matching our AI sorting refresh rate + // Set time-based cache matching our AI sorting refresh rate. if ($current_cache['type'] !== 'time' || $current_cache['options']['output_lifespan'] != $cache_max_age) { $this->view->display_handler->setOption('cache', [ 'type' => 'time', 'options' => [ 'output_lifespan' => $cache_max_age, 'results_lifespan' => $cache_max_age, - ] + ], ]); - + \Drupal::messenger()->addStatus($this->t('Views cache has been automatically set to @seconds seconds to match your AI sorting refresh rate.', ['@seconds' => $cache_max_age])); } - } else { - // Disable cache when AI sorting cache is set to 0 + } + else { + // Disable cache when AI sorting cache is set to 0. if ($current_cache['type'] !== 'none') { $this->view->display_handler->setOption('cache', ['type' => 'none']); - + \Drupal::messenger()->addWarning($this->t('Views cache has been automatically disabled because AI sorting cache is set to "Never cache".')); } } - // Clear any caches if necessary + // Clear any caches if necessary. \Drupal::service('plugin.manager.views.sort')->clearCachedDefinitions(); } @@ -238,21 +239,24 @@ public function submitOptionsForm(&$form, FormStateInterface $form_state) { */ public function adminSummary() { $summary = []; - + $cache_max_age = $this->options['cache_max_age']; if ($cache_max_age == 0) { $summary[] = $this->t('Cache: Never cache'); - } elseif ($cache_max_age < 60) { + } + elseif ($cache_max_age < 60) { $summary[] = $this->t('Cache: @seconds seconds', ['@seconds' => $cache_max_age]); - } elseif ($cache_max_age < 3600) { + } + elseif ($cache_max_age < 3600) { $minutes = $cache_max_age / 60; $summary[] = $this->t('Cache: @minutes minute(s)', ['@minutes' => $minutes]); - } else { + } + else { $hours = $cache_max_age / 3600; $summary[] = $this->t('Cache: @hours hour(s)', ['@hours' => $hours]); } - + return implode(', ', $summary); } -} \ No newline at end of file +} diff --git a/src/Service/ExperimentRegistrationService.php b/src/Service/ExperimentRegistrationService.php index 54c2b5c..329bcf3 100644 --- a/src/Service/ExperimentRegistrationService.php +++ b/src/Service/ExperimentRegistrationService.php @@ -36,4 +36,4 @@ public function registerExperiment(string $uuid): void { $this->experimentRegistry->register($uuid, 'ai_sorting'); } -} \ No newline at end of file +} diff --git a/src/Service/ExperimentResolver.php b/src/Service/ExperimentResolver.php index f094edb..0b8f200 100644 --- a/src/Service/ExperimentResolver.php +++ b/src/Service/ExperimentResolver.php @@ -40,4 +40,4 @@ public function extractViewInfo($experiment_uuid) { return NULL; } -} \ No newline at end of file +} diff --git a/src/Service/TotalTrialsService.php b/src/Service/TotalTrialsService.php index e6e3dc5..d04ea81 100644 --- a/src/Service/TotalTrialsService.php +++ b/src/Service/TotalTrialsService.php @@ -57,4 +57,4 @@ public function getTotalTrials($view_id, $display_id) { return $this->experimentManager->getTotalTurns($experiment_uuid); } -} \ No newline at end of file +} From 9e1b8e292097bbd92f70f6c2ce61916f06a98404 Mon Sep 17 00:00:00 2001 From: Jurriaan Roelofs Date: Mon, 11 Aug 2025 07:38:59 +0200 Subject: [PATCH 2/5] docs --- docs/ai_sorting_project_desc.html | 21 +++++++++++++++++++-- scripts/prepare-drupal-lint.sh | 2 +- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/ai_sorting_project_desc.html b/docs/ai_sorting_project_desc.html index 2abdc55..903b755 100644 --- a/docs/ai_sorting_project_desc.html +++ b/docs/ai_sorting_project_desc.html @@ -1,6 +1,17 @@ -

About AI Sorting

+

This module is part of the AI module ecosystem and included in DXPR CMS.

+ +
Transform your content marketing with AI Sorting - the Drupal module that automatically learns which content engages your audience and promotes it for maximum impact. Stop guessing what works and let machine learning optimize your content visibility.
-

Transform your content marketing with AI Sorting - the Drupal module that automatically learns which content engages your audience and promotes it for maximum impact. Stop guessing what works and let machine learning optimize your content visibility.

+

You need AI Sorting if

+ + +

About AI Sorting

Boost Your Content Performance

@@ -68,6 +79,12 @@

Enterprise-Ready

  • Mobile Responsive - Works across all devices and platforms
  • +
    +

    Prefer a turnkey demo site?

    +

    Spin up DXPR CMS—Drupal pre-configured with DXPR Builder, DXPR Theme, AI Sorting module, and security best practices.

    +

    Get DXPR CMS »

    +
    +

    Dependencies