From f1bfb8bf5ffa44b521b61acfdac606e23cf22929 Mon Sep 17 00:00:00 2001 From: Wouter Lenting Date: Mon, 17 Aug 2026 14:56:30 +0200 Subject: [PATCH 01/14] feat: add typo3 demo environment --- app/typo3/.dockerignore | 97 +++++++++++++++++++++++++++++++++++++++ app/typo3/.env.example | 8 ++++ app/typo3/Dockerfile | 49 ++++++++++++++++++++ app/typo3/README.md | 54 ++++++++++++++++++++++ app/typo3/compose.yaml | 45 ++++++++++++++++++ app/typo3/setup.sh | 37 +++++++++++++++ app/typo3/wait-for-db.php | 85 ++++++++++++++++++++++++++++++++++ 7 files changed, 375 insertions(+) create mode 100644 app/typo3/.dockerignore create mode 100644 app/typo3/.env.example create mode 100644 app/typo3/Dockerfile create mode 100644 app/typo3/README.md create mode 100644 app/typo3/compose.yaml create mode 100755 app/typo3/setup.sh create mode 100644 app/typo3/wait-for-db.php diff --git a/app/typo3/.dockerignore b/app/typo3/.dockerignore new file mode 100644 index 00000000..27612cd0 --- /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..f6fd302f --- /dev/null +++ b/app/typo3/.env.example @@ -0,0 +1,8 @@ +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 diff --git a/app/typo3/Dockerfile b/app/typo3/Dockerfile new file mode 100644 index 00000000..1147d2c0 --- /dev/null +++ b/app/typo3/Dockerfile @@ -0,0 +1,49 @@ +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. +RUN { \ + 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 setup.sh /usr/local/bin/setup.sh +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 '\n\tDocumentRoot /var/www/html/public\n\tFallbackResource /index.php\n\n' \ + > /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 + +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..094b8a9f --- /dev/null +++ b/app/typo3/README.md @@ -0,0 +1,54 @@ +# 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`, `setup.sh` or `wait-for-db.php`, 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 and the configuration are preserved in Docker volumes, so TYPO3 remains installed on the next `docker compose up`. + +To reset completely and start from scratch: + +```shell +docker compose down -v +``` + +## Clearing the cache + +After changing configuration 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/compose.yaml b/app/typo3/compose.yaml new file mode 100644 index 00000000..a2bcb06b --- /dev/null +++ b/app/typo3/compose.yaml @@ -0,0 +1,45 @@ +services: + typo3: + build: . + ports: + - 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_SETUP_CREATE_SITE: ${TYPO3_BASE_URL:-http://localhost:8082} + TYPO3_PROJECT_NAME: ${TYPO3_PROJECT_NAME:-TYPO3} + TYPO3_CONTEXT: Development + volumes: + - typo3_config:/var/www/html/config + - typo3_fileadmin:/var/www/html/public/fileadmin + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + + postgres: + image: postgres:16 + 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} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + typo3_config: + typo3_fileadmin: + postgres_data: diff --git a/app/typo3/setup.sh b/app/typo3/setup.sh new file mode 100755 index 00000000..2c4130a3 --- /dev/null +++ b/app/typo3/setup.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -e + +TYPO3=/var/www/html/vendor/bin/typo3 +TYPO3_ROOT=/var/www/html +SETTINGS="${TYPO3_ROOT}/config/system/settings.php" + +# 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 + +# The installer writes settings.php, and config/ lives in a volume, so its presence marks an +# existing install. +if [[ -f "$SETTINGS" ]]; then + echo "[setup] TYPO3 already installed. Skipping installation." +else + echo "[setup] Installing TYPO3..." + $TYPO3 setup --no-interaction --force --server-type=apache + + 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:-http://localhost:8082}/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.'); From 81e2dbb8ebd095a494a42dde9b21a9ddf21bfa5d Mon Sep 17 00:00:00 2001 From: Wouter Lenting Date: Mon, 17 Aug 2026 16:54:58 +0200 Subject: [PATCH 02/14] feat: load typo3 extensions from a composer path repository --- app/typo3/Dockerfile | 5 +++++ app/typo3/README.md | 18 ++++++++++++++++- app/typo3/compose.yaml | 1 + .../Classes/EventListener/HelloWorld.php | 20 +++++++++++++++++++ .../Configuration/JavaScriptModules.php | 10 ++++++++++ .../example/Configuration/Services.yaml | 8 ++++++++ .../Resources/Public/JavaScript/example.js | 2 ++ app/typo3/packages/example/composer.json | 20 +++++++++++++++++++ 8 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 app/typo3/packages/example/Classes/EventListener/HelloWorld.php create mode 100644 app/typo3/packages/example/Configuration/JavaScriptModules.php create mode 100644 app/typo3/packages/example/Configuration/Services.yaml create mode 100644 app/typo3/packages/example/Resources/Public/JavaScript/example.js create mode 100644 app/typo3/packages/example/composer.json diff --git a/app/typo3/Dockerfile b/app/typo3/Dockerfile index 1147d2c0..b0528073 100644 --- a/app/typo3/Dockerfile +++ b/app/typo3/Dockerfile @@ -30,6 +30,11 @@ 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/ /var/www/html/packages/ +RUN composer --working-dir=/var/www/html require --no-interaction --no-progress \ + nl-design-system-community/example:@dev \ + && composer clear-cache + COPY setup.sh /usr/local/bin/setup.sh 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 diff --git a/app/typo3/README.md b/app/typo3/README.md index 094b8a9f..8f9fcbbc 100644 --- a/app/typo3/README.md +++ b/app/typo3/README.md @@ -43,9 +43,25 @@ To reset completely and start from scratch: docker compose down -v ``` +## 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. | + ## Clearing the cache -After changing configuration inside the container: +After changing configuration or PHP inside the container: ```shell docker compose exec typo3 php vendor/bin/typo3 cache:flush diff --git a/app/typo3/compose.yaml b/app/typo3/compose.yaml index a2bcb06b..f7f0a341 100644 --- a/app/typo3/compose.yaml +++ b/app/typo3/compose.yaml @@ -19,6 +19,7 @@ services: volumes: - typo3_config:/var/www/html/config - typo3_fileadmin:/var/www/html/public/fileadmin + - ./packages:/var/www/html/packages depends_on: postgres: condition: service_healthy 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" + } + } +} From 80c5646c9bbab9273e8f4aa0a6cd940d22683970 Mon Sep 17 00:00:00 2001 From: Wouter Lenting Date: Tue, 18 Aug 2026 17:16:06 +0200 Subject: [PATCH 03/14] feat: add typo3 support for the clippy ckeditor plugin --- app/typo3/Dockerfile | 15 ++- app/typo3/README.md | 17 +++- app/typo3/compose.yaml | 2 + .../src/plugin/content-classes-config.test.ts | 22 ++++- .../src/plugin/content-classes-config.ts | 9 ++ .../Plugin/CKEditor5Plugin/ContentClasses.php | 8 +- packages/typo3-ckeditor-plugin/README.md | 40 ++++++++ .../Configuration/JavaScriptModules.php | 13 +++ .../extension/Configuration/RTE/Clippy.yaml | 11 +++ .../Configuration/Sets/Clippy/config.yaml | 2 + .../extension/Configuration/page.tsconfig | 1 + .../Resources/Public/JavaScript/ckeditor5.js | 6 ++ .../extension/composer.json | 15 +++ .../extension/ext_localconf.php | 8 ++ packages/typo3-ckeditor-plugin/package.json | 31 +++++++ .../scripts/generate-extension.ts | 93 +++++++++++++++++++ packages/typo3-ckeditor-plugin/src/index.ts | 1 + packages/typo3-ckeditor-plugin/tsconfig.json | 16 ++++ packages/typo3-ckeditor-plugin/vite.config.ts | 32 +++++++ pnpm-lock.yaml | 25 +++++ 20 files changed, 352 insertions(+), 15 deletions(-) create mode 100644 packages/typo3-ckeditor-plugin/README.md create mode 100644 packages/typo3-ckeditor-plugin/extension/Configuration/JavaScriptModules.php create mode 100644 packages/typo3-ckeditor-plugin/extension/Configuration/RTE/Clippy.yaml create mode 100644 packages/typo3-ckeditor-plugin/extension/Configuration/Sets/Clippy/config.yaml create mode 100644 packages/typo3-ckeditor-plugin/extension/Configuration/page.tsconfig create mode 100644 packages/typo3-ckeditor-plugin/extension/Resources/Public/JavaScript/ckeditor5.js create mode 100644 packages/typo3-ckeditor-plugin/extension/composer.json create mode 100644 packages/typo3-ckeditor-plugin/extension/ext_localconf.php create mode 100644 packages/typo3-ckeditor-plugin/package.json create mode 100644 packages/typo3-ckeditor-plugin/scripts/generate-extension.ts create mode 100644 packages/typo3-ckeditor-plugin/src/index.ts create mode 100644 packages/typo3-ckeditor-plugin/tsconfig.json create mode 100644 packages/typo3-ckeditor-plugin/vite.config.ts diff --git a/app/typo3/Dockerfile b/app/typo3/Dockerfile index b0528073..d89c3b2c 100644 --- a/app/typo3/Dockerfile +++ b/app/typo3/Dockerfile @@ -14,10 +14,9 @@ RUN apt-get update \ 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/* - + && rm -rf /var/lib/apt/lists/* \ # The minimums TYPO3's environment check asks for. -RUN { \ + && { \ echo 'memory_limit = 512M'; \ echo 'max_execution_time = 240'; \ echo 'max_input_vars = 1500'; \ @@ -30,9 +29,11 @@ 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/ /var/www/html/packages/ +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 COPY setup.sh /usr/local/bin/setup.sh @@ -41,7 +42,11 @@ COPY wait-for-db.php /usr/local/bin/wait-for-db.php # 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 '\n\tDocumentRoot /var/www/html/public\n\tFallbackResource /index.php\n\n' \ + && 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. diff --git a/app/typo3/README.md b/app/typo3/README.md index 8f9fcbbc..ee58b9f7 100644 --- a/app/typo3/README.md +++ b/app/typo3/README.md @@ -55,9 +55,20 @@ Edits under `Resources/Public/` are reflected immediately on page reload. Change ### Available extensions -| Extension | Description | -| --------- | ---------------------------------------------------------------- | -| `example` | Logs "Hello World" to the browser console on every backend page. | +| 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. Build it before the first `docker compose up --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 diff --git a/app/typo3/compose.yaml b/app/typo3/compose.yaml index f7f0a341..a62102b4 100644 --- a/app/typo3/compose.yaml +++ b/app/typo3/compose.yaml @@ -20,6 +20,8 @@ services: - 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 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/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/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", + "typescript": "6.0.3", + "vite": "8.2.2", + "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/index.ts b/packages/typo3-ckeditor-plugin/src/index.ts new file mode 100644 index 00000000..bdc7e61e --- /dev/null +++ b/packages/typo3-ckeditor-plugin/src/index.ts @@ -0,0 +1 @@ +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/pnpm-lock.yaml b/pnpm-lock.yaml index 763a36c3..46459fc9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -616,6 +616,31 @@ 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) + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 8.2.1 + version: 8.2.1(@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: From db651ceecc075f0c888999aab3a6e17bfed6e02b Mon Sep 17 00:00:00 2001 From: Wouter Lenting Date: Mon, 24 Aug 2026 14:05:12 +0200 Subject: [PATCH 04/14] chore: exclude demo wait-for-db.php from Sonar duplication detection --- .sonarcloud.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 54481c4031138a0f46c442985fbba5310426da9b Mon Sep 17 00:00:00 2001 From: Wouter Lenting Date: Tue, 25 Aug 2026 10:25:45 +0200 Subject: [PATCH 05/14] Chore: Use postgres alpine to reflect the prod image --- app/drupal/compose.yaml | 2 +- app/typo3/compose.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/compose.yaml b/app/typo3/compose.yaml index a62102b4..057a7129 100644 --- a/app/typo3/compose.yaml +++ b/app/typo3/compose.yaml @@ -28,7 +28,7 @@ services: restart: unless-stopped postgres: - image: postgres:16 + image: postgres:16-alpine environment: POSTGRES_DB: ${POSTGRES_DB:-typo3} POSTGRES_USER: ${POSTGRES_USER:-typo3} From 32180beaa5e5e601c70b5438eaf4adb50a2a42da Mon Sep 17 00:00:00 2001 From: Wouter Lenting Date: Tue, 25 Aug 2026 10:43:06 +0200 Subject: [PATCH 06/14] chore: remove dead configuration from the Drupal Docker workflow --- .github/workflows/docker-drupal.yml | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) 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 }} From 1ae0622687071810d8ece53eca2b8762a0d74020 Mon Sep 17 00:00:00 2001 From: Wouter Lenting Date: Tue, 25 Aug 2026 13:40:11 +0200 Subject: [PATCH 07/14] feat: publish the typo3 demo image --- .github/workflows/docker-typo3.yml | 75 ++++++++++++++++++++ app/drupal/.dockerignore | 4 +- app/typo3/.dockerignore | 4 +- app/typo3/.env.example | 4 ++ app/typo3/Dockerfile | 12 ++++ app/typo3/README.md | 36 +++++++++- app/typo3/check-install.php | 36 ++++++++++ app/typo3/compose.yaml | 9 ++- app/typo3/config/sites/main/config.yaml | 15 ++++ app/typo3/config/sites/main/setup.typoscript | 11 +++ app/typo3/config/system/additional.php | 16 +++++ app/typo3/config/system/settings.php | 34 +++++++++ app/typo3/setup.sh | 24 +++++-- 13 files changed, 265 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/docker-typo3.yml create mode 100644 app/typo3/check-install.php create mode 100644 app/typo3/config/sites/main/config.yaml create mode 100644 app/typo3/config/sites/main/setup.typoscript create mode 100644 app/typo3/config/system/additional.php create mode 100644 app/typo3/config/system/settings.php 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/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/typo3/.dockerignore b/app/typo3/.dockerignore index 27612cd0..9faecc07 100644 --- a/app/typo3/.dockerignore +++ b/app/typo3/.dockerignore @@ -56,8 +56,8 @@ tsconfig.tsbuildinfo *.der *.env *.key -_.pem -.env._ +*.pem +.env.* # Example file exceptions diff --git a/app/typo3/.env.example b/app/typo3/.env.example index f6fd302f..2a10a2b2 100644 --- a/app/typo3/.env.example +++ b/app/typo3/.env.example @@ -6,3 +6,7 @@ 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 index d89c3b2c..05d922ac 100644 --- a/app/typo3/Dockerfile +++ b/app/typo3/Dockerfile @@ -36,7 +36,11 @@ RUN composer --working-dir=/var/www/html require --no-interaction --no-progress 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. @@ -53,6 +57,14 @@ RUN chmod +x /usr/local/bin/setup.sh \ && 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"] diff --git a/app/typo3/README.md b/app/typo3/README.md index ee58b9f7..04537595 100644 --- a/app/typo3/README.md +++ b/app/typo3/README.md @@ -13,7 +13,7 @@ cp .env.example .env docker compose up ``` -If you change the `Dockerfile`, `setup.sh` or `wait-for-db.php`, rebuild explicitly — `docker compose up` reuses an existing local image otherwise: +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 @@ -35,7 +35,7 @@ TYPO3 has no one-time login link, so log in with the credentials from `.env`. No docker compose down ``` -The database and the configuration are preserved in Docker volumes, so TYPO3 remains installed on the next `docker compose up`. +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: @@ -43,6 +43,31 @@ To reset completely and start from scratch: 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: @@ -60,7 +85,12 @@ Edits under `Resources/Public/` are reflected immediately on page reload. Change | `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. Build it before the first `docker compose up --build`: +`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 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 index 057a7129..5c904d0c 100644 --- a/app/typo3/compose.yaml +++ b/app/typo3/compose.yaml @@ -2,7 +2,7 @@ services: typo3: build: . ports: - - 8082:8080 + - ${TYPO3_PORT:-8082}:8080 environment: TYPO3_DB_DRIVER: postgres TYPO3_DB_HOST: postgres @@ -13,8 +13,11 @@ services: 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_SETUP_CREATE_SITE: ${TYPO3_BASE_URL:-http://localhost:8082} + 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 @@ -36,7 +39,7 @@ services: volumes: - postgres_data:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-typo3} -d ${POSTGRES_DB:-typo3}"] interval: 10s timeout: 5s retries: 5 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/setup.sh b/app/typo3/setup.sh index 2c4130a3..5fd37c00 100755 --- a/app/typo3/setup.sh +++ b/app/typo3/setup.sh @@ -3,7 +3,9 @@ set -e TYPO3=/var/www/html/vendor/bin/typo3 TYPO3_ROOT=/var/www/html -SETTINGS="${TYPO3_ROOT}/config/system/settings.php" +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 @@ -11,14 +13,26 @@ if ! php /usr/local/bin/wait-for-db.php; then exit 1 fi -# The installer writes settings.php, and config/ lives in a volume, so its presence marks an -# existing install. -if [[ -f "$SETTINGS" ]]; then +# 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 @@ -28,7 +42,7 @@ $TYPO3 extension:setup $TYPO3 cache:flush echo "" -echo " TYPO3 ready → ${TYPO3_SETUP_CREATE_SITE:-http://localhost:8082}/typo3" +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 "" From 1c2f0549d952c54386dce7e64470b2eaf9ebf9d5 Mon Sep 17 00:00:00 2001 From: Wouter Lenting Date: Tue, 1 Sep 2026 15:58:11 +0200 Subject: [PATCH 08/14] refactor: let a registered host decide the editor's color scheme --- .../src/components/SiteHeader.astro | 19 +- .../editor-website/src/layouts/document.astro | 21 +- packages/editor/package.json | 3 + packages/editor/src/entries/color-scheme.ts | 10 +- packages/editor/src/utils/colorScheme.test.ts | 363 +++++++++++++++--- packages/editor/src/utils/colorScheme.ts | 178 +++++++-- 6 files changed, 486 insertions(+), 108 deletions(-) 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..36cd73b3 100644 --- a/packages/editor-website/src/layouts/document.astro +++ b/packages/editor-website/src/layouts/document.astro @@ -2,6 +2,11 @@ import SiteHeader from '../components/SiteHeader.astro'; import SiteFooter from '../components/SiteFooter.astro'; import type { Locale } from '../i18n/translations.ts'; +import { + COLOR_SCHEME_STORAGE_KEY, + DARK_COLOR_SCHEME_CLASS, + DARK_COLOR_SCHEME_QUERY, +} from '@nl-design-system-community/editor/color-scheme'; interface Props { title?: string; @@ -18,13 +23,19 @@ const locale = (Astro.currentLocale ?? 'nl') as Locale; {/* Apply the stored/preferred color scheme before first paint to avoid a flash of the wrong theme. */} - + + From 1aa27eb36306f86e448b96033df56f03e7df2af9 Mon Sep 17 00:00:00 2001 From: Wouter Lenting Date: Mon, 7 Sep 2026 10:18:09 +0200 Subject: [PATCH 14/14] refactor: load the color scheme script as a processed astro module --- .../editor-website/src/layouts/document.astro | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/packages/editor-website/src/layouts/document.astro b/packages/editor-website/src/layouts/document.astro index 36cd73b3..13bc9df1 100644 --- a/packages/editor-website/src/layouts/document.astro +++ b/packages/editor-website/src/layouts/document.astro @@ -2,11 +2,6 @@ import SiteHeader from '../components/SiteHeader.astro'; import SiteFooter from '../components/SiteFooter.astro'; import type { Locale } from '../i18n/translations.ts'; -import { - COLOR_SCHEME_STORAGE_KEY, - DARK_COLOR_SCHEME_CLASS, - DARK_COLOR_SCHEME_QUERY, -} from '@nl-design-system-community/editor/color-scheme'; interface Props { title?: string; @@ -22,24 +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. */} -