diff --git a/.env b/.env index 4d598cc..3c692d1 100644 --- a/.env +++ b/.env @@ -28,3 +28,9 @@ APP_SHARE_DIR=var/share # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands DEFAULT_URI=http://localhost ###< symfony/routing ### + +###> brand identity ### +BRAND_NAME="AI Bibliotek" +BRAND_TAGLINE="del & hjemtag assistenter" +BRAND_INITIALS="AI" +###< brand identity ### diff --git a/.github/workflows/block-on-label.yaml b/.github/workflows/block-on-label.yaml new file mode 100644 index 0000000..74ddf96 --- /dev/null +++ b/.github/workflows/block-on-label.yaml @@ -0,0 +1,39 @@ +### Block merge on label +### +### Fails the check while a `do-not-merge` label is applied to the +### pull request. The label is used to gate merges that depend on +### something landing elsewhere (another PR, an upstream release, a +### review by a specific team). +### +### Removing the label and re-running the check turns it green. To +### make the merge button itself unavailable while the label is set, +### this check has to be marked as a required status check in the +### repository's branch-protection rules. + +name: Block merge on label + +on: + pull_request: + types: + - opened + - reopened + - synchronize + - labeled + - unlabeled + +jobs: + block-on-label: + name: Block on do-not-merge label + runs-on: ubuntu-latest + steps: + - name: Check for do-not-merge label + env: + LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: | + echo "Labels on this PR: $LABELS" + if echo "$LABELS" | grep -qx '"do-not-merge"' \ + || echo "$LABELS" | grep -q '"do-not-merge"'; then + echo "::error::This PR carries the 'do-not-merge' label. Remove the label once the blocking dependency is resolved." + exit 1 + fi + echo "No blocking label present." diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index c55bd8e..7cce7b8 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -19,6 +19,9 @@ jobs: - name: Install dependencies run: docker compose run --rm phpfpm composer install + - name: Build Tailwind CSS + run: docker compose run --rm phpfpm bin/console tailwind:build + - name: Run PHPUnit with coverage run: docker compose run -e XDEBUG_MODE=coverage --rm phpfpm vendor/bin/phpunit --coverage-clover=coverage/clover.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index c1783d6..30aa2a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Decision recorded in [ADR 002](docs/adr/002-frontend-tooling.md). - Base Twig layout (`templates/base.html.twig`) and frontend asset entrypoints (`assets/app.js`, `assets/styles/app.css`). +- Placeholder frontpage at `/` (`App\Controller\FrontpageController`) + that previews the AI Bibliotek design with hardcoded sample data: + hero, search prompt, sample-assistant rail, "Sådan virker det" + steps, and "Kommer snart" chips. Follows the prototype mock at + `itk-dev/research-projects/docs/public/projects/ai-bibliotek/mocks`. +- Site chrome (header with brand + nav, footer) in + `templates/base.html.twig`, with the Fraunces/Geist font stack + preloaded from Google Fonts. +- Tailwind v4 design tokens (`@theme` in `assets/styles/app.css`) + matching the prototype palette and typography. +- Stimulus controller `nav_toggle_controller` driving the mobile + navigation menu. +- GitHub Action `block-on-label` that fails the check while a + `do-not-merge` label is applied to a pull request, providing a + per-PR merge gate for dependencies (e.g. another PR that must land + first). - `LICENSE` file at repo root containing the full Mozilla Public License 2.0 text. - ADR `docs/adr/002-project-license-mpl-2.md` recording the MPL-2.0 license decision and its rationale. diff --git a/README.md b/README.md index 92e7c36..ff1d30d 100644 --- a/README.md +++ b/README.md @@ -96,12 +96,18 @@ itkdev-docker-compose php bin/console asset-map:compile itkdev-docker-compose php bin/console debug:asset-map ``` +> **Heads-up:** there is no live Tailwind watcher running by default, and +> `cache:clear` does **not** rebuild the stylesheet. After editing a +> template that introduces a utility class not already in use (e.g. +> `pt-2`, `grid-cols-1`), run `tailwind:build` — or keep a +> `tailwind:build --watch` terminal open while you style. + Source files live under [`assets/`](assets): - `assets/app.js` — JavaScript entrypoint, boots Stimulus. - `assets/styles/app.css` — Tailwind entrypoint (`@import "tailwindcss";`). - `assets/controllers/` — Stimulus controllers, auto-registered by - filename (`hello_controller.js` → `data-controller="hello"`). + filename (`nav_toggle_controller.js` → `data-controller="nav-toggle"`). For one-off commands without a dedicated task, fall back to the underlying tools, e.g. `docker compose --profile dev run --rm prettier ` or diff --git a/assets/controllers/hello_controller.js b/assets/controllers/hello_controller.js deleted file mode 100644 index 6fc936c..0000000 --- a/assets/controllers/hello_controller.js +++ /dev/null @@ -1,17 +0,0 @@ -import { Controller } from "@hotwired/stimulus"; - -/* - * This is an example Stimulus controller! - * - * Any element with a data-controller="hello" attribute will cause - * this controller to be executed. The name "hello" comes from the filename: - * hello_controller.js -> "hello" - * - * Delete this file or adapt it for your use! - */ -export default class extends Controller { - connect() { - this.element.textContent = - "Hello Stimulus! Edit me in assets/controllers/hello_controller.js"; - } -} diff --git a/assets/controllers/nav_toggle_controller.js b/assets/controllers/nav_toggle_controller.js new file mode 100644 index 0000000..9b97ae3 --- /dev/null +++ b/assets/controllers/nav_toggle_controller.js @@ -0,0 +1,26 @@ +import { Controller } from "@hotwired/stimulus"; + +/* + * Mobile navigation toggle. + * + * Mounted on the header container (`data-controller="nav-toggle"`). + * The toggle button uses `data-action="click->nav-toggle#toggle"` and + * the nav element is the `menu` target. Flips `aria-expanded` on the + * button and the `hidden` utility class on the menu so screen readers + * and the CSS toggle effect stay in sync. + */ +export default class extends Controller { + static targets = ["menu"]; + + toggle(event) { + const button = event.currentTarget; + const expanded = button.getAttribute("aria-expanded") === "true"; + button.setAttribute("aria-expanded", String(!expanded)); + + if (!this.hasMenuTarget) { + return; + } + this.menuTarget.classList.toggle("hidden", expanded); + this.menuTarget.classList.toggle("flex", !expanded); + } +} diff --git a/assets/styles/app.css b/assets/styles/app.css index f1d8c73..49926fb 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -1 +1,157 @@ @import "tailwindcss"; + +/* + * Design tokens mirroring the AI Bibliotek prototype + * (https://github.com/itk-dev/research-projects/tree/main/docs/public/projects/ai-bibliotek/mocks). + * Registered via Tailwind v4's `@theme` so the values become utility + * variants — e.g. `bg-surface`, `text-ink`, `font-display`. + */ +@theme { + /* Surface + ink palette */ + --color-bg: #ffffff; + --color-surface: #ffffff; + --color-surface-2: #fbf9f5; + --color-border: #e3ddd1; + --color-border-strong: #c8bea9; + --color-line: #ddd5c4; + --color-ink: #11192a; + --color-text: #1c2433; + --color-text-muted: #5b6478; + + /* Brand */ + --color-primary: #0f766e; + --color-primary-hover: #0c5e57; + --color-primary-ink: #ffffff; + --color-accent: #c89b3c; + --color-accent-soft: #f0e3c2; + --color-accent-deep: #8a6a1f; + + /* Typography */ + --font-display: + "Fraunces", "Source Serif Pro", "Iowan Old Style", Georgia, serif; + --font-sans: + "Geist", "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, Helvetica, Arial, sans-serif; + --font-mono: + "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, + monospace; +} + +/* + * Base layer: page chrome that has to apply to regardless of + * which utility classes the template carries. Mirrors the prototype's + * body styling so the same font stack and ink colour are used + * everywhere. + */ +@layer base { + body { + font-family: var(--font-sans); + color: var(--color-text); + background-color: var(--color-bg); + font-feature-settings: "ss01", "ss02"; + } + + h1, + h2, + h3, + h4 { + font-family: var(--font-display); + font-weight: 500; + color: var(--color-ink); + letter-spacing: -0.01em; + line-height: 1.15; + } +} + +/* + * Component layer: small set of compound patterns that would be + * unwieldy as inline utility lists. Each is named after its + * prototype counterpart and only contains decorative bits that + * Tailwind utilities don't express directly (pseudo-elements, + * keyframes). + */ +@layer components { + /* Eyebrow label with leading decorative rule, used as a section kicker. */ + /* font-family is set explicitly so an

renders in */ + /* the sans body font instead of the serif `--font-display` that the */ + /* @layer base { h1..h4 } rule above otherwise applies to headings. */ + .eyebrow { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-family: var(--font-sans); + font-size: 0.78rem; + font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--color-accent-deep); + } + .eyebrow::before { + content: ""; + display: inline-block; + width: 24px; + height: 1px; + background-color: var(--color-accent-deep); + } + + /* Staggered fade-up on the main view's direct children. */ + @keyframes fade-up { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + .view-root > * { + animation: fade-up 220ms cubic-bezier(0.22, 1, 0.36, 1) both; + } + .view-root > *:nth-child(1) { + animation-delay: 0ms; + } + .view-root > *:nth-child(2) { + animation-delay: 40ms; + } + .view-root > *:nth-child(3) { + animation-delay: 80ms; + } + .view-root > *:nth-child(4) { + animation-delay: 120ms; + } + .view-root > *:nth-child(5) { + animation-delay: 160ms; + } + @media (prefers-reduced-motion: reduce) { + .view-root > * { + animation: none; + } + } + + /* + * Animated hamburger toggle. The three bars rotate into an X when + * `aria-expanded="true"`. Hand-rolled because Tailwind utilities + * can't express child-element transforms tied to a parent ARIA + * state. + */ + .nav-toggle-bar { + display: block; + width: 18px; + height: 2px; + background-color: var(--color-ink); + border-radius: 2px; + transition: + transform 220ms ease, + opacity 220ms ease; + } + .nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(1) { + transform: translateY(5.5px) rotate(45deg); + } + .nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(2) { + opacity: 0; + } + .nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(3) { + transform: translateY(-5.5px) rotate(-45deg); + } +} diff --git a/composer.json b/composer.json index 4a69f18..79beba5 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,9 @@ "symfony/framework-bundle": "~8.1.0", "symfony/runtime": "~8.1.0", "symfony/stimulus-bundle": "^3.1", + "symfony/translation": "~8.1.0", "symfony/twig-bundle": "~8.1.0", + "symfony/ux-twig-component": "^3.1", "symfony/yaml": "~8.1.0", "symfonycasts/tailwind-bundle": "^0.13.0", "twig/extra-bundle": "^2.12 || ^3.0", diff --git a/composer.lock b/composer.lock index 84fcd77..abb6510 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "21f6c085529afc7c14f91927af22ea88", + "content-hash": "5e5be02b1936d9ba94164fc47d576055", "packages": [ { "name": "composer/semver", @@ -2474,6 +2474,173 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/property-access", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/9261ef060f26cc7b728f67f141ba19b98a6209a9", + "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/property-info": "^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/property-info", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/4721e8c56d0cd2378e0ef9a9899f810008b859f7", + "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, { "name": "symfony/routing", "version": "v8.1.0", @@ -2891,6 +3058,99 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/translation", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, { "name": "symfony/translation-contracts", "version": "v3.7.0", @@ -3165,6 +3425,176 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/type-info", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/ux-twig-component", + "version": "v3.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/ux-twig-component.git", + "reference": "69763f39367d7185ebff3a8aec3e9d6ec7e10d55" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/69763f39367d7185ebff3a8aec3e9d6ec7e10d55", + "reference": "69763f39367d7185ebff3a8aec3e9d6ec7e10d55", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/deprecation-contracts": "^2.2|^3.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "twig/twig": "^3.10.3" + }, + "conflict": { + "symfony/config": "<6.4" + }, + "require-dev": { + "phpunit/phpunit": "^11.1|^12.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/stimulus-bundle": "^2.9.1|^3.0", + "symfony/twig-bundle": "^7.4|^8.0", + "twig/extra-bundle": "^3.10.3", + "twig/html-extra": "^3.10.3" + }, + "type": "symfony-bundle", + "extra": { + "thanks": { + "url": "https://github.com/symfony/ux", + "name": "symfony/ux" + } + }, + "autoload": { + "psr-4": { + "Symfony\\UX\\TwigComponent\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Twig components for Symfony", + "homepage": "https://symfony.com", + "keywords": [ + "components", + "symfony-ux", + "twig" + ], + "support": { + "source": "https://github.com/symfony/ux-twig-component/tree/v3.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T07:08:56+00:00" + }, { "name": "symfony/var-dumper", "version": "v8.1.0", diff --git a/config/bundles.php b/config/bundles.php index 1a6d22b..76e2980 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -6,4 +6,5 @@ Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true], Symfonycasts\TailwindBundle\SymfonycastsTailwindBundle::class => ['all' => true], + Symfony\UX\TwigComponent\TwigComponentBundle::class => ['all' => true], ]; diff --git a/config/packages/property_info.yaml b/config/packages/property_info.yaml new file mode 100644 index 0000000..dd31b9d --- /dev/null +++ b/config/packages/property_info.yaml @@ -0,0 +1,3 @@ +framework: + property_info: + with_constructor_extractor: true diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml new file mode 100644 index 0000000..40921c7 --- /dev/null +++ b/config/packages/translation.yaml @@ -0,0 +1,7 @@ +framework: + default_locale: da + translator: + default_path: '%kernel.project_dir%/translations' + fallbacks: + - da + providers: diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 3f795d9..8a505e8 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -1,5 +1,9 @@ twig: file_name_pattern: '*.twig' + globals: + brand_name: '%env(BRAND_NAME)%' + brand_tagline: '%env(BRAND_TAGLINE)%' + brand_initials: '%env(BRAND_INITIALS)%' when@test: twig: diff --git a/config/packages/twig_component.yaml b/config/packages/twig_component.yaml new file mode 100644 index 0000000..fd17ac6 --- /dev/null +++ b/config/packages/twig_component.yaml @@ -0,0 +1,5 @@ +twig_component: + anonymous_template_directory: 'components/' + defaults: + # Namespace & directory for components + App\Twig\Components\: 'components/' diff --git a/config/reference.php b/config/reference.php index 14b81cc..f852f60 100644 --- a/config/reference.php +++ b/config/reference.php @@ -305,7 +305,7 @@ * }, * }, * translator?: bool|array{ // Translator configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * fallbacks?: string|list, * logging?: bool|Param, // Default: false * formatter?: scalar|Param|null, // Default: "translator.formatter.default" @@ -369,7 +369,7 @@ * }>, * }, * property_access?: bool|array{ // Property access configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * magic_call?: bool|Param, // Default: false * magic_get?: bool|Param, // Default: true * magic_set?: bool|Param, // Default: true @@ -377,11 +377,11 @@ * throw_exception_on_invalid_property_path?: bool|Param, // Default: true * }, * type_info?: bool|array{ // Type info configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * aliases?: array, * }, * property_info?: bool|array{ // Property info configuration - * enabled?: bool|Param, // Default: false + * enabled?: bool|Param, // Default: true * with_constructor_extractor?: bool|Param, // Registers the constructor extractor. // Default: true * }, * cache?: array{ // Cache configuration @@ -794,6 +794,17 @@ * strict_mode?: bool|Param|null, // When enabled, an exception will be thrown if there are no built assets (default: false in `test` env, true otherwise) // Default: null * process_timeout?: int|Param, // Timeout in seconds for the Tailwind build process - use "0" to disable // Default: 60 * } + * @psalm-type TwigComponentConfig = array{ + * defaults?: array, + * anonymous_template_directory?: scalar|Param|null, // Defaults to `components` + * profiler?: bool|array{ // Enables the profiler for Twig Component + * enabled?: bool|Param, // Default: "%kernel.debug%" + * collect_components?: bool|Param, // Collect components instances // Default: true + * }, + * } * @psalm-type ConfigType = array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -803,6 +814,7 @@ * twig_extra?: TwigExtraConfig, * stimulus?: StimulusConfig, * symfonycasts_tailwind?: SymfonycastsTailwindConfig, + * twig_component?: TwigComponentConfig, * "when@dev"?: array{ * imports?: ImportsConfig, * parameters?: ParametersConfig, @@ -812,6 +824,7 @@ * twig_extra?: TwigExtraConfig, * stimulus?: StimulusConfig, * symfonycasts_tailwind?: SymfonycastsTailwindConfig, + * twig_component?: TwigComponentConfig, * }, * "when@prod"?: array{ * imports?: ImportsConfig, @@ -822,6 +835,7 @@ * twig_extra?: TwigExtraConfig, * stimulus?: StimulusConfig, * symfonycasts_tailwind?: SymfonycastsTailwindConfig, + * twig_component?: TwigComponentConfig, * }, * "when@test"?: array{ * imports?: ImportsConfig, @@ -832,6 +846,7 @@ * twig_extra?: TwigExtraConfig, * stimulus?: StimulusConfig, * symfonycasts_tailwind?: SymfonycastsTailwindConfig, + * twig_component?: TwigComponentConfig, * }, * ... 'Aarhus Kommune', + 'model' => 'gpt-4o', + 'name' => 'Borgerservice-vejviser', + 'summary' => 'Hjælper sagsbehandlere med at finde den rigtige paragraf i lov om social service og opsummere borgerens situation.', + ], + [ + 'kommune' => 'Københavns Kommune', + 'model' => 'claude-3.5-sonnet', + 'name' => 'Mødereferent', + 'summary' => 'Tager udgangspunkt i et indtalt møde og leverer struktureret referat med beslutninger, ansvar og deadlines.', + ], + [ + 'kommune' => 'Odense Kommune', + 'model' => 'llama-3.1-70b', + 'name' => 'Journaliseringsassistent', + 'summary' => 'Foreslår journalplan-numre og overskrifter ud fra dokumentets indhold, så fagmedarbejdere kan godkende i et klik.', + ], + [ + 'kommune' => 'Vejle Kommune', + 'model' => 'gpt-4o-mini', + 'name' => 'Skole- og dagtilbudssvar', + 'summary' => 'Drafter svar til forældrehenvendelser på skole- og dagtilbudsområdet med kildehenvisninger til kommunens egen vejledningssamling.', + ], + [ + 'kommune' => 'Aalborg Kommune', + 'model' => 'mistral-large', + 'name' => 'Tilsynsrapport-assistent', + 'summary' => 'Læser plejehjemstilsynsrapporter og fremhæver afvigelser, opfølgningspunkter og forbedringer over tid.', + ], + ]; + + /** + * Render the placeholder frontpage. + * + * Anonymous visitors to `/` receive a design-preview landing page + * that mirrors the AI Bibliotek prototype. Hero, search prompt, + * sample-assistant rail, and "Sådan virker det" steps are rendered + * with hardcoded sample data — the point is to convey what the + * catalogue will feel like before the persistence and search + * layers land. + * + * @return Response the rendered `frontpage/index.html.twig` template + */ + #[Route('/', name: 'app_frontpage', methods: ['GET'])] + public function index(): Response + { + $kommuner = array_unique(array_column(self::SAMPLE_ASSISTANTS, 'kommune')); + $models = array_unique(array_column(self::SAMPLE_ASSISTANTS, 'model')); + + return $this->render('frontpage/index.html.twig', [ + 'assistants' => self::SAMPLE_ASSISTANTS, + 'stats' => [ + 'assistants' => count(self::SAMPLE_ASSISTANTS), + 'kommuner' => count($kommuner), + 'models' => count($models), + ], + ]); + } +} diff --git a/src/Twig/DevTemplateMarkerExtension.php b/src/Twig/DevTemplateMarkerExtension.php new file mode 100644 index 0000000..419e197 --- /dev/null +++ b/src/Twig/DevTemplateMarkerExtension.php @@ -0,0 +1,44 @@ + the visitors + * to register + */ + public function getNodeVisitors(): array + { + if ('dev' !== $this->environment) { + return []; + } + + return [new DevTemplateMarkerNodeVisitor()]; + } +} diff --git a/src/Twig/DevTemplateMarkerNodeVisitor.php b/src/Twig/DevTemplateMarkerNodeVisitor.php new file mode 100644 index 0000000..40aa315 --- /dev/null +++ b/src/Twig/DevTemplateMarkerNodeVisitor.php @@ -0,0 +1,124 @@ +` / `` around each template + * boundary. Templates that `extends` another are wrapped at their + * `body` block instead, because their top-level body never runs at + * render time. + * + * Registered only in the dev environment by + * {@see DevTemplateMarkerExtension::getNodeVisitors()}; the visitor + * itself trusts the registration and unconditionally wraps every + * non-namespaced template it sees. + */ +final class DevTemplateMarkerNodeVisitor implements NodeVisitorInterface +{ + public function enterNode(Node $node, Environment $env): Node + { + return $node; + } + + /** + * Wrap the template body (or its `body` block, for extending + * templates) with begin and end marker TextNodes. + * + * @param Node $node the node being left + * @param Environment $env the Twig environment (unused) + * + * @return Node the original node, mutated in place when applicable + */ + public function leaveNode(Node $node, Environment $env): Node + { + if (!$node instanceof ModuleNode) { + return $node; + } + + $name = $node->getTemplateName(); + if (null === $name || str_starts_with($name, '@')) { + // Skip framework / vendor templates loaded via a Twig namespace. + return $node; + } + + $line = $node->getTemplateLine(); + $prefix = new TextNode('', $line); + $suffix = new TextNode('', $line); + + if ($node->hasNode('parent')) { + $this->wrapExtendingBody($node, $prefix, $suffix); + + return $node; + } + + $this->wrap($node, $prefix, $suffix); + + return $node; + } + + /** + * For templates that `extends` another, wrap the content of the + * `body` block — the template's top-level body never runs. + * + * Twig stores each block as a `BodyNode` containing the + * `BlockNode`; we iterate to find the BlockNode and replace its + * body with the wrapped sequence. + * + * @param ModuleNode $node the extending module + * @param TextNode $prefix the opening marker + * @param TextNode $suffix the closing marker + */ + private function wrapExtendingBody(ModuleNode $node, TextNode $prefix, TextNode $suffix): void + { + $blocks = $node->getNode('blocks'); + if (!$blocks->hasNode('body')) { + return; + } + foreach ($blocks->getNode('body') as $child) { + if ($child instanceof BlockNode) { + $this->wrap($child, $prefix, $suffix); + + return; + } + } + } + + /** + * Lowest priority — run after other visitors so the markers stay + * the outermost layer of the compiled body. + * + * @return int the visitor priority + */ + public function getPriority(): int + { + return 0; + } + + /** + * Replace `$node`'s `body` child with a sequence: prefix, original + * body, suffix. + * + * @param Node $node the node whose body to wrap + * @param TextNode $prefix the opening marker + * @param TextNode $suffix the closing marker + */ + private function wrap(Node $node, TextNode $prefix, TextNode $suffix): void + { + $body = $node->getNode('body'); + $wrapped = new Nodes([$prefix, $body, $suffix], $body->getTemplateLine()); + $node->setNode('body', $wrapped); + } +} diff --git a/symfony.lock b/symfony.lock index 562a46b..f765f9e 100644 --- a/symfony.lock +++ b/symfony.lock @@ -95,6 +95,18 @@ "ref": "56f87409c45ff6654b0bf07c5cc359aebf6016ab" } }, + "symfony/property-info": { + "version": "8.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.3", + "ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7" + }, + "files": [ + "config/packages/property_info.yaml" + ] + }, "symfony/routing": { "version": "8.1", "recipe": { @@ -123,6 +135,19 @@ "assets/stimulus_bootstrap.js" ] }, + "symfony/translation": { + "version": "8.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.3", + "ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843" + }, + "files": [ + "config/packages/translation.yaml", + "translations/.gitignore" + ] + }, "symfony/twig-bundle": { "version": "8.1", "recipe": { @@ -136,6 +161,18 @@ "templates/base.html.twig" ] }, + "symfony/ux-twig-component": { + "version": "3.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.13", + "ref": "f367ae2a1faf01c503de2171f1ec22567febeead" + }, + "files": [ + "config/packages/twig_component.yaml" + ] + }, "symfonycasts/tailwind-bundle": { "version": "0.13", "recipe": { diff --git a/templates/base.html.twig b/templates/base.html.twig index c223fd4..ebc91c3 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -1,10 +1,15 @@ - + - {% block title %}ai-lib{% endblock %} + {% block title %}{{ brand_name }}{% endblock %} + + + + + {% block stylesheets %} {% endblock %} @@ -13,7 +18,27 @@ {% block importmap %}{{ importmap('app') }}{% endblock %} {% endblock %} - - {% block body %}{% endblock %} + + + + + + {{ 'nav.links.catalog'|trans }} + {{ 'nav.links.share'|trans }} + {{ 'nav.links.mine'|trans }} + {{ 'nav.links.favorites'|trans }} + {{ 'nav.links.collections'|trans }} + + + +
+ {% block body %}{% endblock %} +
+ + + {{ 'layout.footer.caption_html'|trans({'%link%': '#'})|raw }} + diff --git a/templates/components/Box.html.twig b/templates/components/Box.html.twig new file mode 100644 index 0000000..0265ba8 --- /dev/null +++ b/templates/components/Box.html.twig @@ -0,0 +1,8 @@ +{% props eyebrow, lead = null %} +
+ {{ eyebrow|trans }} + {% if lead %} +

{{ lead|trans }}

+ {% endif %} + {% block content %}{% endblock %} +
diff --git a/templates/components/CardRail/Card.html.twig b/templates/components/CardRail/Card.html.twig new file mode 100644 index 0000000..d282dcb --- /dev/null +++ b/templates/components/CardRail/Card.html.twig @@ -0,0 +1,13 @@ +{% props kommune, model, name, summary, href = '#' %} +{# basis-[260px] grow: preferred width 260, expand to fill row slack. #} +{# max-w-[400px] keeps a lonely card on a wide row from stretching out. #} + + {{ kommune }} · {{ model }} + {# tracking-normal cancels the -0.01em letter-spacing that the #} + {# @layer base rule applies to every heading, so the visual matches #} + {# the previous . #} +

{{ name }}

+ {{ summary }} +
diff --git a/templates/components/CardRail/Container.html.twig b/templates/components/CardRail/Container.html.twig new file mode 100644 index 0000000..55967c9 --- /dev/null +++ b/templates/components/CardRail/Container.html.twig @@ -0,0 +1,15 @@ +{% props eyebrow, linkHref = '#', linkLabel, listLabel = 'frontpage.rail.list_label' %} +
+
+ {{ eyebrow|trans }} + {{ linkLabel|trans }} +
+ {# Cards wrap to as many rows as fit the viewport. Each card sets its #} + {# own basis + grow on the flex item side so the row fills naturally. #} +
+ {% block content %}{% endblock %} +
+
diff --git a/templates/components/Eyebrow.html.twig b/templates/components/Eyebrow.html.twig new file mode 100644 index 0000000..6f53b69 --- /dev/null +++ b/templates/components/Eyebrow.html.twig @@ -0,0 +1,4 @@ +{% props class = '', as = 'h2' %} +{# `as` lets a call site pick the tag — defaults to h2 because most #} +{# section kickers are the section's heading. Hero passes as="p". #} +<{{ as }} class="eyebrow{{ class ? ' ' ~ class : '' }}">{% block content %}{% endblock %} diff --git a/templates/components/Hero.html.twig b/templates/components/Hero.html.twig new file mode 100644 index 0000000..95a5611 --- /dev/null +++ b/templates/components/Hero.html.twig @@ -0,0 +1,11 @@ +{% props eyebrow %} +
+ {{ eyebrow|trans }} +

+ {% block heading %}{% endblock %} +

+

+ {% block lead %}{% endblock %} +

+ {% block content %}{% endblock %} +
diff --git a/templates/components/Layout/Brand.html.twig b/templates/components/Layout/Brand.html.twig new file mode 100644 index 0000000..9271659 --- /dev/null +++ b/templates/components/Layout/Brand.html.twig @@ -0,0 +1,8 @@ +{% props href, initials = brand_initials, name = brand_name, tagline = brand_tagline %} + + + + {{ name }} + {{ tagline|raw }} + + diff --git a/templates/components/Layout/SiteFooter.html.twig b/templates/components/Layout/SiteFooter.html.twig new file mode 100644 index 0000000..0338dfb --- /dev/null +++ b/templates/components/Layout/SiteFooter.html.twig @@ -0,0 +1,7 @@ +{% props linkHref, linkLabel %} + diff --git a/templates/components/Layout/SiteHeader.html.twig b/templates/components/Layout/SiteHeader.html.twig new file mode 100644 index 0000000..2189fc3 --- /dev/null +++ b/templates/components/Layout/SiteHeader.html.twig @@ -0,0 +1,9 @@ +{# Header shell. Default slot receives the nav menu. #} +
+
+ + + {% block content %}{% endblock %} +
+
diff --git a/templates/components/Layout/SkipLink.html.twig b/templates/components/Layout/SkipLink.html.twig new file mode 100644 index 0000000..cbc6f1a --- /dev/null +++ b/templates/components/Layout/SkipLink.html.twig @@ -0,0 +1,4 @@ +{% props href = '#main', label = 'layout.skip_link' %} + + {{ label|trans }} + diff --git a/templates/components/Nav/Link.html.twig b/templates/components/Nav/Link.html.twig new file mode 100644 index 0000000..409fb22 --- /dev/null +++ b/templates/components/Nav/Link.html.twig @@ -0,0 +1,2 @@ +{% props href %} +{% block content %}{% endblock %} diff --git a/templates/components/Nav/Menu.html.twig b/templates/components/Nav/Menu.html.twig new file mode 100644 index 0000000..d5db1ac --- /dev/null +++ b/templates/components/Nav/Menu.html.twig @@ -0,0 +1,7 @@ +{% props id = 'primary-nav', label = 'nav.menu_label' %} + diff --git a/templates/components/Nav/Toggle.html.twig b/templates/components/Nav/Toggle.html.twig new file mode 100644 index 0000000..99fc863 --- /dev/null +++ b/templates/components/Nav/Toggle.html.twig @@ -0,0 +1,11 @@ +{# Hamburger button. Class/ARIA/data-action are referenced by app.css and nav_toggle_controller.js — preserve verbatim. #} + diff --git a/templates/components/SearchBox.html.twig b/templates/components/SearchBox.html.twig new file mode 100644 index 0000000..3487ad4 --- /dev/null +++ b/templates/components/SearchBox.html.twig @@ -0,0 +1,20 @@ +{% props legend = 'search.legend', placeholder, submitLabel = 'search.submit', action = '#', name = 'q', inputId = 'frontpage-search' %} +
+ {{ legend|trans }} + +
diff --git a/templates/components/Stats/Item.html.twig b/templates/components/Stats/Item.html.twig new file mode 100644 index 0000000..159e82d --- /dev/null +++ b/templates/components/Stats/Item.html.twig @@ -0,0 +1,5 @@ +{% props label, value %} +
+
{{ label|trans }}
+
{{ value }}
+
diff --git a/templates/components/Stats/List.html.twig b/templates/components/Stats/List.html.twig new file mode 100644 index 0000000..9cf57df --- /dev/null +++ b/templates/components/Stats/List.html.twig @@ -0,0 +1,3 @@ +
+ {% block content %}{% endblock %} +
diff --git a/templates/components/StepList/Item.html.twig b/templates/components/StepList/Item.html.twig new file mode 100644 index 0000000..9ec34c5 --- /dev/null +++ b/templates/components/StepList/Item.html.twig @@ -0,0 +1,7 @@ +{% props index, lead, body %} +
  • + {{ index }} + + {{ lead|trans }}{{ body|trans }} + +
  • diff --git a/templates/components/StepList/List.html.twig b/templates/components/StepList/List.html.twig new file mode 100644 index 0000000..f1ea3cf --- /dev/null +++ b/templates/components/StepList/List.html.twig @@ -0,0 +1,3 @@ +
      + {% block content %}{% endblock %} +
    diff --git a/templates/components/TagList/List.html.twig b/templates/components/TagList/List.html.twig new file mode 100644 index 0000000..e67c80f --- /dev/null +++ b/templates/components/TagList/List.html.twig @@ -0,0 +1,4 @@ +{% props label %} +
    + {% block content %}{% endblock %} +
    diff --git a/templates/components/TagList/Tag.html.twig b/templates/components/TagList/Tag.html.twig new file mode 100644 index 0000000..e2f96a8 --- /dev/null +++ b/templates/components/TagList/Tag.html.twig @@ -0,0 +1,7 @@ +{% props label, badge = 'tag.badge_default', title = 'tag.title_default' %} + + {{ label|trans }} + {{ badge|trans }} + diff --git a/templates/frontpage/index.html.twig b/templates/frontpage/index.html.twig new file mode 100644 index 0000000..e67515d --- /dev/null +++ b/templates/frontpage/index.html.twig @@ -0,0 +1,49 @@ +{% extends 'base.html.twig' %} + +{% block title %}{{ 'frontpage.title'|trans({'%brand%': brand_name}) }}{% endblock %} + +{% block body %} + {# grid-cols-1 gives the implicit column min 0 (minmax(0, 1fr)), so #} + {# children like the CardRail can let their overflow-x-auto kick in #} + {# instead of expanding the grid to their min-content width. #} +
    + + + {{ 'frontpage.hero.heading_html'|trans|raw }} + {{ 'frontpage.hero.lead'|trans }} + + + + + + + + + + + + {% for a in assistants %} + + {% endfor %} + + + + {% set steps = [ + {lead: 'frontpage.steps.1.lead', body: 'frontpage.steps.1.body'}, + {lead: 'frontpage.steps.2.lead', body: 'frontpage.steps.2.body'}, + {lead: 'frontpage.steps.3.lead', body: 'frontpage.steps.3.body'}, + {lead: 'frontpage.steps.4.lead', body: 'frontpage.steps.4.body'}, + ] %} + + {% for step in steps %} + + {% endfor %} + + + +
    +{% endblock %} diff --git a/tests/Controller/FrontpageControllerTest.php b/tests/Controller/FrontpageControllerTest.php new file mode 100644 index 0000000..25d57b5 --- /dev/null +++ b/tests/Controller/FrontpageControllerTest.php @@ -0,0 +1,31 @@ +request('GET', '/'); + + self::assertResponseIsSuccessful(); + self::assertSelectorTextContains('body', 'AI Bibliotek'); + self::assertSelectorTextContains('h1', 'kommunale'); + } +} diff --git a/tests/KernelTest.php b/tests/KernelTest.php new file mode 100644 index 0000000..c7e1a2e --- /dev/null +++ b/tests/KernelTest.php @@ -0,0 +1,27 @@ +invoke($kernel)); + } +} diff --git a/tests/Twig/DevTemplateMarkerExtensionTest.php b/tests/Twig/DevTemplateMarkerExtensionTest.php new file mode 100644 index 0000000..8005785 --- /dev/null +++ b/tests/Twig/DevTemplateMarkerExtensionTest.php @@ -0,0 +1,36 @@ +getNodeVisitors(); + + self::assertCount(1, $visitors); + self::assertInstanceOf(DevTemplateMarkerNodeVisitor::class, $visitors[0]); + } + + public function testRegistersNoVisitorInProdEnvironment(): void + { + self::assertSame( + [], + (new DevTemplateMarkerExtension('prod'))->getNodeVisitors(), + ); + } + + public function testRegistersNoVisitorInTestEnvironment(): void + { + self::assertSame( + [], + (new DevTemplateMarkerExtension('test'))->getNodeVisitors(), + ); + } +} diff --git a/tests/Twig/DevTemplateMarkerNodeVisitorTest.php b/tests/Twig/DevTemplateMarkerNodeVisitorTest.php new file mode 100644 index 0000000..3ffa965 --- /dev/null +++ b/tests/Twig/DevTemplateMarkerNodeVisitorTest.php @@ -0,0 +1,93 @@ +render(['hello.html.twig' => '

    hi

    ']); + + self::assertSame( + '

    hi

    ', + $output, + ); + } + + public function testWrapsBodyBlockOfExtendingTemplate(): void + { + $output = $this->render([ + 'base.html.twig' => '[{% block body %}{% endblock %}]', + 'child.html.twig' => '{% extends "base.html.twig" %}{% block body %}hi{% endblock %}', + ], 'child.html.twig'); + + // base.html.twig is itself a non-extending template and also gets + // its body wrapped. child.html.twig's markers live inside the + // `body` block, between base's markers. + self::assertSame( + '[hi]', + $output, + ); + } + + public function testExtendingTemplateWithoutBodyBlockIsLeftAlone(): void + { + $output = $this->render([ + 'base.html.twig' => '[{% block other %}fallback{% endblock %}]', + 'child.html.twig' => '{% extends "base.html.twig" %}{% block other %}hi{% endblock %}', + ], 'child.html.twig'); + + // No `body` block in the chain, so child.html.twig contributes no + // markers. Base still gets its own. + self::assertSame('[hi]', $output); + } + + public function testNamespacedTemplateIsSkipped(): void + { + $output = $this->render(['@vendor/widget.html.twig' => '

    vendor

    ']); + + self::assertSame('

    vendor

    ', $output); + } + + public function testEnterNodeIsAPassThrough(): void + { + $visitor = new DevTemplateMarkerNodeVisitor(); + $env = new Environment(new ArrayLoader([])); + $node = new EmptyNode(); + + self::assertSame($node, $visitor->enterNode($node, $env)); + } + + public function testLeaveNodeIgnoresNonModuleNodes(): void + { + $visitor = new DevTemplateMarkerNodeVisitor(); + $env = new Environment(new ArrayLoader([])); + $node = new EmptyNode(); + + self::assertSame($node, $visitor->leaveNode($node, $env)); + } + + public function testPriorityIsZero(): void + { + self::assertSame(0, (new DevTemplateMarkerNodeVisitor())->getPriority()); + } + + /** + * @param array $templates + */ + private function render(array $templates, ?string $name = null): string + { + $env = new Environment(new ArrayLoader($templates), ['cache' => false]); + $env->addNodeVisitor(new DevTemplateMarkerNodeVisitor()); + + return $env->render($name ?? array_key_first($templates)); + } +} diff --git a/translations/.gitignore b/translations/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml new file mode 100644 index 0000000..d7804b2 --- /dev/null +++ b/translations/messages.da.yaml @@ -0,0 +1,63 @@ +# Danish UI strings. Hierarchical dot-notation keys. +# +# - Brand identity (name, tagline, initials) is NOT here — see BRAND_* +# env vars in .env and the Twig globals in config/packages/twig.yaml. +# - Keys ending in `_html` carry HTML and must be rendered with |raw at +# the call site. +# - Components apply |trans on text props themselves; call sites pass +# keys, not literal strings. + +layout: + skip_link: "Spring til indhold" + brand_aria_label: "Til forsiden" + footer: + caption_html: 'Forhåndsvisning · ingen data endnu · designet følger prototype-mockuppet' + about_link: "Om projektet" + +nav: + menu_label: "Hovedmenu" + toggle_label: "Vis menu" + links: + catalog: "Katalog" + share: "Del assistent" + mine: "Mine assistenter" + favorites: "Favoritter" + collections: "Samlinger" + +search: + legend: "Find en assistent" + submit: "Søg" + placeholder: "Søg efter borgerservice, referat, journalisering…" + +tag: + badge_default: "snart" + title_default: "Kommer snart" + +frontpage: + title: "%brand% – forhåndsvisning" + hero: + eyebrow: "Del & hjemtag · dansk offentlig AI" + heading_html: 'Et fælles bibliotek over kommunale AI-assistenter.' + lead: "Find, del og hjemtag AI-assistenter bygget af danske myndigheder. Når én kommune løser en opgave, kan resten hjemtage assistenten, eksportere konfigurationen og køre den lokalt — så gode løsninger skalerer nationalt." + stats: + assistants: "Assistenter" + kommuner: "Kommuner" + models: "Sprogmodeller" + rail: + eyebrow: "Forhåndsvisning" + link: "Kataloget kommer →" + list_label: "Eksempler på assistenter" + steps: + eyebrow: "Sådan virker det" + "1": + lead: "Opret bruger" + body: " og log ind som repræsentant for din myndighed." + "2": + lead: "Find en assistent" + body: " – søg og filtrér på kommune, sprogmodel og datafølsomhed." + "3": + lead: "Hjemtag" + body: " – eksportér assistentens JSON og følg vidensopskriften, der beskriver hvilke data du selv skal levere." + "4": + lead: "Tilpas lokalt" + body: " – importér i din egen OpenWebUI, tilføj kommunens viden og tag den i brug."