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