diff --git a/.github/workflows/docker-drupal.yml b/.github/workflows/docker-drupal.yml
index 7f8839a9..2fae7137 100644
--- a/.github/workflows/docker-drupal.yml
+++ b/.github/workflows/docker-drupal.yml
@@ -53,13 +53,7 @@ jobs:
images: |
ghcr.io/${{ github.repository }}/drupal
tags: |
- type=ref,event=pr
- type=semver,pattern={{version}}
- type=semver,pattern={{major}}.{{minor}}
- type=sha,format=long,enable=${{ github.ref_type != 'tag' }}
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
+ type=sha,format=long
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
@@ -76,6 +70,6 @@ jobs:
with:
context: app/drupal
file: app/drupal/Dockerfile
- push: ${{ github.event_name != 'pull_request' }}
+ push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
diff --git a/.github/workflows/docker-typo3.yml b/.github/workflows/docker-typo3.yml
new file mode 100644
index 00000000..62b47f38
--- /dev/null
+++ b/.github/workflows/docker-typo3.yml
@@ -0,0 +1,75 @@
+name: Docker TYPO3
+
+on:
+ push:
+ branches: main
+ paths:
+ - app/typo3/**
+ - packages/ckeditor-plugin/**
+ - packages/typo3-ckeditor-plugin/**
+ - packages/editor/**
+ - .github/workflows/docker-typo3.yml
+
+jobs:
+ docker:
+ runs-on: ubuntu-latest
+ environment: Publish
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Install pnpm package manager
+ uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
+
+ - name: Set up Node.js version
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version-file: .nvmrc
+ package-manager-cache: false
+
+ - name: Check for known security issues with npm packages
+ run: |
+ echo "Auditing npm dependencies before installing them. For more information, see: https://nldesignsystem.nl/pnpm-audit"
+ pnpm audit --audit-level critical
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile --ignore-scripts
+
+ # The clippy extension in app/typo3/packages/clippy is generated, not committed, so it has to be
+ # built before the image is built.
+ - name: Build the TYPO3 extension
+ run: pnpm run build
+
+ - name: Docker meta
+ id: meta
+ uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
+ with:
+ images: |
+ ghcr.io/${{ github.repository }}/typo3
+ tags: |
+ type=sha,format=long
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
+ with:
+ context: app/typo3
+ file: app/typo3/Dockerfile
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
diff --git a/.sonarcloud.properties b/.sonarcloud.properties
index ab7bfee0..407039f8 100644
--- a/.sonarcloud.properties
+++ b/.sonarcloud.properties
@@ -12,4 +12,4 @@ sonar.exclusions=**/index.html
#sonar.sourceEncoding=UTF-8
# Exclusions for copy-paste detection
-sonar.cpd.exclusions=**/index.html,**/*.test.ts,**/*.spec.ts,**/locales/*.ts,**/result-content.ts,**/question-content.ts
+sonar.cpd.exclusions=**/index.html,**/*.test.ts,**/*.spec.ts,**/locales/*.ts,**/result-content.ts,**/question-content.ts,app/**/wait-for-db.php
diff --git a/app/drupal/.dockerignore b/app/drupal/.dockerignore
index 27612cd0..9faecc07 100644
--- a/app/drupal/.dockerignore
+++ b/app/drupal/.dockerignore
@@ -56,8 +56,8 @@ tsconfig.tsbuildinfo
*.der
*.env
*.key
-_.pem
-.env._
+*.pem
+.env.*
# Example file exceptions
diff --git a/app/drupal/compose.yaml b/app/drupal/compose.yaml
index 29f66ebf..fa44e2b3 100644
--- a/app/drupal/compose.yaml
+++ b/app/drupal/compose.yaml
@@ -20,7 +20,7 @@ services:
restart: unless-stopped
postgres:
- image: postgres:16
+ image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-drupal}
POSTGRES_USER: ${POSTGRES_USER:-drupal}
diff --git a/app/typo3/.dockerignore b/app/typo3/.dockerignore
new file mode 100644
index 00000000..9faecc07
--- /dev/null
+++ b/app/typo3/.dockerignore
@@ -0,0 +1,97 @@
+# See https://help.github.com/ignore-files/ for more about ignoring files.
+
+# Ignore 3rd party files
+
+node_modules/
+vendor/
+.npm/
+
+# Ignore generated files
+
+build/
+coverage/
+tmp/
+
+# NOTE: do not ignore dist/ as it is used in the CKEditor 5 plugin
+
+# Ignore archives
+
+**/*.7z
+**/*.dmg
+**/*.gz
+**/*.iso
+**/*.jar
+**/*.rar
+**/*.tar
+**/*.tgz
+**/*.zip
+
+# Cache
+
+.cache/
+tsconfig.tsbuildinfo
+
+# Ignore log files
+
+*.err
+*.log
+*.tmp
+
+# Code site files
+
+.idea/
+.vs/
+.vscode/
+.code-workspace/
+*.sublime-project
+*.sublime-workspace
+
+# Operating system files
+
+**/.DS_Store
+**/Thumbs.db
+
+# Files that (sometimes) contain secrets
+
+*.der
+*.env
+*.key
+*.pem
+.env.*
+
+# Example file exceptions
+
+!.env.example
+
+# Only track the pnpm lockfile in the root
+
+yarn.lock
+package-lock.json
+pnpm-lock.yaml
+
+# Ignore generated files from Stencil
+
+components.d.ts
+
+.github/copilot-instructions.md
+
+# Ignore generated files from Astro
+
+.astro/
+
+.claude
+
+# Version control
+
+.git
+.gitignore
+
+# Docker files themselves
+
+Dockerfile
+compose.yaml
+.dockerignore
+
+# Docs (not needed in the image)
+
+README.md
diff --git a/app/typo3/.env.example b/app/typo3/.env.example
new file mode 100644
index 00000000..2a10a2b2
--- /dev/null
+++ b/app/typo3/.env.example
@@ -0,0 +1,12 @@
+POSTGRES_DB=typo3
+POSTGRES_USER=typo3
+POSTGRES_PASSWORD=typo3
+TYPO3_SETUP_ADMIN_USERNAME=admin
+TYPO3_SETUP_ADMIN_PASSWORD=Password.1
+TYPO3_SETUP_ADMIN_EMAIL=admin@example.com
+TYPO3_PROJECT_NAME=NL Design System | typo3 demo environment
+TYPO3_BASE_URL=http://localhost:8082
+TYPO3_ENCRYPTION_KEY=insecure-local-dev-encryption-key
+TYPO3_TRUSTED_HOSTS_PATTERN=localhost(:[0-9]+)?
+# A password hash, not a plaintext password: `typo3 install:password:set --dry-run` prints one.
+TYPO3_INSTALL_TOOL_PASSWORD_HASH=
diff --git a/app/typo3/Dockerfile b/app/typo3/Dockerfile
new file mode 100644
index 00000000..05d922ac
--- /dev/null
+++ b/app/typo3/Dockerfile
@@ -0,0 +1,71 @@
+FROM php:8.4-apache
+
+COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
+
+# intl is TYPO3's only requirement the base image lacks; pdo_pgsql, zip and gd are for postgres,
+# composer and image processing.
+RUN apt-get update \
+ && apt-get install --no-install-recommends --yes \
+ libfreetype6-dev \
+ libicu-dev \
+ libjpeg62-turbo-dev \
+ libpng-dev \
+ libpq-dev \
+ libzip-dev \
+ && docker-php-ext-configure gd --with-freetype --with-jpeg \
+ && docker-php-ext-install -j"$(nproc)" gd intl pdo_pgsql zip \
+ && rm -rf /var/lib/apt/lists/* \
+# The minimums TYPO3's environment check asks for.
+ && { \
+ echo 'memory_limit = 512M'; \
+ echo 'max_execution_time = 240'; \
+ echo 'max_input_vars = 1500'; \
+ echo 'upload_max_filesize = 32M'; \
+ echo 'post_max_size = 32M'; \
+ } > /usr/local/etc/php/conf.d/typo3.ini
+
+ENV COMPOSER_ALLOW_SUPERUSER=1
+
+RUN composer create-project typo3/cms-base-distribution:^14.3 /var/www/html --no-interaction --no-progress \
+ && composer clear-cache
+
+COPY packages/example/ /var/www/html/packages/example/
+COPY packages/clippy/dist/ /var/www/html/packages/clippy/
+RUN composer --working-dir=/var/www/html require --no-interaction --no-progress \
+ nl-design-system-community/example:@dev \
+ nl-design-system-community/clippy:@dev \
+ && composer clear-cache
+
+ENV TYPO3_CONTEXT=Production
+ENV TYPO3_BASE_URL=http://localhost:8082
+
+COPY setup.sh /usr/local/bin/setup.sh
+COPY check-install.php /usr/local/bin/check-install.php
+COPY wait-for-db.php /usr/local/bin/wait-for-db.php
+# Apache runs on 8080 so the process needs no root, and FallbackResource replaces the .htaccess the
+# installer writes into public/, which is not a volume and so does not survive a new container.
+RUN chmod +x /usr/local/bin/setup.sh \
+ && sed -i 's/Listen 80/Listen 8080/' /etc/apache2/ports.conf \
+ && printf '%s\n' \
+ '' \
+ ' DocumentRoot /var/www/html/public' \
+ ' FallbackResource /index.php' \
+ '' \
+ > /etc/apache2/sites-available/000-default.conf \
+ && echo "ServerName localhost" >> /etc/apache2/apache2.conf \
+ # Docker creates a missing mount point as root, which www-data may not write to.
+ && mkdir -p /var/www/html/config /var/www/html/public/fileadmin \
+ && chown -R www-data:www-data /var/log/apache2 /var/run/apache2 /var/www/html
+
+# settings.php stays writable because `typo3 setup` rewrites it; additional.php holds what must not
+# drift and stays root-owned. sites/ is kept outside config/ so startup can restore it after install.
+COPY config/ /var/www/html/config/
+COPY config/sites/ /usr/local/share/typo3/sites/
+RUN chown -R www-data:www-data /var/www/html/config \
+ && chown root:root /var/www/html/config/system/additional.php \
+ && chmod 444 /var/www/html/config/system/additional.php
+
+USER www-data
+
+ENTRYPOINT ["/usr/local/bin/setup.sh"]
+CMD ["apache2-foreground"]
diff --git a/app/typo3/README.md b/app/typo3/README.md
new file mode 100644
index 00000000..04537595
--- /dev/null
+++ b/app/typo3/README.md
@@ -0,0 +1,111 @@
+# TYPO3 demo environment
+
+## Prerequisites
+
+Install Docker.
+
+## Getting started
+
+Copy the environment file and start the containers:
+
+```shell
+cp .env.example .env
+docker compose up
+```
+
+If you change the `Dockerfile`, the startup scripts or anything under `config/`, rebuild explicitly — `docker compose up` reuses an existing local image otherwise:
+
+```shell
+docker compose up --build
+```
+
+TYPO3 installs itself automatically on first boot. When it's ready, the terminal prints where to go:
+
+```text
+ TYPO3 ready → http://localhost:8082/typo3
+ Username → admin
+ Password → the TYPO3_SETUP_ADMIN_PASSWORD from your .env
+```
+
+TYPO3 has no one-time login link, so log in with the credentials from `.env`. Note that TYPO3 enforces a password policy for strong passwords, the default of the env example meets the requirements.
+
+## Stopping
+
+```shell
+docker compose down
+```
+
+The database is preserved in a Docker volume, and startup asks the database whether TYPO3 is installed, so TYPO3 remains installed on the next `docker compose up`.
+
+To reset completely and start from scratch:
+
+```shell
+docker compose down -v
+```
+
+## Deploying
+
+The image is published to GHCR on every push to `main` by `.github/workflows/docker-typo3.yml`, tagged `sha-`. It carries its own `config/`, so it needs no persistent volume to boot: the database credentials, the site configuration and the settings that must not drift between containers all come from the image and the environment.
+
+### Environment variables the deployment must set
+
+Every variable below falls back to a default or an empty value when it is unset, so a missing secret produces a working-looking site rather than a visible failure. Check each one.
+
+| Variable | Unset |
+| ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `TYPO3_SETUP_ADMIN_PASSWORD` | Installation still succeeds, but `typo3 setup` skips creating the backend user and skips setting the install-tool password, so the site comes up with no way to log in. Both demo environments are publicly reachable, so this must also not be the well-known password from `.env.example`. |
+| `TYPO3_SETUP_ADMIN_USERNAME` | The installer falls back to `admin`. Only relevant when the password is set, since no user is created without one. |
+| `TYPO3_SETUP_ADMIN_EMAIL` | The backend user is created without an email address. |
+| `TYPO3_ENCRYPTION_KEY` | The installing container generates a random key into its own `settings.php`, which dies with the container because `config/` does not survive a rollout. Every container after it skips installation and runs with no key at all. Sessions and encrypted values break. |
+| `TYPO3_TRUSTED_HOSTS_PATTERN` | TYPO3 trusts only the server name, which the image hardcodes to `localhost`, so every request through an ingress is rejected as a host mismatch. Set a real pattern; a wildcard removes the protection rather than configuring it. |
+| `TYPO3_INSTALL_TOOL_PASSWORD_HASH` | The install tool keeps its empty default. The value is a password hash, not a password: `typo3 install:password:set --dry-run` prints one. |
+| `TYPO3_BASE_URL` | The site serves `http://localhost:8082`, the image default, instead of the deploy hostname. |
+| `TYPO3_DB_*` | `postgres`, `5432`, `typo3`, `typo3`, `typo3` — the local development values. |
+| `TYPO3_PROJECT_NAME` | The site name is `TYPO3`. |
+| `TYPO3_CONTEXT` | The image defaults to `Production`; `compose.yaml` overrides it to `Development` locally. |
+
+### State
+
+Only Postgres holds state that survives a rollout. Uploaded files are written to `public/fileadmin` inside the container and are therefore per-container unless a persistent claim is provisioned for that path — a cluster-side decision this repository deliberately does not make.
+
+## Extensions
+
+Extensions live in `./packages/`, the folder TYPO3 uses for local extensions, and are activated on startup. Each one is a Composer package, so it also has to be named in the `composer require` in the `Dockerfile`. Add the extension, add it to that line, and rebuild:
+
+```shell
+docker compose up --build
+```
+
+Edits under `Resources/Public/` are reflected immediately on page reload. Changes to PHP files need a cache flush, and a newly added `Resources/Public/` needs another rebuild to publish its assets.
+
+### Available extensions
+
+| Extension | Description |
+| --------- | ------------------------------------------------------------------------ |
+| `example` | Logs "Hello World" to the browser console on every backend page. |
+| `clippy` | Accessibility feedback and the design-system content classes in the RTE. |
+
+`clippy` is not a source folder: it is generated into `./packages/clippy/dist/` by `packages/typo3-ckeditor-plugin`, and that `dist/` is the extension root. The image build copies that `dist/`, so build it from the repository root before the first `docker compose up` — without it the build fails on the missing path:
+
+```shell
+pnpm install
+pnpm run build
+```
+
+### Editing the content classes
+
+The classes `clippy` adds to the editor's output ship as defaults in the extension's RTE preset. To override them per site in the backend, add the `Clippy` site set to the site once:
+
+_Sites → Setup →_ edit the site _→ Sets →_ add **Clippy**.
+
+_Sites → Setup →_ the site's **Settings** then lists every class under _Content classes_, and the values are stored in `config/sites//settings.yaml`. A field left empty outputs that element without a class. Without the set nothing changes: the defaults from the preset apply.
+
+## Clearing the cache
+
+After changing configuration or PHP inside the container:
+
+```shell
+docker compose exec typo3 php vendor/bin/typo3 cache:flush
+```
+
+Every TYPO3 CLI command is available that way; `php vendor/bin/typo3 list` shows them.
diff --git a/app/typo3/check-install.php b/app/typo3/check-install.php
new file mode 100644
index 00000000..6a8fe9be
--- /dev/null
+++ b/app/typo3/check-install.php
@@ -0,0 +1,36 @@
+ 65535) {
+ fwrite(STDERR, '[check-install] ERROR: invalid database host, name or port.' . PHP_EOL);
+ exit(2);
+}
+
+try {
+ $pdo = new PDO(
+ sprintf('pgsql:host=%s;port=%d;dbname=%s', $host, $port, $db),
+ $user,
+ $password,
+ [PDO::ATTR_TIMEOUT => 5, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
+ );
+ $statement = $pdo->prepare(
+ 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = ?'
+ );
+ $statement->execute([INSTALLED_TABLE]);
+ $installed = (int)$statement->fetchColumn() > 0;
+} catch (Throwable $e) {
+ fwrite(STDERR, '[check-install] ERROR: ' . $e->getMessage() . PHP_EOL);
+ exit(2);
+}
+
+fwrite(STDERR, '[check-install] ' . ($installed ? 'TYPO3 is installed.' : 'No installation found.') . PHP_EOL);
+exit($installed ? 0 : 1);
diff --git a/app/typo3/compose.yaml b/app/typo3/compose.yaml
new file mode 100644
index 00000000..5c904d0c
--- /dev/null
+++ b/app/typo3/compose.yaml
@@ -0,0 +1,51 @@
+services:
+ typo3:
+ build: .
+ ports:
+ - ${TYPO3_PORT:-8082}:8080
+ environment:
+ TYPO3_DB_DRIVER: postgres
+ TYPO3_DB_HOST: postgres
+ TYPO3_DB_PORT: 5432
+ TYPO3_DB_DBNAME: ${POSTGRES_DB:-typo3}
+ TYPO3_DB_USERNAME: ${POSTGRES_USER:-typo3}
+ TYPO3_DB_PASSWORD: ${POSTGRES_PASSWORD:-typo3}
+ TYPO3_SETUP_ADMIN_USERNAME: ${TYPO3_SETUP_ADMIN_USERNAME:-admin}
+ TYPO3_SETUP_ADMIN_PASSWORD: ${TYPO3_SETUP_ADMIN_PASSWORD:-Password.1}
+ TYPO3_SETUP_ADMIN_EMAIL: ${TYPO3_SETUP_ADMIN_EMAIL:-admin@example.com}
+ TYPO3_BASE_URL: ${TYPO3_BASE_URL:-http://localhost:8082}
+ TYPO3_PROJECT_NAME: ${TYPO3_PROJECT_NAME:-TYPO3}
+ TYPO3_ENCRYPTION_KEY: ${TYPO3_ENCRYPTION_KEY:-insecure-local-dev-encryption-key}
+ TYPO3_TRUSTED_HOSTS_PATTERN: ${TYPO3_TRUSTED_HOSTS_PATTERN:-}
+ TYPO3_INSTALL_TOOL_PASSWORD_HASH: ${TYPO3_INSTALL_TOOL_PASSWORD_HASH:-}
+ TYPO3_CONTEXT: Development
+ volumes:
+ - typo3_config:/var/www/html/config
+ - typo3_fileadmin:/var/www/html/public/fileadmin
+ - ./packages:/var/www/html/packages
+ # Typo3/composer does not recursively go through the folders, so the generated dist/ is the package root.
+ - ./packages/clippy/dist:/var/www/html/packages/clippy
+ depends_on:
+ postgres:
+ condition: service_healthy
+ restart: unless-stopped
+
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB:-typo3}
+ POSTGRES_USER: ${POSTGRES_USER:-typo3}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-typo3}
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-typo3} -d ${POSTGRES_DB:-typo3}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+volumes:
+ typo3_config:
+ typo3_fileadmin:
+ postgres_data:
diff --git a/app/typo3/config/sites/main/config.yaml b/app/typo3/config/sites/main/config.yaml
new file mode 100644
index 00000000..5213af3a
--- /dev/null
+++ b/app/typo3/config/sites/main/config.yaml
@@ -0,0 +1,15 @@
+rootPageId: 1
+base: "%env(TYPO3_BASE_URL)%"
+languages:
+ - title: English
+ enabled: true
+ languageId: 0
+ base: /
+ locale: en_US.UTF-8
+ navigationTitle: English
+ flag: us
+errorHandling: []
+routes: []
+dependencies:
+ - typo3/fluid-styled-content
+ - typo3/fluid-styled-content-css
diff --git a/app/typo3/config/sites/main/setup.typoscript b/app/typo3/config/sites/main/setup.typoscript
new file mode 100644
index 00000000..8414a53c
--- /dev/null
+++ b/app/typo3/config/sites/main/setup.typoscript
@@ -0,0 +1,11 @@
+page = PAGE
+page.10 = COA
+page.10.stdWrap.wrap =
|
+page.10.10 = CONTENT
+page.10.10 {
+ table = tt_content
+ select {
+ orderBy = sorting
+ where = {#colPos}=0
+ }
+}
diff --git a/app/typo3/config/system/additional.php b/app/typo3/config/system/additional.php
new file mode 100644
index 00000000..e92912a3
--- /dev/null
+++ b/app/typo3/config/system/additional.php
@@ -0,0 +1,16 @@
+ [
+ 'Connections' => [
+ 'Default' => [
+ 'charset' => 'utf8',
+ 'driver' => 'pdo_pgsql',
+ 'dbname' => getenv('TYPO3_DB_DBNAME') ?: 'typo3',
+ 'host' => getenv('TYPO3_DB_HOST') ?: 'postgres',
+ 'password' => getenv('TYPO3_DB_PASSWORD') ?: 'typo3',
+ 'port' => (int)(getenv('TYPO3_DB_PORT') ?: 5432),
+ 'user' => getenv('TYPO3_DB_USERNAME') ?: 'typo3',
+ ],
+ ],
+ ],
+ 'FE' => [
+ 'cacheHash' => [
+ 'enforceValidation' => true,
+ ],
+ 'disableNoCacheParameter' => true,
+ ],
+ 'GFX' => [
+ 'processor_enabled' => false,
+ ],
+ 'SYS' => [
+ 'features' => [
+ 'frontend.cache.autoTagging' => true,
+ 'security.system.enforceAllowedFileExtensions' => true,
+ ],
+ 'sitename' => getenv('TYPO3_PROJECT_NAME') ?: 'TYPO3',
+ 'UTF8filesystem' => true,
+ ],
+];
diff --git a/app/typo3/packages/example/Classes/EventListener/HelloWorld.php b/app/typo3/packages/example/Classes/EventListener/HelloWorld.php
new file mode 100644
index 00000000..70299d4b
--- /dev/null
+++ b/app/typo3/packages/example/Classes/EventListener/HelloWorld.php
@@ -0,0 +1,20 @@
+javaScriptRenderer->addJavaScriptModuleInstruction(
+ JavaScriptModuleInstruction::create('@nl-design-system-community/example/example.js'),
+ );
+ }
+}
diff --git a/app/typo3/packages/example/Configuration/JavaScriptModules.php b/app/typo3/packages/example/Configuration/JavaScriptModules.php
new file mode 100644
index 00000000..6b917839
--- /dev/null
+++ b/app/typo3/packages/example/Configuration/JavaScriptModules.php
@@ -0,0 +1,10 @@
+ [
+ 'backend',
+ ],
+ 'imports' => [
+ '@nl-design-system-community/example/' => 'EXT:example/Resources/Public/JavaScript/',
+ ],
+];
diff --git a/app/typo3/packages/example/Configuration/Services.yaml b/app/typo3/packages/example/Configuration/Services.yaml
new file mode 100644
index 00000000..ea986386
--- /dev/null
+++ b/app/typo3/packages/example/Configuration/Services.yaml
@@ -0,0 +1,8 @@
+services:
+ _defaults:
+ autowire: true
+ autoconfigure: true
+ public: false
+
+ NlDesignSystemCommunity\Example\:
+ resource: "../Classes/*"
diff --git a/app/typo3/packages/example/Resources/Public/JavaScript/example.js b/app/typo3/packages/example/Resources/Public/JavaScript/example.js
new file mode 100644
index 00000000..1e755902
--- /dev/null
+++ b/app/typo3/packages/example/Resources/Public/JavaScript/example.js
@@ -0,0 +1,2 @@
+/* global console */
+console.log('Hello World');
diff --git a/app/typo3/packages/example/composer.json b/app/typo3/packages/example/composer.json
new file mode 100644
index 00000000..6e8abce7
--- /dev/null
+++ b/app/typo3/packages/example/composer.json
@@ -0,0 +1,20 @@
+{
+ "name": "nl-design-system-community/example",
+ "description": "Example extension for the Clippy Editor integration.",
+ "license": "EUPL-1.2",
+ "type": "typo3-cms-extension",
+ "require": {
+ "typo3/cms-backend": "^14.3",
+ "typo3/cms-core": "^14.3"
+ },
+ "autoload": {
+ "psr-4": {
+ "NlDesignSystemCommunity\\Example\\": "Classes/"
+ }
+ },
+ "extra": {
+ "typo3/cms": {
+ "extension-key": "example"
+ }
+ }
+}
diff --git a/app/typo3/setup.sh b/app/typo3/setup.sh
new file mode 100755
index 00000000..5fd37c00
--- /dev/null
+++ b/app/typo3/setup.sh
@@ -0,0 +1,51 @@
+#!/bin/bash
+set -e
+
+TYPO3=/var/www/html/vendor/bin/typo3
+TYPO3_ROOT=/var/www/html
+SITE_CONFIG_TEMPLATE=/usr/local/share/typo3/sites
+
+export TYPO3_SETUP_CREATE_SITE="${TYPO3_SETUP_CREATE_SITE:-${TYPO3_BASE_URL:-http://localhost:8082}}"
+
+# Wait for the database to accept connections.
+if ! php /usr/local/bin/wait-for-db.php; then
+ echo "[setup] Database not reachable. Aborting."
+ exit 1
+fi
+
+# settings.php is baked into the image, so only the database can tell an installation from an
+# empty but reachable database.
+INSTALLED=0
+php /usr/local/bin/check-install.php || INSTALLED=$?
+
+if [[ $INSTALLED -gt 1 ]]; then
+ echo "[setup] Could not determine whether TYPO3 is installed. Aborting."
+ exit 1
+fi
+
+if [[ $INSTALLED -eq 0 ]]; then
+ echo "[setup] TYPO3 already installed. Skipping installation."
+else
+ echo "[setup] Installing TYPO3..."
+ $TYPO3 setup --no-interaction --force --server-type=apache
+
+ # The installer writes the site configuration with the base URL hardcoded, which would shadow
+ # the committed one for the lifetime of this container.
+ cp -R "${SITE_CONFIG_TEMPLATE}/." "${TYPO3_ROOT}/config/sites/"
+
+ echo "[setup] TYPO3 installed."
+fi
+
+# Set up the extensions and apply pending database changes (the database persists across deploys)
+echo "[setup] Setting up extensions and running database updates..."
+$TYPO3 extension:setup
+$TYPO3 cache:flush
+
+echo ""
+echo " TYPO3 ready → ${TYPO3_SETUP_CREATE_SITE}/typo3"
+echo " Username → ${TYPO3_SETUP_ADMIN_USERNAME:-admin}"
+echo " Password → the TYPO3_SETUP_ADMIN_PASSWORD from your .env"
+echo ""
+
+echo "[setup] Ready. Starting Apache..."
+exec "$@"
diff --git a/app/typo3/wait-for-db.php b/app/typo3/wait-for-db.php
new file mode 100644
index 00000000..710ca03d
--- /dev/null
+++ b/app/typo3/wait-for-db.php
@@ -0,0 +1,85 @@
+ 0 ? $int : $default;
+}
+
+// Writes a log to STDERR.
+function logLine(string $message): void
+{
+ fwrite(STDERR, '[wait-for-db] ' . $message . PHP_EOL);
+}
+
+// Aborts with an error message on STDERR and a non-zero exit code.
+function fail(string $message): void
+{
+ logLine('ERROR: ' . $message);
+ exit(1);
+}
+
+$user = envValue('TYPO3_DB_USERNAME', 'typo3');
+$password = envValue('TYPO3_DB_PASSWORD', 'typo3');
+$host = envValue('TYPO3_DB_HOST', 'postgres');
+$db = envValue('TYPO3_DB_DBNAME', 'typo3');
+$port = envInt('TYPO3_DB_PORT', 5432);
+
+$attempts = envInt('DB_WAIT_ATTEMPTS', 30);
+$interval = envInt('DB_WAIT_INTERVAL', 2);
+
+if (!preg_match(SAFE_IDENTIFIER_PATTERN, $host)) {
+ fail('TYPO3_DB_HOST contains invalid characters: ' . $host);
+}
+if (!preg_match(SAFE_IDENTIFIER_PATTERN, $db)) {
+ fail('TYPO3_DB_DBNAME contains invalid characters: ' . $db);
+}
+if ($port < 1 || $port > 65535) {
+ fail('TYPO3_DB_PORT is out of range: ' . $port);
+}
+if ($user === '') {
+ fail('TYPO3_DB_USERNAME may not be empty.');
+}
+
+$dsn = sprintf('pgsql:host=%s;port=%d;dbname=%s', $host, $port, $db);
+
+logLine("Waiting for database at {$host}:{$port} (db={$db})...");
+
+$connected = false;
+for ($i = 1; $i <= $attempts; $i++) {
+ try {
+ $pdo = new PDO($dsn, $user, $password, [PDO::ATTR_TIMEOUT => 2]);
+ unset($pdo);
+ $connected = true;
+ break;
+ } catch (Throwable $e) {
+ logLine("Attempt {$i}/{$attempts} failed ({$e->getMessage()}), retrying in {$interval}s...");
+ if ($i < $attempts) {
+ sleep($interval);
+ }
+ }
+}
+
+if (!$connected) {
+ fail("Database not reachable after {$attempts} attempts.");
+}
+
+logLine('Database is ready.');
diff --git a/packages/ckeditor-plugin/src/index.ts b/packages/ckeditor-plugin/src/index.ts
index ec3f4210..cea052e2 100644
--- a/packages/ckeditor-plugin/src/index.ts
+++ b/packages/ckeditor-plugin/src/index.ts
@@ -1,3 +1,16 @@
export { ClippyPlugin } from './plugin/ClippyPlugin.ts';
export { ContentClasses } from './plugin/ContentClasses.ts';
export type { ValidationResult, ValidationSeverity } from '@nl-design-system-community/editor/validators';
+// Re-exported so integration packages register into the same module instance this bundle inlines.
+export {
+ DARK_COLOR_SCHEME_CLASS,
+ THEME_CLASS,
+ matchDarkColorScheme,
+ observeAttributes,
+ prefersDarkColorScheme,
+ resolveDeclaredColorScheme,
+ setColorSchemeHost,
+ themeScopeHost,
+ watchHostColorScheme,
+} from '@nl-design-system-community/editor/color-scheme';
+export type { ColorScheme, ColorSchemeHost } from '@nl-design-system-community/editor/color-scheme';
diff --git a/packages/ckeditor-plugin/src/plugin/ClippyPlugin.ts b/packages/ckeditor-plugin/src/plugin/ClippyPlugin.ts
index 70c37bdc..0eb50ef6 100644
--- a/packages/ckeditor-plugin/src/plugin/ClippyPlugin.ts
+++ b/packages/ckeditor-plugin/src/plugin/ClippyPlugin.ts
@@ -3,7 +3,7 @@ import {
type CloseValidationsDrawerDetail,
type ValidationsDrawer,
} from '@nl-design-system-community/editor/accessibility-notifications';
-import { applyColorScheme } from '@nl-design-system-community/editor/color-scheme';
+import { setDarkColorScheme, watchHostColorScheme } from '@nl-design-system-community/editor/color-scheme';
import { EditorContentWrapper, EditorWrapper } from '@nl-design-system-community/editor/editor-wrapper';
import {
CustomEvents,
@@ -42,6 +42,7 @@ export class ClippyPlugin extends Plugin {
private _notificationsView: View | null = null;
private _validationsMap: ValidationsMap = new Map();
private _settings: EditorSettings = DEFAULT_SETTINGS;
+ private _unwatchColorScheme: (() => void) | null = null;
// Scope drawer events to this editor so multiple CKEditors on one page don't open each other's drawer.
private get _identifier(): string {
@@ -67,10 +68,12 @@ export class ClippyPlugin extends Plugin {
return;
}
+ const editorEl = this._editorEl;
// add theme token scoping for the drupal environment.
- this._editorEl.classList.add('ma-theme', 'clippy-theme', 'utrecht-theme');
- // follow the user's preferred color scheme by toggling the dark modifier on this editor.
- applyColorScheme(this._editorEl);
+ editorEl.classList.add('ma-theme', 'clippy-theme', 'utrecht-theme');
+ this._unwatchColorScheme = watchHostColorScheme(editorEl, (colorScheme) =>
+ setDarkColorScheme(colorScheme === 'dark', editorEl),
+ );
adoptClippyStyles();
const wrapper = document.createElement('clippy-editor-wrapper') as EditorWrapper;
@@ -263,6 +266,7 @@ export class ClippyPlugin extends Plugin {
override destroy(): void {
this._editorEl?.removeEventListener(CustomEvents.FOCUS_NODE, this._handleFocusNode);
+ this._unwatchColorScheme?.();
this._drawerEl?.remove();
this._gutterEl?.remove();
// Restore the editable to its original parent so CKEditor's own teardown
diff --git a/packages/ckeditor-plugin/src/plugin/content-classes-config.test.ts b/packages/ckeditor-plugin/src/plugin/content-classes-config.test.ts
index 553cf822..f6c586b7 100644
--- a/packages/ckeditor-plugin/src/plugin/content-classes-config.test.ts
+++ b/packages/ckeditor-plugin/src/plugin/content-classes-config.test.ts
@@ -13,10 +13,28 @@ describe('CONTENT_CLASS_FIELDS', () => {
expect(new Set(keys).size).toBe(keys.length);
});
- it('names at least one tag and a label per field', () => {
- for (const { key, label, tags } of CONTENT_CLASS_FIELDS) {
+ it('names at least one tag, a label and a description per field', () => {
+ for (const { description, key, label, tags } of CONTENT_CLASS_FIELDS) {
expect(tags.length, key).toBeGreaterThan(0);
expect(label, key).not.toBe('');
+ expect(description, key).not.toBe('');
+ }
+ });
+
+ // Drupal renders a description as markup and TYPO3 renders it as markdown, so the one shared
+ // string has to be plain text to survive both.
+ it('keeps every description free of markup', () => {
+ for (const { description, key } of CONTENT_CLASS_FIELDS) {
+ expect(description, key).not.toMatch(/[<>`]/u);
+ }
+ });
+
+ // The token appears verbatim in the heading default, so its description has to explain it.
+ it('explains the level token wherever a default contains it', () => {
+ for (const { defaultValue, description, key } of CONTENT_CLASS_FIELDS) {
+ if (defaultValue.includes(HEADING_LEVEL_TOKEN)) {
+ expect(description, key).toContain(HEADING_LEVEL_TOKEN);
+ }
}
});
diff --git a/packages/ckeditor-plugin/src/plugin/content-classes-config.ts b/packages/ckeditor-plugin/src/plugin/content-classes-config.ts
index 133570bc..8cb2c617 100644
--- a/packages/ckeditor-plugin/src/plugin/content-classes-config.ts
+++ b/packages/ckeditor-plugin/src/plugin/content-classes-config.ts
@@ -15,6 +15,7 @@ export interface ContentClassField {
key: string;
defaultValue: string;
label: string;
+ description: string;
tags: readonly string[];
modelElements: readonly string[];
listTypes: readonly string[];
@@ -26,6 +27,7 @@ const HEADING_MODEL_ELEMENTS = HEADING_LEVELS.map((level) => `heading${level}`);
export const CONTENT_CLASS_FIELDS = [
{
defaultValue: contentClasses.paragraph,
+ description: 'Added to p tags.',
key: 'paragraph',
label: 'Paragraph',
listTypes: [],
@@ -34,6 +36,7 @@ export const CONTENT_CLASS_FIELDS = [
},
{
defaultValue: headingClassPattern,
+ description: 'Added to h1 to h6 tags. {level} is replaced by the heading level, 1 to 6.',
key: 'heading',
label: 'Heading',
listTypes: [],
@@ -42,6 +45,7 @@ export const CONTENT_CLASS_FIELDS = [
},
{
defaultValue: contentClasses.bulletList,
+ description: 'Added to ul tags.',
key: 'bulletList',
label: 'Bulleted list',
listTypes: ['bulleted'],
@@ -50,6 +54,7 @@ export const CONTENT_CLASS_FIELDS = [
},
{
defaultValue: contentClasses.orderedList,
+ description: 'Added to ol tags.',
key: 'orderedList',
label: 'Numbered list',
listTypes: ['numbered'],
@@ -58,6 +63,7 @@ export const CONTENT_CLASS_FIELDS = [
},
{
defaultValue: contentClasses.blockquote,
+ description: 'Added to blockquote tags.',
key: 'blockquote',
label: 'Block quote',
listTypes: [],
@@ -66,6 +72,7 @@ export const CONTENT_CLASS_FIELDS = [
},
{
defaultValue: contentClasses.codeBlock,
+ description: 'Added to code tags.',
key: 'codeBlock',
label: 'Code block',
listTypes: [],
@@ -74,6 +81,7 @@ export const CONTENT_CLASS_FIELDS = [
},
{
defaultValue: contentClasses.image,
+ description: 'Added to the figure tag around a block image, and to the img tag of an inline image.',
key: 'image',
label: 'Image',
listTypes: [],
@@ -83,6 +91,7 @@ export const CONTENT_CLASS_FIELDS = [
},
{
defaultValue: contentClasses.table,
+ description: 'Added to the figure tag around a table.',
key: 'table',
label: 'Table',
listTypes: [],
diff --git a/packages/drupal-ckeditor-plugin/README.md b/packages/drupal-ckeditor-plugin/README.md
index f32b2020..72bd899b 100644
--- a/packages/drupal-ckeditor-plugin/README.md
+++ b/packages/drupal-ckeditor-plugin/README.md
@@ -29,6 +29,37 @@ The plugin declares `
`, `
` … as its elements, and narrows t
`
` and `` are left out, see `ContentClasses::NON_CREATABLE_TAGS`. Drupal validates that every tag a plugin claims an attribute on can actually be created by some enabled plugin, and refuses to save the format otherwise. Nothing creates `` — Drupal rewrites a caption to `data-caption` on `` — and `heading1` is off in every stock format. So in a restricted format the image and table classes only reach ``, not the wrapper; in Full HTML, which is unrestricted, the editor writes all of them.
+## Color scheme
+
+The editor follows the admin theme, not the browser: a light backend keeps a light editor even for
+an editor whose OS is set to dark.
+
+It reads the CSS `color-scheme` the active theme declares on ``, so it works without knowing
+which theme is installed:
+
+| Admin theme | Declares | Editor |
+| ------------------------------------ | ---------------------------------------------- | ------ |
+| Gin, dark mode | `color-scheme: dark` on `.gin--dark-mode` | dark |
+| Gin, light mode | nothing | light |
+| `default_admin` (core, experimental) | `light` on `html`, `dark` on `.gin--dark-mode` | either |
+| Claro | nothing | light |
+| A theme declaring `light dark` | both | the OS |
+
+Any other admin theme with a dark mode is covered as long as it declares its scheme, which it has
+to do anyway for native form controls and scrollbars to render correctly.
+
+### Overriding it
+
+A theme that declares no scheme, or the wrong one, can be corrected without patching the plugin.
+Set `drupalSettings.clippy.colorScheme` to `dark`, `light` or `auto` (follow the OS) from a module
+or theme:
+
+```php
+function MYTHEME_page_attachments_alter(array &$attachments): void {
+ $attachments['#attached']['drupalSettings']['clippy']['colorScheme'] = 'dark';
+}
+```
+
## Prerequisites
Build `ckeditor-plugin` first, since this package depends on its compiled output and on the `content-classes.json` it generates:
diff --git a/packages/drupal-ckeditor-plugin/module/src/Plugin/CKEditor5Plugin/ContentClasses.php b/packages/drupal-ckeditor-plugin/module/src/Plugin/CKEditor5Plugin/ContentClasses.php
index 65c61e13..0bc1ae56 100644
--- a/packages/drupal-ckeditor-plugin/module/src/Plugin/CKEditor5Plugin/ContentClasses.php
+++ b/packages/drupal-ckeditor-plugin/module/src/Plugin/CKEditor5Plugin/ContentClasses.php
@@ -14,8 +14,8 @@
/**
* Makes the content classes editable per text format.
*
- * The fields, their labels and their defaults come from content-classes.json,
- * generated by the ckeditor-plugin build.
+ * The fields, their labels, descriptions and defaults come from content-classes.json,
+ * generated by the ckeditor-plugin build, so they cannot drift between extension.
*/
class ContentClasses extends CKEditor5PluginDefault implements CKEditor5PluginElementsSubsetInterface {
@@ -59,9 +59,7 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta
'#title' => $this->t($field['label']),
'#default_value' => $classes[$key],
'#size' => 60,
- '#description' => self::hasToken($field['defaultValue'])
- ? $this->t('@token is replaced by the level of the heading, 1 through 6.', ['@token' => self::token()])
- : NULL,
+ '#description' => $this->t($field['description']),
];
}
diff --git a/packages/drupal-ckeditor-plugin/package.json b/packages/drupal-ckeditor-plugin/package.json
index a95ee295..705d8537 100644
--- a/packages/drupal-ckeditor-plugin/package.json
+++ b/packages/drupal-ckeditor-plugin/package.json
@@ -12,7 +12,8 @@
},
"type": "module",
"scripts": {
- "build": "vite build"
+ "build": "vite build",
+ "test": "vitest --run"
},
"dependencies": {
"@nl-design-system-community/ckeditor-plugin": "workspace:*"
@@ -24,7 +25,9 @@
"@nl-design-system/tsconfig": "1.0.5",
"@types/node": "22.20.1",
"ckeditor5": "48.4.0",
+ "jsdom": "30.0.1",
"typescript": "6.0.3",
- "vite": "8.2.2"
+ "vite": "8.2.2",
+ "vitest": "4.1.10"
}
}
diff --git a/packages/drupal-ckeditor-plugin/src/colorSchemeHost.test.ts b/packages/drupal-ckeditor-plugin/src/colorSchemeHost.test.ts
new file mode 100644
index 00000000..65ef95aa
--- /dev/null
+++ b/packages/drupal-ckeditor-plugin/src/colorSchemeHost.test.ts
@@ -0,0 +1,211 @@
+import { watchHostColorScheme } from '@nl-design-system-community/ckeditor-plugin';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { drupalColorSchemeHost } from './colorSchemeHost.ts';
+
+const GIN_DARK_CLASS = 'gin--dark-mode';
+
+const element = (): HTMLElement => document.createElement('div');
+
+const setPrefersDark = (matches: boolean): void => {
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn((query: string) => ({ addEventListener: () => {}, matches, media: query, removeEventListener: () => {} })),
+ );
+};
+
+/** Stubs `matchMedia` so the OS preference can be flipped, firing the listeners the host added. */
+const stubOsPreference = (matches: boolean) => {
+ const listeners: (() => void)[] = [];
+ const state = { matches };
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn(() => ({
+ addEventListener: (_type: string, listener: () => void) => listeners.push(listener),
+ get matches() {
+ return state.matches;
+ },
+ removeEventListener: (_type: string, listener: () => void) => {
+ const index = listeners.indexOf(listener);
+ if (index >= 0) {
+ listeners.splice(index, 1);
+ }
+ },
+ })),
+ );
+ return {
+ listenerCount: () => listeners.length,
+ setMatches: (next: boolean) => {
+ state.matches = next;
+ listeners.forEach((listener) => listener());
+ },
+ };
+};
+
+const styles: HTMLStyleElement[] = [];
+
+const declare = (css: string): void => {
+ const style = document.createElement('style');
+ style.textContent = css;
+ document.head.append(style);
+ styles.push(style);
+};
+
+/** The rule Gin ships in `styles/base/_body.scss`, verbatim. */
+const declareGin = (): void => declare('.gin--dark-mode { color-scheme: dark; }');
+
+/** Both rules core's `default_admin` ships in `css/base/elements.css`, verbatim. */
+const declareDefaultAdmin = (): void =>
+ declare('html { color-scheme: light; } .gin--dark-mode { color-scheme: dark; }');
+
+const setOverride = (colorScheme: unknown): void => {
+ vi.stubGlobal('drupalSettings', { clippy: { colorScheme } });
+};
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ styles.splice(0).forEach((style) => style.remove());
+ document.documentElement.className = '';
+ document.documentElement.removeAttribute('style');
+ document.body.innerHTML = '';
+});
+
+describe('drupalColorSchemeHost', () => {
+ it('is dark under Gin in dark mode', () => {
+ setPrefersDark(false);
+ declareGin();
+ document.documentElement.classList.add(GIN_DARK_CLASS);
+
+ expect(drupalColorSchemeHost.resolve(element())).toBe('dark');
+ });
+
+ it('is light under Gin in light mode, even when the OS prefers dark', () => {
+ setPrefersDark(true);
+ declareGin();
+
+ expect(drupalColorSchemeHost.resolve(element())).toBe('light');
+ });
+
+ it("is dark under core's default_admin in dark mode, where a light root is overruled", () => {
+ setPrefersDark(false);
+ declareDefaultAdmin();
+ document.documentElement.classList.add(GIN_DARK_CLASS);
+
+ expect(drupalColorSchemeHost.resolve(element())).toBe('dark');
+ });
+
+ it("is light under core's default_admin in light mode, even when the OS prefers dark", () => {
+ setPrefersDark(true);
+ declareDefaultAdmin();
+
+ expect(drupalColorSchemeHost.resolve(element())).toBe('light');
+ });
+
+ it('is light under Claro, which declares no scheme, even when the OS prefers dark', () => {
+ setPrefersDark(true);
+
+ expect(drupalColorSchemeHost.resolve(element())).toBe('light');
+ });
+
+ it('follows the OS under a theme that declares it renders both schemes', () => {
+ declare(':root { color-scheme: light dark; }');
+
+ setPrefersDark(true);
+ expect(drupalColorSchemeHost.resolve(element())).toBe('dark');
+
+ setPrefersDark(false);
+ expect(drupalColorSchemeHost.resolve(element())).toBe('light');
+ });
+
+ it('reports when the dark class is toggled after subscribing, and stops on teardown', async () => {
+ setPrefersDark(false);
+ declareGin();
+ const onChange = vi.fn();
+ const stop = drupalColorSchemeHost.subscribe(element(), onChange);
+
+ document.documentElement.classList.add(GIN_DARK_CLASS);
+ await vi.waitFor(() => expect(onChange).toHaveBeenCalledOnce());
+
+ stop();
+ document.documentElement.classList.remove(GIN_DARK_CLASS);
+ await vi.waitFor(() => expect(document.documentElement.classList.contains(GIN_DARK_CLASS)).toBe(false));
+ expect(onChange).toHaveBeenCalledOnce();
+ });
+
+ it('reports when a theme sets the scheme inline', async () => {
+ setPrefersDark(false);
+ const onChange = vi.fn();
+ drupalColorSchemeHost.subscribe(element(), onChange);
+
+ document.documentElement.setAttribute('style', 'color-scheme: dark');
+ await vi.waitFor(() => expect(onChange).toHaveBeenCalledOnce());
+ });
+});
+
+describe('following the OS where the answer depends on it', () => {
+ it('reports an OS change when the override is auto', () => {
+ const os = stubOsPreference(false);
+ setOverride('auto');
+ const onChange = vi.fn();
+
+ watchHostColorScheme(element(), onChange, drupalColorSchemeHost);
+ expect(onChange).toHaveBeenCalledExactlyOnceWith('light');
+
+ os.setMatches(true);
+ expect(onChange).toHaveBeenLastCalledWith('dark');
+ });
+
+ it('reports an OS change under a theme that declares both schemes', () => {
+ const os = stubOsPreference(false);
+ declare(':root { color-scheme: light dark; }');
+ const onChange = vi.fn();
+
+ watchHostColorScheme(element(), onChange, drupalColorSchemeHost);
+ expect(onChange).toHaveBeenCalledExactlyOnceWith('light');
+
+ os.setMatches(true);
+ expect(onChange).toHaveBeenLastCalledWith('dark');
+ });
+});
+
+describe('the drupalSettings override', () => {
+ it('forces dark on a theme that declares light', () => {
+ setPrefersDark(false);
+ declareDefaultAdmin();
+ setOverride('dark');
+
+ expect(drupalColorSchemeHost.resolve(element())).toBe('dark');
+ });
+
+ it('forces light on a theme that declares dark', () => {
+ setPrefersDark(true);
+ declareGin();
+ document.documentElement.classList.add(GIN_DARK_CLASS);
+ setOverride('light');
+
+ expect(drupalColorSchemeHost.resolve(element())).toBe('light');
+ });
+
+ it('follows the OS when set to auto, on a theme that declares nothing', () => {
+ setPrefersDark(true);
+ setOverride('auto');
+
+ expect(drupalColorSchemeHost.resolve(element())).toBe('dark');
+ });
+
+ it('is ignored when it holds a value that is not a setting', () => {
+ setPrefersDark(false);
+ declareGin();
+ document.documentElement.classList.add(GIN_DARK_CLASS);
+ setOverride('nonsense');
+
+ expect(drupalColorSchemeHost.resolve(element())).toBe('dark');
+ });
+
+ it('is ignored when drupalSettings is absent altogether', () => {
+ setPrefersDark(false);
+ declareGin();
+ document.documentElement.classList.add(GIN_DARK_CLASS);
+
+ expect(drupalColorSchemeHost.resolve(element())).toBe('dark');
+ });
+});
diff --git a/packages/drupal-ckeditor-plugin/src/colorSchemeHost.ts b/packages/drupal-ckeditor-plugin/src/colorSchemeHost.ts
new file mode 100644
index 00000000..1fef56c3
--- /dev/null
+++ b/packages/drupal-ckeditor-plugin/src/colorSchemeHost.ts
@@ -0,0 +1,52 @@
+import {
+ matchDarkColorScheme,
+ observeAttributes,
+ prefersDarkColorScheme,
+ resolveDeclaredColorScheme,
+ type ColorScheme,
+ type ColorSchemeHost,
+} from '@nl-design-system-community/ckeditor-plugin';
+
+interface DrupalSettingsWindow {
+ drupalSettings?: { clippy?: { colorScheme?: unknown } };
+}
+
+/**
+ * The scheme a site has pinned in `drupalSettings.clippy.colorScheme`, or `null` when it has not.
+ *
+ * The escape hatch for an admin theme that declares the wrong scheme, or none at all. It wins over
+ * the declared scheme rather than filling in for it, because a theme that declares wrongly cannot
+ * be corrected by a fallback.
+ */
+const overriddenColorScheme = (): ColorScheme | null => {
+ const setting = (window as unknown as DrupalSettingsWindow).drupalSettings?.clippy?.colorScheme;
+ if (setting === 'dark' || setting === 'light') {
+ return setting;
+ }
+ if (setting === 'auto') {
+ return prefersDarkColorScheme() ? 'dark' : 'light';
+ }
+ return null;
+};
+
+/**
+ * Drupal's color scheme, as the active admin theme declares it in CSS.
+ *
+ * Reading the declared `color-scheme` rather than a theme's own marker class covers every admin
+ * theme that supports dark mode, because declaring it is what makes native controls and scrollbars
+ * render correctly.
+ */
+export const drupalColorSchemeHost: ColorSchemeHost = {
+ resolve: () => overriddenColorScheme() ?? resolveDeclaredColorScheme(document.documentElement),
+ subscribe: (_element, onChange) => {
+ const stopObserving = observeAttributes(document.documentElement, ['class', 'style'], onChange);
+
+ const query = matchDarkColorScheme();
+ query?.addEventListener('change', onChange);
+
+ return () => {
+ stopObserving();
+ query?.removeEventListener('change', onChange);
+ };
+ },
+};
diff --git a/packages/drupal-ckeditor-plugin/src/index.ts b/packages/drupal-ckeditor-plugin/src/index.ts
index bdc7e61e..bfe20c96 100644
--- a/packages/drupal-ckeditor-plugin/src/index.ts
+++ b/packages/drupal-ckeditor-plugin/src/index.ts
@@ -1 +1,6 @@
+import { setColorSchemeHost } from '@nl-design-system-community/ckeditor-plugin';
+import { drupalColorSchemeHost } from './colorSchemeHost.ts';
+
+setColorSchemeHost(drupalColorSchemeHost);
+
export { ClippyPlugin, ContentClasses } from '@nl-design-system-community/ckeditor-plugin';
diff --git a/packages/drupal-ckeditor-plugin/vitest.config.ts b/packages/drupal-ckeditor-plugin/vitest.config.ts
new file mode 100644
index 00000000..647a9e54
--- /dev/null
+++ b/packages/drupal-ckeditor-plugin/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ },
+});
diff --git a/packages/editor-website/src/components/SiteHeader.astro b/packages/editor-website/src/components/SiteHeader.astro
index 05f43a0e..f65c17db 100644
--- a/packages/editor-website/src/components/SiteHeader.astro
+++ b/packages/editor-website/src/components/SiteHeader.astro
@@ -79,22 +79,21 @@ const localeRoot = locale === 'en' ? '/en' : '/';
applyColorScheme,
isDarkColorScheme,
setDarkColorScheme,
+ storeColorScheme,
} from '@nl-design-system-community/editor/color-scheme';
const toggle = document.getElementById('ma-color-scheme-toggle');
- const syncState = () => {
- toggle?.setAttribute('aria-pressed', String(isDarkColorScheme()));
- toggle?.classList.toggle('nl-button--pressed', isDarkColorScheme());
- };
-
- const query = applyColorScheme();
- syncState();
- query?.addEventListener('change', syncState);
+ applyColorScheme((colorScheme) => {
+ const dark = colorScheme === 'dark';
+ toggle?.setAttribute('aria-pressed', String(dark));
+ toggle?.classList.toggle('nl-button--pressed', dark);
+ });
toggle?.addEventListener('click', () => {
- setDarkColorScheme(!isDarkColorScheme());
- syncState();
+ const dark = !isDarkColorScheme();
+ setDarkColorScheme(dark);
+ storeColorScheme(dark ? 'dark' : 'light');
});
diff --git a/packages/editor-website/src/layouts/document.astro b/packages/editor-website/src/layouts/document.astro
index 244a25a7..13bc9df1 100644
--- a/packages/editor-website/src/layouts/document.astro
+++ b/packages/editor-website/src/layouts/document.astro
@@ -17,18 +17,20 @@ const locale = (Astro.currentLocale ?? 'nl') as Locale;
{title}
- {/* Apply the stored/preferred color scheme before first paint to avoid a flash of the wrong theme. */}
-
+
+
diff --git a/packages/editor/package.json b/packages/editor/package.json
index c46bfad9..1f00a828 100644
--- a/packages/editor/package.json
+++ b/packages/editor/package.json
@@ -55,6 +55,9 @@
"accessibility-notifications": [
"./dist/entries/accessibility-notifications.d.ts"
],
+ "color-scheme": [
+ "./dist/entries/color-scheme.d.ts"
+ ],
"content-classes": [
"./dist/entries/content-classes.d.ts"
],
diff --git a/packages/editor/src/entries/color-scheme.ts b/packages/editor/src/entries/color-scheme.ts
index 484518c5..34ddc231 100644
--- a/packages/editor/src/entries/color-scheme.ts
+++ b/packages/editor/src/entries/color-scheme.ts
@@ -2,10 +2,18 @@ export {
applyColorScheme,
getStoredColorScheme,
isDarkColorScheme,
+ matchDarkColorScheme,
+ observeAttributes,
+ prefersDarkColorScheme,
+ resolveDeclaredColorScheme,
+ setColorSchemeHost,
setDarkColorScheme,
+ storeColorScheme,
+ themeScopeHost,
+ watchHostColorScheme,
COLOR_SCHEME_STORAGE_KEY,
DARK_COLOR_SCHEME_CLASS,
DARK_COLOR_SCHEME_QUERY,
THEME_CLASS,
} from '../utils/colorScheme';
-export type { ColorScheme } from '../utils/colorScheme';
+export type { ColorScheme, ColorSchemeHost } from '../utils/colorScheme';
diff --git a/packages/editor/src/utils/colorScheme.test.ts b/packages/editor/src/utils/colorScheme.test.ts
index b5a23fd1..c05ab859 100644
--- a/packages/editor/src/utils/colorScheme.test.ts
+++ b/packages/editor/src/utils/colorScheme.test.ts
@@ -1,12 +1,22 @@
-import { afterEach, describe, it, expect, vi } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import {
applyColorScheme,
getStoredColorScheme,
isDarkColorScheme,
+ observeAttributes,
+ prefersDarkColorScheme,
+ resolveDeclaredColorScheme,
+ setColorSchemeHost,
setDarkColorScheme,
+ storeColorScheme,
+ storedPreferenceHost,
+ themeScopeHost,
+ watchHostColorScheme,
COLOR_SCHEME_STORAGE_KEY,
DARK_COLOR_SCHEME_CLASS,
THEME_CLASS,
+ type ColorScheme,
+ type ColorSchemeHost,
} from './colorScheme';
/** A `MediaQueryList` stand-in whose match can be flipped, firing a real `change` event. */
@@ -20,113 +30,335 @@ class MockMediaQueryList extends EventTarget {
setMatches(matches: boolean): void {
this.matches = matches;
- this.dispatchEvent(Object.assign(new Event('change'), { matches }));
+ this.dispatchEvent(new Event('change'));
}
}
-/** Makes `window.matchMedia` return `query`, so `applyColorScheme` observes it. */
const stubMatchMedia = (matches: boolean): MockMediaQueryList => {
const query = new MockMediaQueryList(matches);
vi.stubGlobal('matchMedia', () => query as unknown as MediaQueryList);
return query;
};
+/** Teardowns for everything a test adds: elements, styles and live subscriptions. */
+const cleanups: (() => void)[] = [];
+
+/** Applies real CSS, so the declared scheme is read through the engine's own cascade. */
+const declare = (css: string): void => {
+ const style = document.createElement('style');
+ style.textContent = css;
+ document.head.append(style);
+ cleanups.push(() => style.remove());
+};
+
+const createElement = (parent: HTMLElement = document.body, className = ''): HTMLElement => {
+ const element = document.createElement('div');
+ element.className = className;
+ parent.append(element);
+ cleanups.push(() => element.remove());
+ return element;
+};
+
afterEach(() => {
+ cleanups.splice(0).forEach((cleanup) => cleanup());
vi.unstubAllGlobals();
- // applyColorScheme reads localStorage, so keep tests isolated from each other.
+ setColorSchemeHost(themeScopeHost);
localStorage.removeItem(COLOR_SCHEME_STORAGE_KEY);
+ document.documentElement.classList.remove(DARK_COLOR_SCHEME_CLASS);
+});
+
+describe('prefersDarkColorScheme', () => {
+ it('reads the OS preference', () => {
+ stubMatchMedia(true);
+ expect(prefersDarkColorScheme()).toBe(true);
+
+ stubMatchMedia(false);
+ expect(prefersDarkColorScheme()).toBe(false);
+ });
+
+ it('is false when matchMedia is unavailable', () => {
+ vi.stubGlobal('matchMedia', undefined);
+ expect(prefersDarkColorScheme()).toBe(false);
+ });
+});
+
+describe('resolveDeclaredColorScheme', () => {
+ /** `null` stands for an environment without `matchMedia`. */
+ it.each<{ name: string; declaration: string; prefersDark: boolean | null; expected: ColorScheme }>([
+ {
+ name: 'is dark when the root declares dark',
+ declaration: 'color-scheme: dark',
+ expected: 'dark',
+ prefersDark: false,
+ },
+ {
+ name: 'is light when the root declares light, whatever the OS prefers',
+ declaration: 'color-scheme: light',
+ expected: 'light',
+ prefersDark: true,
+ },
+ {
+ name: 'is light when nothing is declared, whatever the OS prefers',
+ declaration: '',
+ expected: 'light',
+ prefersDark: true,
+ },
+ {
+ name: 'follows the OS when both schemes are declared and the OS prefers dark',
+ declaration: 'color-scheme: light dark',
+ expected: 'dark',
+ prefersDark: true,
+ },
+ {
+ name: 'follows the OS when both schemes are declared and the OS prefers light',
+ declaration: 'color-scheme: light dark',
+ expected: 'light',
+ prefersDark: false,
+ },
+ {
+ name: 'reads a scheme the `only` keyword pins',
+ declaration: 'color-scheme: only dark',
+ expected: 'dark',
+ prefersDark: false,
+ },
+ {
+ name: 'is light when both schemes are declared and matchMedia is unavailable',
+ declaration: 'color-scheme: light dark',
+ expected: 'light',
+ prefersDark: null,
+ },
+ ])('$name', ({ declaration, expected, prefersDark }) => {
+ if (prefersDark === null) {
+ vi.stubGlobal('matchMedia', undefined);
+ } else {
+ stubMatchMedia(prefersDark);
+ }
+ declare(`.declares { ${declaration} }`);
+
+ expect(resolveDeclaredColorScheme(createElement(document.body, 'declares'))).toBe(expected);
+ });
+
+ it('resolves through the cascade, so a more specific dark rule wins over a light root', () => {
+ stubMatchMedia(false);
+ declare('.root-light { color-scheme: light; } .is-dark { color-scheme: dark; }');
+
+ expect(resolveDeclaredColorScheme(createElement(document.body, 'root-light is-dark'))).toBe('dark');
+ });
+});
+
+describe('themeScopeHost', () => {
+ const resolve = (element: HTMLElement): ColorScheme => themeScopeHost.resolve(element);
+
+ it('is dark when the nearest theme scope carries the dark modifier', () => {
+ stubMatchMedia(false);
+ const scope = createElement(document.body, `${THEME_CLASS} ${DARK_COLOR_SCHEME_CLASS}`);
+
+ expect(resolve(createElement(scope))).toBe('dark');
+ });
+
+ it('is light when the nearest theme scope has no dark modifier, whatever the OS prefers', () => {
+ stubMatchMedia(true);
+ const scope = createElement(document.body, THEME_CLASS);
+
+ expect(resolve(createElement(scope))).toBe('light');
+ });
+
+ it('reads the nearest theme scope, not an outer one', () => {
+ stubMatchMedia(false);
+ const outer = createElement(document.body, `${THEME_CLASS} ${DARK_COLOR_SCHEME_CLASS}`);
+ const inner = createElement(outer, THEME_CLASS);
+
+ expect(resolve(createElement(inner))).toBe('light');
+ });
+
+ it('ignores the element itself being a theme scope', () => {
+ stubMatchMedia(true);
+
+ expect(resolve(createElement(document.body, THEME_CLASS))).toBe('dark');
+ });
+
+ it('falls back to the OS preference without a theme scope', () => {
+ stubMatchMedia(true);
+ expect(resolve(createElement())).toBe('dark');
+
+ stubMatchMedia(false);
+ expect(resolve(createElement())).toBe('light');
+ });
+
+ it('is light when matchMedia is unavailable', () => {
+ vi.stubGlobal('matchMedia', undefined);
+
+ expect(resolve(createElement())).toBe('light');
+ });
+});
+
+describe('watchHostColorScheme', () => {
+ it('reports the resolved scheme straight away', () => {
+ stubMatchMedia(true);
+ const onChange = vi.fn();
+
+ cleanups.push(watchHostColorScheme(createElement(), onChange));
+
+ expect(onChange).toHaveBeenCalledExactlyOnceWith('dark');
+ });
+
+ it('follows the theme scope when its dark modifier is toggled', async () => {
+ stubMatchMedia(false);
+ const scope = createElement(document.body, THEME_CLASS);
+ const onChange = vi.fn();
+
+ cleanups.push(watchHostColorScheme(createElement(scope), onChange));
+ scope.classList.add(DARK_COLOR_SCHEME_CLASS);
+
+ await vi.waitFor(() => expect(onChange).toHaveBeenLastCalledWith('dark'));
+ });
+
+ it('follows the OS preference when there is no theme scope', async () => {
+ const query = stubMatchMedia(false);
+ const onChange = vi.fn();
+
+ cleanups.push(watchHostColorScheme(createElement(), onChange));
+ query.setMatches(true);
+
+ await vi.waitFor(() => expect(onChange).toHaveBeenLastCalledWith('dark'));
+ });
+
+ it('takes an explicit host over the registered one', () => {
+ stubMatchMedia(false);
+ storeColorScheme('dark');
+ const onChange = vi.fn();
+
+ cleanups.push(watchHostColorScheme(createElement(), onChange, storedPreferenceHost));
+
+ expect(onChange).toHaveBeenCalledExactlyOnceWith('dark');
+ });
+});
+
+describe('setColorSchemeHost', () => {
+ it('hands resolution and subscription to the registered host', () => {
+ stubMatchMedia(false);
+ let notify = (): void => {};
+ let scheme: ColorScheme = 'dark';
+ const host: ColorSchemeHost = {
+ resolve: () => scheme,
+ subscribe: (_element, onChange) => {
+ notify = onChange;
+ return () => {};
+ },
+ };
+ setColorSchemeHost(host);
+ const onChange = vi.fn();
+
+ cleanups.push(watchHostColorScheme(createElement(), onChange));
+ expect(onChange).toHaveBeenCalledExactlyOnceWith('dark');
+
+ scheme = 'light';
+ notify();
+ expect(onChange).toHaveBeenLastCalledWith('light');
+ });
+
+ it('does not affect applyColorScheme, which follows the stored preference', () => {
+ stubMatchMedia(false);
+ setColorSchemeHost({ resolve: () => 'dark', subscribe: () => () => {} });
+ const root = createElement();
+
+ cleanups.push(applyColorScheme(undefined, root));
+
+ expect(isDarkColorScheme(root)).toBe(false);
+ });
});
describe('applyColorScheme', () => {
- it('follows the OS preference when no choice is stored', () => {
- const root = document.createElement('div');
+ it('follows the OS preference when no choice is stored', async () => {
const query = stubMatchMedia(false);
+ const root = createElement();
- applyColorScheme(root);
- expect(root.classList.contains(DARK_COLOR_SCHEME_CLASS)).toBe(false);
+ cleanups.push(applyColorScheme(undefined, root));
+ expect(isDarkColorScheme(root)).toBe(false);
query.setMatches(true);
- expect(root.classList.contains(DARK_COLOR_SCHEME_CLASS)).toBe(true);
+ await vi.waitFor(() => expect(isDarkColorScheme(root)).toBe(true));
query.setMatches(false);
- expect(root.classList.contains(DARK_COLOR_SCHEME_CLASS)).toBe(false);
+ await vi.waitFor(() => expect(isDarkColorScheme(root)).toBe(false));
});
- it('prefers a stored choice over the OS preference and ignores system changes', () => {
- const root = document.createElement('div');
- const query = stubMatchMedia(false); // OS = light
- localStorage.setItem(COLOR_SCHEME_STORAGE_KEY, 'dark'); // explicit choice = dark
+ it('prefers a stored choice over the OS preference and ignores system changes', async () => {
+ const query = stubMatchMedia(false);
+ storeColorScheme('dark');
+ const root = createElement();
- applyColorScheme(root);
- expect(root.classList.contains(DARK_COLOR_SCHEME_CLASS)).toBe(true);
+ cleanups.push(applyColorScheme(undefined, root));
+ expect(isDarkColorScheme(root)).toBe(true);
query.setMatches(true);
- expect(root.classList.contains(DARK_COLOR_SCHEME_CLASS)).toBe(true);
+ await vi.waitFor(() => expect(isDarkColorScheme(root)).toBe(true));
+ });
+
+ it('reports every scheme it settles on, including a later toggle', async () => {
+ stubMatchMedia(false);
+ const root = createElement();
+ const onChange = vi.fn();
+
+ cleanups.push(applyColorScheme(onChange, root));
+ expect(onChange).toHaveBeenCalledExactlyOnceWith('light');
+
+ setDarkColorScheme(true, root);
+ storeColorScheme('dark');
+
+ await vi.waitFor(() => expect(onChange).toHaveBeenLastCalledWith('dark'));
+ expect(onChange).toHaveBeenCalledTimes(2);
});
it('appends the modifier to the ma-theme element by default', () => {
- const themed = document.createElement('div');
- themed.classList.add(THEME_CLASS);
- document.body.appendChild(themed);
+ const themed = createElement(document.body, THEME_CLASS);
stubMatchMedia(true);
- try {
- applyColorScheme();
- expect(themed.classList.contains(DARK_COLOR_SCHEME_CLASS)).toBe(true);
- expect(document.documentElement.classList.contains(DARK_COLOR_SCHEME_CLASS)).toBe(false);
- } finally {
- themed.remove();
- }
+ cleanups.push(applyColorScheme());
+
+ expect(isDarkColorScheme(themed)).toBe(true);
+ expect(document.documentElement.classList.contains(DARK_COLOR_SCHEME_CLASS)).toBe(false);
});
it('falls back to when no ma-theme element exists', () => {
expect(document.querySelector(`.${THEME_CLASS}`)).toBeNull();
stubMatchMedia(true);
- try {
- applyColorScheme();
- expect(document.documentElement.classList.contains(DARK_COLOR_SCHEME_CLASS)).toBe(true);
- } finally {
- document.documentElement.classList.remove(DARK_COLOR_SCHEME_CLASS);
- }
+ cleanups.push(applyColorScheme());
+
+ expect(document.documentElement.classList.contains(DARK_COLOR_SCHEME_CLASS)).toBe(true);
});
- it('is a no-op when matchMedia is unavailable (e.g. SSR/jsdom)', () => {
- const root = document.createElement('div');
+ it('applies light where matchMedia is unavailable and nothing is stored', () => {
vi.stubGlobal('matchMedia', undefined);
+ const root = createElement();
+ root.classList.add(DARK_COLOR_SCHEME_CLASS);
- expect(applyColorScheme(root)).toBeUndefined();
- expect(root.classList.contains(DARK_COLOR_SCHEME_CLASS)).toBe(false);
+ cleanups.push(applyColorScheme(undefined, root));
+
+ expect(isDarkColorScheme(root)).toBe(false);
});
});
describe('color scheme helpers', () => {
- it('setDarkColorScheme toggles the class and persists the choice by default', () => {
- const root = document.createElement('div');
+ it('setDarkColorScheme toggles the class without storing anything', () => {
+ const root = createElement();
- setDarkColorScheme(true, { root });
- expect(root.classList.contains(DARK_COLOR_SCHEME_CLASS)).toBe(true);
- expect(getStoredColorScheme()).toBe('dark');
+ setDarkColorScheme(true, root);
+ expect(isDarkColorScheme(root)).toBe(true);
+ expect(getStoredColorScheme()).toBeNull();
- setDarkColorScheme(false, { root });
- expect(root.classList.contains(DARK_COLOR_SCHEME_CLASS)).toBe(false);
- expect(getStoredColorScheme()).toBe('light');
+ setDarkColorScheme(false, root);
+ expect(isDarkColorScheme(root)).toBe(false);
});
- it('setDarkColorScheme with persist:false does not store the choice', () => {
- const root = document.createElement('div');
+ it('storeColorScheme remembers the choice without touching any class', () => {
+ const root = createElement();
- setDarkColorScheme(true, { persist: false, root });
- expect(root.classList.contains(DARK_COLOR_SCHEME_CLASS)).toBe(true);
- expect(getStoredColorScheme()).toBeNull();
- });
+ storeColorScheme('dark');
- it('isDarkColorScheme reflects the current class on the root', () => {
- const root = document.createElement('div');
+ expect(getStoredColorScheme()).toBe('dark');
expect(isDarkColorScheme(root)).toBe(false);
-
- root.classList.add(DARK_COLOR_SCHEME_CLASS);
- expect(isDarkColorScheme(root)).toBe(true);
});
it('getStoredColorScheme ignores unrecognised stored values', () => {
@@ -134,3 +366,22 @@ describe('color scheme helpers', () => {
expect(getStoredColorScheme()).toBeNull();
});
});
+
+describe('observeAttributes', () => {
+ it('reports only the listed attributes and stops on teardown', async () => {
+ const target = createElement();
+ const onChange = vi.fn();
+
+ const stop = observeAttributes(target, ['data-color-scheme'], onChange);
+ target.setAttribute('lang', 'nl');
+ target.setAttribute('data-color-scheme', 'dark');
+
+ await vi.waitFor(() => expect(onChange).toHaveBeenCalledOnce());
+
+ stop();
+ target.setAttribute('data-color-scheme', 'light');
+
+ await vi.waitFor(() => expect(target.getAttribute('data-color-scheme')).toBe('light'));
+ expect(onChange).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/editor/src/utils/colorScheme.ts b/packages/editor/src/utils/colorScheme.ts
index b7615f3a..801c762b 100644
--- a/packages/editor/src/utils/colorScheme.ts
+++ b/packages/editor/src/utils/colorScheme.ts
@@ -12,6 +12,17 @@ export type ColorScheme = 'dark' | 'light';
const defaultRoot = (): HTMLElement =>
document.querySelector(`.${THEME_CLASS}`) ?? document.documentElement;
+/**
+ * The OS dark-scheme query, or `null` where `matchMedia` is unavailable
+ * (server-side rendering, jsdom without a stub).
+ */
+export const matchDarkColorScheme = (): MediaQueryList | null =>
+ typeof window !== 'undefined' && typeof window.matchMedia === 'function'
+ ? window.matchMedia(DARK_COLOR_SCHEME_QUERY)
+ : null;
+
+export const prefersDarkColorScheme = (): boolean => matchDarkColorScheme()?.matches ?? false;
+
/**
* The user's explicitly stored color-scheme choice, or `null` when none is
* stored (in which case the OS `prefers-color-scheme` is followed).
@@ -25,54 +36,149 @@ export const getStoredColorScheme = (): ColorScheme | null => {
}
};
+export const storeColorScheme = (colorScheme: ColorScheme): void => {
+ try {
+ localStorage.setItem(COLOR_SCHEME_STORAGE_KEY, colorScheme);
+ } catch {
+ /* localStorage unavailable (e.g. private mode) — class still applies for this session. */
+ }
+};
+
/** Whether the dark color scheme is currently applied to `root`. */
export const isDarkColorScheme = (root: HTMLElement = defaultRoot()): boolean =>
root.classList.contains(DARK_COLOR_SCHEME_CLASS);
+/** Toggles the dark color scheme modifier on `root`. */
+export const setDarkColorScheme = (dark: boolean, root: HTMLElement = defaultRoot()): void => {
+ root.classList.toggle(DARK_COLOR_SCHEME_CLASS, dark);
+};
+
/**
- * Toggles the `ma-theme--color-scheme-dark` modifier on `root`.
- *
- * @param dark - Whether to enable the dark scheme.
- * @param options.root - Element to toggle the class on (defaults to the `.ma-theme` element).
- * @param options.persist - Store the choice so it survives reloads (defaults to `true`);
- * pass `false` when merely reflecting a live OS preference change.
+ * `light dark` is a page saying it renders both and defers to the OS, so the OS decides.
+ * Declaring nothing resolves to `light`: a theme that has not opted in renders light only.
*/
-export const setDarkColorScheme = (
- dark: boolean,
- { persist = true, root = defaultRoot() }: { root?: HTMLElement; persist?: boolean } = {},
-): void => {
- root.classList.toggle(DARK_COLOR_SCHEME_CLASS, dark);
- if (persist) {
- try {
- localStorage.setItem(COLOR_SCHEME_STORAGE_KEY, dark ? 'dark' : 'light');
- } catch {
- /* localStorage unavailable (e.g. private mode) — the class still applies for this session. */
- }
+export const resolveDeclaredColorScheme = (root: HTMLElement): ColorScheme => {
+ const declared = root.ownerDocument.defaultView?.getComputedStyle(root).colorScheme ?? '';
+ const dark = declared.includes('dark');
+ const light = declared.includes('light');
+
+ if (dark && !light) {
+ return 'dark';
+ }
+ if (dark && light) {
+ return prefersDarkColorScheme() ? 'dark' : 'light';
}
+ return 'light';
};
+/** The page an editor is embedded in, as far as the color scheme is concerned. */
+export interface ColorSchemeHost {
+ /** The scheme the page around `element` has settled on. */
+ resolve(element: HTMLElement): ColorScheme;
+ /** Calls `onChange` whenever that answer might have changed. Returns a teardown. */
+ subscribe(element: HTMLElement, onChange: () => void): () => void;
+}
+
+export const observeAttributes = (target: Element, attributeFilter: string[], onChange: () => void): (() => void) => {
+ const observer = new MutationObserver(onChange);
+ observer.observe(target, { attributeFilter });
+ return () => observer.disconnect();
+};
+
+/** Subscribes to OS preference changes. A no-op teardown where `matchMedia` is unavailable. */
+const subscribeToOsPreference = (onChange: () => void): (() => void) => {
+ const query = matchDarkColorScheme();
+ if (!query) {
+ return () => {};
+ }
+ query.addEventListener('change', onChange);
+ return () => query.removeEventListener('change', onChange);
+};
+
+const themeScopeOf = (element: HTMLElement): HTMLElement | null =>
+ element.parentElement?.closest(`.${THEME_CLASS}`) ?? null;
+
/**
- * Applies the color scheme and keeps it in sync with the user's preference: an
- * explicit stored choice wins, otherwise the OS `prefers-color-scheme` is
- * followed, including live changes.
- *
- * @param root - Element to toggle the class on (defaults to the `.ma-theme` element).
- * @returns The observed `MediaQueryList`, or `undefined` in environments without
- * `matchMedia` (e.g. server-side rendering or jsdom).
+ * The host used when nothing else is registered: the surrounding theme scope, or the
+ * OS preference when the editor sits outside one.
*/
-export const applyColorScheme = (root: HTMLElement = defaultRoot()): MediaQueryList | undefined => {
- if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
- return undefined;
- }
+export const themeScopeHost: ColorSchemeHost = {
+ resolve: (element) => {
+ const scope = themeScopeOf(element);
+ if (scope) {
+ return scope.classList.contains(DARK_COLOR_SCHEME_CLASS) ? 'dark' : 'light';
+ }
+ return prefersDarkColorScheme() ? 'dark' : 'light';
+ },
+ subscribe: (element, onChange) => {
+ const scope = themeScopeOf(element);
+ return scope ? observeAttributes(scope, ['class'], onChange) : subscribeToOsPreference(onChange);
+ },
+};
- const query = window.matchMedia(DARK_COLOR_SCHEME_QUERY);
- const resolveDark = (): boolean => {
- const stored = getStoredColorScheme();
- return stored === null ? query.matches : stored === 'dark';
- };
+/**
+ * The host the standalone app follows: the user's own stored choice, falling back to the
+ * OS preference.
+ */
+export const storedPreferenceHost: ColorSchemeHost = {
+ resolve: () => getStoredColorScheme() ?? (prefersDarkColorScheme() ? 'dark' : 'light'),
+ subscribe: (element, onChange) => {
+ const stopObserving = observeAttributes(element, ['class'], onChange);
+ const stopListening = subscribeToOsPreference(onChange);
+ return () => {
+ stopObserving();
+ stopListening();
+ };
+ },
+};
- setDarkColorScheme(resolveDark(), { persist: false, root });
- query.addEventListener('change', () => setDarkColorScheme(resolveDark(), { persist: false, root }));
+let registeredHost: ColorSchemeHost = themeScopeHost;
- return query;
+export const setColorSchemeHost = (host: ColorSchemeHost): void => {
+ registeredHost = host;
};
+
+/**
+ * Applies the host's color scheme to `element` and keeps it in sync.
+ *
+ * `onChange` is called immediately with the resolved scheme, then again on every change
+ * to it — never twice with the same value.
+ *
+ * @param host - Defaults to the host registered for this page.
+ * @returns A teardown that unsubscribes from the host.
+ */
+export const watchHostColorScheme = (
+ element: HTMLElement,
+ onChange: (colorScheme: ColorScheme) => void,
+ host: ColorSchemeHost = registeredHost,
+): (() => void) => {
+ let current = host.resolve(element);
+ onChange(current);
+
+ return host.subscribe(element, () => {
+ const next = host.resolve(element);
+ if (next !== current) {
+ current = next;
+ onChange(next);
+ }
+ });
+};
+
+/**
+ * Applies the user's own color scheme to `root` and keeps it in sync.
+ *
+ * @param onChange - Called with every scheme the app settles on, after the class is applied.
+ * @returns A teardown that stops following the preference.
+ */
+export const applyColorScheme = (
+ onChange?: (colorScheme: ColorScheme) => void,
+ root: HTMLElement = defaultRoot(),
+): (() => void) =>
+ watchHostColorScheme(
+ root,
+ (colorScheme) => {
+ setDarkColorScheme(colorScheme === 'dark', root);
+ onChange?.(colorScheme);
+ },
+ storedPreferenceHost,
+ );
diff --git a/packages/typo3-ckeditor-plugin/README.md b/packages/typo3-ckeditor-plugin/README.md
new file mode 100644
index 00000000..2de7f9e0
--- /dev/null
+++ b/packages/typo3-ckeditor-plugin/README.md
@@ -0,0 +1,40 @@
+# typo3-ckeditor-plugin
+
+Builds the Clippy CKEditor 5 plugin as an ES module for use in TYPO3.
+
+## How it works
+
+`extension/` holds the TYPO3 extension itself: the Composer manifest, the RTE preset, the import map and the page TSconfig. The build copies that folder, the bundle and the generated stylesheets to `app/typo3/packages/clippy/dist/` — not a local `dist/` folder. This makes the plugin immediately available to the TYPO3 demo environment without a manual copy step.
+
+`Configuration/page.tsconfig` sets `RTE.default.preset = clippy`, so every rich text field in the demo uses the preset.
+
+| CKEditor plugin | Purpose |
+| ---------------- | ----------------------------------------------------------- |
+| `ClippyPlugin` | Accessibility feedback in the editor. |
+| `ContentClasses` | The design-system classes CKEditor writes into the content. |
+
+## The ckeditor5 shim
+
+TYPO3 loads CKEditor 5 as native ES modules through an import map with a specifier per package — `@ckeditor/ckeditor5-core`, `@ckeditor/ckeditor5-ui`, and about fifty others. There is no `ckeditor5` umbrella specifier, so the build externalises `ckeditor5` and rewrites it to `@nl-design-system-community/clippy/ckeditor5.js`, a shim that re-exports the two packages the plugin needs at runtime.
+
+This is why `packages/ckeditor-plugin` needs no host-specific code.
+
+## Prerequisites
+
+Build `ckeditor-plugin` first, since this package depends on its compiled output:
+
+```sh
+pnpm --filter @nl-design-system-community/ckeditor-plugin build
+```
+
+## Build
+
+```sh
+pnpm build
+```
+
+Files under `Resources/Public/` are picked up on page reload. After changing `composer.json`, `ext_localconf.php` or anything under `Configuration/`, flush the TYPO3 cache:
+
+```sh
+docker compose exec typo3 php vendor/bin/typo3 cache:flush
+```
diff --git a/packages/typo3-ckeditor-plugin/extension/Configuration/JavaScriptModules.php b/packages/typo3-ckeditor-plugin/extension/Configuration/JavaScriptModules.php
new file mode 100644
index 00000000..bb3f9fbe
--- /dev/null
+++ b/packages/typo3-ckeditor-plugin/extension/Configuration/JavaScriptModules.php
@@ -0,0 +1,13 @@
+ [
+ 'backend',
+ 'rte_ckeditor',
+ ],
+ 'imports' => [
+ '@nl-design-system-community/clippy/' => 'EXT:clippy/Resources/Public/JavaScript/',
+ ],
+];
diff --git a/packages/typo3-ckeditor-plugin/extension/Configuration/RTE/Clippy.yaml b/packages/typo3-ckeditor-plugin/extension/Configuration/RTE/Clippy.yaml
new file mode 100644
index 00000000..95abf32e
--- /dev/null
+++ b/packages/typo3-ckeditor-plugin/extension/Configuration/RTE/Clippy.yaml
@@ -0,0 +1,11 @@
+imports:
+ - { resource: "EXT:rte_ckeditor/Configuration/RTE/Default.yaml" }
+ - { resource: "EXT:clippy/Configuration/RTE/ContentClasses.yaml" }
+
+editor:
+ config:
+ importModules:
+ - { module: "@nl-design-system-community/clippy/clippy.js", exports: ["ClippyPlugin", "ContentClasses"] }
+ contentsCss:
+ - "EXT:clippy/Resources/Public/Css/clippy-tokens.css"
+ - "EXT:clippy/Resources/Public/Css/clippy.css"
diff --git a/packages/typo3-ckeditor-plugin/extension/Configuration/Sets/Clippy/config.yaml b/packages/typo3-ckeditor-plugin/extension/Configuration/Sets/Clippy/config.yaml
new file mode 100644
index 00000000..1bfdc259
--- /dev/null
+++ b/packages/typo3-ckeditor-plugin/extension/Configuration/Sets/Clippy/config.yaml
@@ -0,0 +1,2 @@
+name: nl-design-system-community/clippy
+label: Clippy
diff --git a/packages/typo3-ckeditor-plugin/extension/Configuration/page.tsconfig b/packages/typo3-ckeditor-plugin/extension/Configuration/page.tsconfig
new file mode 100644
index 00000000..2d752251
--- /dev/null
+++ b/packages/typo3-ckeditor-plugin/extension/Configuration/page.tsconfig
@@ -0,0 +1 @@
+RTE.default.preset = clippy
diff --git a/packages/typo3-ckeditor-plugin/extension/Resources/Public/JavaScript/ckeditor5.js b/packages/typo3-ckeditor-plugin/extension/Resources/Public/JavaScript/ckeditor5.js
new file mode 100644
index 00000000..79c120e4
--- /dev/null
+++ b/packages/typo3-ckeditor-plugin/extension/Resources/Public/JavaScript/ckeditor5.js
@@ -0,0 +1,6 @@
+// TYPO3 loads CKEditor 5 as per-package ES modules through an import map and has no `ckeditor5`
+// umbrella specifier. This shim re-exports the two packages the plugin needs at runtime — `Plugin`
+// from core, `View` from ui — and is the ESM counterpart of the DLL merge in
+// packages/drupal-ckeditor-plugin/vite.config.ts.
+export * from '@ckeditor/ckeditor5-core';
+export * from '@ckeditor/ckeditor5-ui';
diff --git a/packages/typo3-ckeditor-plugin/extension/composer.json b/packages/typo3-ckeditor-plugin/extension/composer.json
new file mode 100644
index 00000000..56224c33
--- /dev/null
+++ b/packages/typo3-ckeditor-plugin/extension/composer.json
@@ -0,0 +1,15 @@
+{
+ "name": "nl-design-system-community/clippy",
+ "description": "Accessibility feedback and recommendations in CKEditor, based on NL Design System guidelines.",
+ "license": "EUPL-1.2",
+ "type": "typo3-cms-extension",
+ "require": {
+ "typo3/cms-core": "^14.3",
+ "typo3/cms-rte-ckeditor": "^14.3"
+ },
+ "extra": {
+ "typo3/cms": {
+ "extension-key": "clippy"
+ }
+ }
+}
diff --git a/packages/typo3-ckeditor-plugin/extension/ext_localconf.php b/packages/typo3-ckeditor-plugin/extension/ext_localconf.php
new file mode 100644
index 00000000..0f665a92
--- /dev/null
+++ b/packages/typo3-ckeditor-plugin/extension/ext_localconf.php
@@ -0,0 +1,8 @@
+=43.0.0"
+ },
+ "devDependencies": {
+ "@nl-design-system/tsconfig": "1.0.5",
+ "@types/node": "22.20.1",
+ "ckeditor5": "48.4.0",
+ "jsdom": "30.0.1",
+ "typescript": "6.0.3",
+ "vite": "8.2.2",
+ "vitest": "4.1.10",
+ "yaml": "2.9.0"
+ }
+}
diff --git a/packages/typo3-ckeditor-plugin/scripts/generate-extension.ts b/packages/typo3-ckeditor-plugin/scripts/generate-extension.ts
new file mode 100644
index 00000000..8cb75c49
--- /dev/null
+++ b/packages/typo3-ckeditor-plugin/scripts/generate-extension.ts
@@ -0,0 +1,93 @@
+import { cpSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { createRequire } from 'node:module';
+import { resolve } from 'node:path';
+import { stringify } from 'yaml';
+
+const packageDir = resolve(import.meta.dirname, '..');
+export const extensionOut = resolve(packageDir, '../../app/typo3/packages/clippy/dist');
+
+const cssOut = resolve(extensionOut, 'Resources/Public/Css');
+const setOut = resolve(extensionOut, 'Configuration/Sets/Clippy');
+
+const SETTING_PREFIX = 'clippy.contentClasses';
+const SETTING_CATEGORY = 'contentClasses';
+const CATEGORY_LABEL = 'Content classes';
+const CATEGORY_DESCRIPTION =
+ 'Added to the HTML this editor produces. The defaults match the NL Design System. Leave a field empty to output that element without a class.';
+
+const GENERATED = '# Generated from @nl-design-system-community/ckeditor-plugin/content-classes.json - do not edit.';
+
+interface ContentClassesJson {
+ fields: { defaultValue: string; description: string; key: string; label: string }[];
+}
+
+const packageRequire = createRequire(resolve(packageDir, 'package.json'));
+
+// TEMP: tokens bundled into the extension so the typo3 demo is self-contained.
+const editorRequire = createRequire(resolve(packageDir, '../editor/package.json'));
+const TOKENS_CSS = [
+ '@nl-design-system-community/ma-design-tokens/dist/theme.css',
+ '@nl-design-system-community/ma-design-tokens/dist/color-scheme-dark/theme.css',
+ '@utrecht/design-tokens/dist/theme.css',
+ '@nl-design-system-candidate/button-css/button.css',
+];
+
+const writeYaml = (path: string, value: unknown): void =>
+ writeFileSync(path, `${GENERATED}\n${stringify(value, { lineWidth: 0 })}`);
+
+const readContentClasses = (): ContentClassesJson =>
+ JSON.parse(
+ readFileSync(packageRequire.resolve('@nl-design-system-community/ckeditor-plugin/content-classes.json'), 'utf8'),
+ ) as ContentClassesJson;
+
+function writeStyles(): void {
+ mkdirSync(cssOut, { recursive: true });
+ // Ship the editor's clippy theme tokens (--clippy-*) as the extension stylesheet.
+ copyFileSync(resolve(packageDir, '../editor/theme.css'), resolve(cssOut, 'clippy.css'));
+
+ // TEMP - inject tokens from --basis / --utrecht --nl; to be discussed
+ const tokenString = TOKENS_CSS.map((spec) => readFileSync(editorRequire.resolve(spec), 'utf8')).join('\n');
+ writeFileSync(resolve(cssOut, 'clippy-tokens.css'), tokenString);
+}
+
+// TYPO3 has no settings form to read content-classes.json at runtime, so the defaults are
+// generated into a preset Clippy.yaml imports.
+function writePreset(fields: ContentClassesJson['fields']): void {
+ writeYaml(resolve(extensionOut, 'Configuration/RTE/ContentClasses.yaml'), {
+ editor: { config: { contentClasses: Object.fromEntries(fields.map((f) => [f.key, f.defaultValue])) } },
+ });
+}
+
+// The same defaults again as site settings, so an admin can override them per site in the
+// backend. The set's page.tsconfig wins over the preset above; without the set nothing changes.
+function writeSet(fields: ContentClassesJson['fields']): void {
+ writeYaml(resolve(setOut, 'settings.definitions.yaml'), {
+ categories: { [SETTING_CATEGORY]: { description: CATEGORY_DESCRIPTION, label: CATEGORY_LABEL } },
+ settings: Object.fromEntries(
+ fields.map(({ defaultValue, description, key, label }) => [
+ `${SETTING_PREFIX}.${key}`,
+ { category: SETTING_CATEGORY, default: defaultValue, description, label, type: 'string' },
+ ]),
+ ),
+ });
+
+ writeFileSync(
+ resolve(setOut, 'page.tsconfig'),
+ [
+ GENERATED,
+ ...fields.map(({ key }) => `RTE.default.editor.config.contentClasses.${key} = {$${SETTING_PREFIX}.${key}}`),
+ '',
+ ].join('\n'),
+ );
+}
+
+export function generateExtension(): void {
+ // The TYPO3 extension itself (composer.json, RTE preset, import map, page TSconfig, shim).
+ cpSync(resolve(packageDir, 'extension'), extensionOut, { recursive: true });
+
+ writeStyles();
+
+ const { fields } = readContentClasses();
+ writePreset(fields);
+ writeSet(fields);
+}
diff --git a/packages/typo3-ckeditor-plugin/src/colorSchemeHost.test.ts b/packages/typo3-ckeditor-plugin/src/colorSchemeHost.test.ts
new file mode 100644
index 00000000..2f7cc876
--- /dev/null
+++ b/packages/typo3-ckeditor-plugin/src/colorSchemeHost.test.ts
@@ -0,0 +1,120 @@
+import { watchHostColorScheme } from '@nl-design-system-community/ckeditor-plugin';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { typo3ColorSchemeHost } from './colorSchemeHost.ts';
+
+const COLOR_SCHEME_ATTRIBUTE = 'data-color-scheme';
+
+const element = (): HTMLElement => document.createElement('div');
+
+const setPrefersDark = (matches: boolean): void => {
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn((query: string) => ({ addEventListener: () => {}, matches, media: query, removeEventListener: () => {} })),
+ );
+};
+
+const styles: HTMLStyleElement[] = [];
+
+/** Declares both schemes on the root the way a 13.3+ backend does, as real CSS. */
+const declareBothSchemes = (): void => {
+ const style = document.createElement('style');
+ style.textContent = ':root { color-scheme: light dark; }';
+ document.head.append(style);
+ styles.push(style);
+};
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ styles.splice(0).forEach((style) => style.remove());
+ document.documentElement.removeAttribute(COLOR_SCHEME_ATTRIBUTE);
+});
+
+describe('typo3ColorSchemeHost', () => {
+ it('follows data-color-scheme="dark"', () => {
+ setPrefersDark(false);
+ document.documentElement.setAttribute(COLOR_SCHEME_ATTRIBUTE, 'dark');
+
+ expect(typo3ColorSchemeHost.resolve(element())).toBe('dark');
+ });
+
+ it('follows data-color-scheme="light", even when the OS prefers dark', () => {
+ setPrefersDark(true);
+ document.documentElement.setAttribute(COLOR_SCHEME_ATTRIBUTE, 'light');
+
+ expect(typo3ColorSchemeHost.resolve(element())).toBe('light');
+ });
+
+ it('resolves data-color-scheme="auto" against the OS preference', () => {
+ setPrefersDark(true);
+ declareBothSchemes();
+ document.documentElement.setAttribute(COLOR_SCHEME_ATTRIBUTE, 'auto');
+
+ expect(typo3ColorSchemeHost.resolve(element())).toBe('dark');
+ });
+
+ it('follows the OS when the attribute is absent, which is how PageRenderer renders auto', () => {
+ setPrefersDark(true);
+ declareBothSchemes();
+
+ expect(typo3ColorSchemeHost.resolve(element())).toBe('dark');
+ });
+
+ it('is light when the attribute is absent and the OS prefers light', () => {
+ setPrefersDark(false);
+ declareBothSchemes();
+
+ expect(typo3ColorSchemeHost.resolve(element())).toBe('light');
+ });
+
+ it('is light on a version with no color scheme at all, even when the OS prefers dark', () => {
+ setPrefersDark(true);
+
+ expect(typo3ColorSchemeHost.resolve(element())).toBe('light');
+ });
+
+ it('reports when the attribute changes, and stops on teardown', async () => {
+ setPrefersDark(false);
+ const onChange = vi.fn();
+ const stop = typo3ColorSchemeHost.subscribe(element(), onChange);
+
+ document.documentElement.setAttribute(COLOR_SCHEME_ATTRIBUTE, 'dark');
+ await vi.waitFor(() => expect(onChange).toHaveBeenCalledOnce());
+
+ stop();
+ document.documentElement.setAttribute(COLOR_SCHEME_ATTRIBUTE, 'light');
+ await vi.waitFor(() => expect(document.documentElement.getAttribute(COLOR_SCHEME_ATTRIBUTE)).toBe('light'));
+ expect(onChange).toHaveBeenCalledOnce();
+ });
+
+ it('reports an OS change, which under auto changes the answer without touching the attribute', () => {
+ const listeners: (() => void)[] = [];
+ vi.stubGlobal(
+ 'matchMedia',
+ vi.fn(() => ({
+ addEventListener: (_type: string, listener: () => void) => listeners.push(listener),
+ matches: false,
+ removeEventListener: () => {},
+ })),
+ );
+ const onChange = vi.fn();
+
+ typo3ColorSchemeHost.subscribe(element(), onChange);
+ listeners.forEach((listener) => listener());
+
+ expect(onChange).toHaveBeenCalledOnce();
+ });
+});
+
+describe('the package entry point', () => {
+ it('registers the host as it loads', async () => {
+ setPrefersDark(false);
+ document.documentElement.setAttribute(COLOR_SCHEME_ATTRIBUTE, 'dark');
+ await import('./index.ts');
+ const onChange = vi.fn();
+
+ watchHostColorScheme(element(), onChange);
+
+ expect(onChange).toHaveBeenCalledExactlyOnceWith('dark');
+ });
+});
diff --git a/packages/typo3-ckeditor-plugin/src/colorSchemeHost.ts b/packages/typo3-ckeditor-plugin/src/colorSchemeHost.ts
new file mode 100644
index 00000000..363f3f42
--- /dev/null
+++ b/packages/typo3-ckeditor-plugin/src/colorSchemeHost.ts
@@ -0,0 +1,56 @@
+import {
+ matchDarkColorScheme,
+ observeAttributes,
+ resolveDeclaredColorScheme,
+ type ColorScheme,
+ type ColorSchemeHost,
+} from '@nl-design-system-community/ckeditor-plugin';
+
+const COLOR_SCHEME_ATTRIBUTE = 'data-color-scheme';
+
+/**
+ * The document element carrying the current backend color scheme.
+ *
+ * The backend nests iframes (`list_frame`, `modal_frame`), each with its own document, but
+ * TYPO3 only writes scheme changes onto the top document and `list_frame`'s. An editor inside
+ * a modal would therefore read a stale attribute from its own document, so read the top one
+ * instead — falling back to ours when the top frame is cross-origin and inaccessible.
+ */
+const schemeRoot = (): HTMLElement => {
+ try {
+ return window.top?.document.documentElement ?? document.documentElement;
+ } catch {
+ return document.documentElement;
+ }
+};
+
+/**
+ * TYPO3's backend color scheme, as written on the document element since 13.3.
+ *
+ * The attribute holds the raw setting, so only `dark` and `light` are answers. Everything else —
+ * `auto`, or the absent attribute `PageRenderer` renders for `auto` — falls through to what the
+ * backend declares in CSS: `light dark` on 13.3+, which resolves against the OS, and nothing at
+ * all on versions predating the feature, which resolves to light.
+ */
+export const typo3ColorSchemeHost: ColorSchemeHost = {
+ resolve: (): ColorScheme => {
+ const root = schemeRoot();
+ const colorScheme = root.getAttribute(COLOR_SCHEME_ATTRIBUTE);
+ if (colorScheme === 'dark' || colorScheme === 'light') {
+ return colorScheme;
+ }
+ return resolveDeclaredColorScheme(root);
+ },
+ subscribe: (_element, onChange) => {
+ const stopObserving = observeAttributes(schemeRoot(), [COLOR_SCHEME_ATTRIBUTE], onChange);
+
+ // Under `auto` the attribute stays put while the answer changes with the OS.
+ const query = matchDarkColorScheme();
+ query?.addEventListener('change', onChange);
+
+ return () => {
+ stopObserving();
+ query?.removeEventListener('change', onChange);
+ };
+ },
+};
diff --git a/packages/typo3-ckeditor-plugin/src/index.ts b/packages/typo3-ckeditor-plugin/src/index.ts
new file mode 100644
index 00000000..738a1276
--- /dev/null
+++ b/packages/typo3-ckeditor-plugin/src/index.ts
@@ -0,0 +1,6 @@
+import { setColorSchemeHost } from '@nl-design-system-community/ckeditor-plugin';
+import { typo3ColorSchemeHost } from './colorSchemeHost.ts';
+
+setColorSchemeHost(typo3ColorSchemeHost);
+
+export { ClippyPlugin, ContentClasses } from '@nl-design-system-community/ckeditor-plugin';
diff --git a/packages/typo3-ckeditor-plugin/tsconfig.json b/packages/typo3-ckeditor-plugin/tsconfig.json
new file mode 100644
index 00000000..f4ea7825
--- /dev/null
+++ b/packages/typo3-ckeditor-plugin/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "allowImportingTsExtensions": true,
+ "allowSyntheticDefaultImports": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "preserve",
+ "moduleDetection": "force",
+ "moduleResolution": "bundler",
+ "noEmit": true,
+ "noUncheckedSideEffectImports": true,
+ "verbatimModuleSyntax": true
+ },
+ "extends": "@nl-design-system/tsconfig",
+ "include": ["./src/", "./scripts/", "vite.config.ts"]
+}
diff --git a/packages/typo3-ckeditor-plugin/vite.config.ts b/packages/typo3-ckeditor-plugin/vite.config.ts
new file mode 100644
index 00000000..8a677ee5
--- /dev/null
+++ b/packages/typo3-ckeditor-plugin/vite.config.ts
@@ -0,0 +1,32 @@
+import { defineConfig, type Plugin } from 'vite';
+import { extensionOut, generateExtension } from './scripts/generate-extension.ts';
+
+function copyExtensionAssets(): Plugin {
+ return {
+ name: 'copy-extension-assets',
+ closeBundle: generateExtension,
+ };
+}
+
+export default defineConfig({
+ build: {
+ emptyOutDir: true,
+ lib: {
+ entry: 'src/index.ts',
+ formats: ['es'],
+ },
+ outDir: extensionOut,
+ rollupOptions: {
+ // TYPO3's import map has no `ckeditor5` specifier, so `paths` sends it to our shim,
+ // which re-exports the per-package modules the host has already loaded.
+ external: ['ckeditor5'],
+ output: {
+ entryFileNames: 'Resources/Public/JavaScript/clippy.js',
+ paths: {
+ ckeditor5: '@nl-design-system-community/clippy/ckeditor5.js',
+ },
+ },
+ },
+ },
+ plugins: [copyExtensionAssets()],
+});
diff --git a/packages/typo3-ckeditor-plugin/vitest.config.ts b/packages/typo3-ckeditor-plugin/vitest.config.ts
new file mode 100644
index 00000000..647a9e54
--- /dev/null
+++ b/packages/typo3-ckeditor-plugin/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 763a36c3..acc0feee 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -222,12 +222,18 @@ importers:
ckeditor5:
specifier: 48.4.0
version: 48.4.0(supports-color@10.2.2)
+ jsdom:
+ specifier: 30.0.1
+ version: 30.0.1
typescript:
specifier: 6.0.3
version: 6.0.3
vite:
specifier: 8.2.2
version: 8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)
+ vitest:
+ specifier: 4.1.10
+ version: 4.1.10(@types/node@22.20.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))
packages/editor:
dependencies:
@@ -616,6 +622,37 @@ importers:
specifier: 5.1.0
version: 5.1.0(esbuild@0.28.2)(rolldown@1.2.6)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))
+ packages/typo3-ckeditor-plugin:
+ dependencies:
+ '@nl-design-system-community/ckeditor-plugin':
+ specifier: workspace:*
+ version: link:../ckeditor-plugin
+ devDependencies:
+ '@nl-design-system/tsconfig':
+ specifier: 1.0.5
+ version: 1.0.5(typescript@6.0.3)
+ '@types/node':
+ specifier: 22.20.1
+ version: 22.20.1
+ ckeditor5:
+ specifier: 48.4.0
+ version: 48.4.0(supports-color@10.2.2)
+ jsdom:
+ specifier: 30.0.1
+ version: 30.0.1
+ typescript:
+ specifier: 6.0.3
+ version: 6.0.3
+ vite:
+ specifier: 8.2.2
+ version: 8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)
+ vitest:
+ specifier: 4.1.10
+ version: 4.1.10(@types/node@22.20.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))
+ yaml:
+ specifier: 2.9.0
+ version: 2.9.0
+
proprietary/assets: {}
proprietary/design-tokens:
@@ -3211,9 +3248,23 @@ packages:
'@vitest/browser':
optional: true
+ '@vitest/expect@4.1.10':
+ resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==}
+
'@vitest/expect@4.1.11':
resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==}
+ '@vitest/mocker@4.1.10':
+ resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
'@vitest/mocker@4.1.11':
resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==}
peerDependencies:
@@ -3225,18 +3276,33 @@ packages:
vite:
optional: true
+ '@vitest/pretty-format@4.1.10':
+ resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==}
+
'@vitest/pretty-format@4.1.11':
resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==}
+ '@vitest/runner@4.1.10':
+ resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==}
+
'@vitest/runner@4.1.11':
resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==}
+ '@vitest/snapshot@4.1.10':
+ resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==}
+
'@vitest/snapshot@4.1.11':
resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==}
+ '@vitest/spy@4.1.10':
+ resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==}
+
'@vitest/spy@4.1.11':
resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==}
+ '@vitest/utils@4.1.10':
+ resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
+
'@vitest/utils@4.1.11':
resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
@@ -6987,6 +7053,47 @@ packages:
vite:
optional: true
+ vitest@4.1.10:
+ resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==}
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@opentelemetry/api': ^1.9.0
+ '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
+ '@vitest/browser-playwright': 4.1.10
+ '@vitest/browser-preview': 4.1.10
+ '@vitest/browser-webdriverio': 4.1.10
+ '@vitest/coverage-istanbul': 4.1.10
+ '@vitest/coverage-v8': 4.1.10
+ '@vitest/ui': 4.1.10
+ happy-dom: '*'
+ jsdom: '*'
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser-playwright':
+ optional: true
+ '@vitest/browser-preview':
+ optional: true
+ '@vitest/browser-webdriverio':
+ optional: true
+ '@vitest/coverage-istanbul':
+ optional: true
+ '@vitest/coverage-v8':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
vitest@4.1.11:
resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -10194,6 +10301,15 @@ snapshots:
optionalDependencies:
'@vitest/browser': 4.1.11(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))(vitest@4.1.11)
+ '@vitest/expect@4.1.10':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@types/chai': 5.2.3
+ '@vitest/spy': 4.1.10
+ '@vitest/utils': 4.1.10
+ chai: 6.2.2
+ tinyrainbow: 3.1.1
+
'@vitest/expect@4.1.11':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -10203,6 +10319,14 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.1
+ '@vitest/mocker@4.1.10(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))':
+ dependencies:
+ '@vitest/spy': 4.1.10
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)
+
'@vitest/mocker@4.1.11(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.11
@@ -10211,15 +10335,31 @@ snapshots:
optionalDependencies:
vite: 8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)
+ '@vitest/pretty-format@4.1.10':
+ dependencies:
+ tinyrainbow: 3.1.1
+
'@vitest/pretty-format@4.1.11':
dependencies:
tinyrainbow: 3.1.1
+ '@vitest/runner@4.1.10':
+ dependencies:
+ '@vitest/utils': 4.1.10
+ pathe: 2.0.3
+
'@vitest/runner@4.1.11':
dependencies:
'@vitest/utils': 4.1.11
pathe: 2.0.3
+ '@vitest/snapshot@4.1.10':
+ dependencies:
+ '@vitest/pretty-format': 4.1.10
+ '@vitest/utils': 4.1.10
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
'@vitest/snapshot@4.1.11':
dependencies:
'@vitest/pretty-format': 4.1.11
@@ -10227,8 +10367,16 @@ snapshots:
magic-string: 0.30.21
pathe: 2.0.3
+ '@vitest/spy@4.1.10': {}
+
'@vitest/spy@4.1.11': {}
+ '@vitest/utils@4.1.10':
+ dependencies:
+ '@vitest/pretty-format': 4.1.10
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.1.1
+
'@vitest/utils@4.1.11':
dependencies:
'@vitest/pretty-format': 4.1.11
@@ -14684,6 +14832,34 @@ snapshots:
optionalDependencies:
vite: 8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)
+ vitest@4.1.10(@types/node@22.20.1)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)):
+ dependencies:
+ '@vitest/expect': 4.1.10
+ '@vitest/mocker': 4.1.10(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))
+ '@vitest/pretty-format': 4.1.10
+ '@vitest/runner': 4.1.10
+ '@vitest/snapshot': 4.1.10
+ '@vitest/spy': 4.1.10
+ '@vitest/utils': 4.1.10
+ es-module-lexer: 2.3.2
+ expect-type: 1.4.0
+ magic-string: 0.30.21
+ obug: 2.1.4
+ pathe: 2.0.3
+ picomatch: 4.0.7
+ std-env: 4.2.0
+ tinybench: 2.9.0
+ tinyexec: 1.3.0
+ tinyglobby: 0.2.17
+ tinyrainbow: 3.1.1
+ vite: 8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 22.20.1
+ jsdom: 30.0.1
+ transitivePeerDependencies:
+ - msw
+
vitest@4.1.11(@types/node@22.20.1)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(jsdom@30.0.1)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.11