Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
/.twig-cs-fixer.cache
###< vincentlanglet/twig-cs-fixer ###

###> symfony/asset-mapper ###
/public/assets/
/assets/vendor/
###< symfony/asset-mapper ###

###> phpunit/phpunit ###
/phpunit.xml
/.phpunit.cache/
Expand Down
2 changes: 2 additions & 0 deletions .markdownlintignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ web/*.md
web/core/
web/libraries/
web/*/contrib/
# Symfony (cache + scratch dir, gitignored)
var/
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Dev dependencies for coding standards and composer normalization:
`ergebnis/composer-normalize`, `friendsofphp/php-cs-fixer`, `vincentlanglet/twig-cs-fixer`.
- Project README with local development instructions.
- Frontend tooling: Tailwind CSS (via `symfonycasts/tailwind-bundle`),
Symfony AssetMapper, and Stimulus (via `symfony/stimulus-bundle`).
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`).
- `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.
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,39 @@ task test
task test-coverage
```

## Frontend assets

The project uses [Tailwind CSS](https://tailwindcss.com/) on top of
Symfony's [AssetMapper](https://symfony.com/doc/current/frontend/asset_mapper.html),
with [Stimulus](https://stimulus.hotwired.dev/) for behaviour. There is
no Node toolchain — the Tailwind binary is managed by
[`symfonycasts/tailwind-bundle`](https://github.com/SymfonyCasts/tailwind-bundle).
See [ADR 002](docs/adr/002-frontend-tooling.md) for the rationale.

```sh
# One-time: download the Tailwind binary (also runs lazily on first build)
itkdev-docker-compose php bin/console tailwind:build

# Build the compiled stylesheet
itkdev-docker-compose php bin/console tailwind:build

# Watch source files and rebuild on change (development)
itkdev-docker-compose php bin/console tailwind:build --watch

# Compile and version the full importmap + assets (production)
itkdev-docker-compose php bin/console asset-map:compile

# Inspect what AssetMapper sees
itkdev-docker-compose php bin/console debug:asset-map
```

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"`).

For one-off commands without a dedicated task, fall back to the underlying
tools, e.g. `docker compose --profile dev run --rm prettier <args>` or
`itkdev-docker-compose <args>`.
Expand Down
28 changes: 28 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ tasks:
- task: coding-standards-php-check
- task: coding-standards-twig-check
- task: coding-standards-yaml-check
- task: coding-standards-js-check
- task: coding-standards-css-check
- task: coding-standards-markdown-check
- task: coding-standards-composer-check
silent: true
Expand All @@ -122,6 +124,8 @@ tasks:
- task: coding-standards-php-apply
- task: coding-standards-twig-apply
- task: coding-standards-yaml-apply
- task: coding-standards-js-apply
- task: coding-standards-css-apply
- task: coding-standards-markdown-apply
- task: coding-standards-composer-apply
silent: true
Expand Down Expand Up @@ -162,6 +166,30 @@ tasks:
- task compose -- --profile dev run --rm prettier '**/*.{yml,yaml}' --write
silent: true

coding-standards-js-check:
desc: 'Check JavaScript formatting (Prettier). Mirrors the JavaScript CI workflow.'
cmds:
- task compose -- --profile dev run --rm prettier 'assets/**/*.js' --check
silent: true

coding-standards-js-apply:
desc: 'Apply JavaScript formatting (Prettier).'
cmds:
- task compose -- --profile dev run --rm prettier 'assets/**/*.js' --write
silent: true

coding-standards-css-check:
desc: 'Check CSS/SCSS formatting (Prettier). Mirrors the Styles CI workflow.'
cmds:
- task compose -- --profile dev run --rm prettier 'assets/**/*.{css,scss}' --check
silent: true

coding-standards-css-apply:
desc: 'Apply CSS/SCSS formatting (Prettier).'
cmds:
- task compose -- --profile dev run --rm prettier 'assets/**/*.{css,scss}' --write
silent: true

coding-standards-markdown-check:
desc: 'Check Markdown (markdownlint).'
cmds:
Expand Down
1 change: 0 additions & 1 deletion assets/app.css

This file was deleted.

3 changes: 2 additions & 1 deletion assets/app.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
// Placeholder JavaScript entry. Replace when a frontend stack is adopted.
import "./stimulus_bootstrap.js";
import "./stimulus_bootstrap.js";
4 changes: 4 additions & 0 deletions assets/controllers.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"controllers": [],
"entrypoints": []
}
139 changes: 139 additions & 0 deletions assets/controllers/csrf_protection_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* Stateless CSRF protection — client side.
*
* Installed by the `symfony/stimulus-bundle` Flex recipe (see
* https://github.com/symfony/recipes). Pairs with Symfony's
* `SameOriginCsrfTokenManager` server side: on form submit, this script
* writes a random token into a `__Host-` cookie and the form's hidden
* `_csrf_token` field, then the server checks that the two match
* (double-submit cookie pattern). Also forwards the token as a header
* for Turbo-driven submissions.
*
* Edits to this file are preserved across `composer install`; Flex only
* re-applies the recipe on `composer recipes:update` / `--force`, and
* will prompt before overwriting local changes.
*/

const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;

// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event
// and thus this event-listener will not be executed.
document.addEventListener(
"submit",
function (event) {
generateCsrfToken(event.target);
},
true,
);

// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
document.addEventListener("turbo:submit-start", function (event) {
const h = generateCsrfHeaders(event.detail.formSubmission.formElement);
Object.keys(h).map(function (k) {
event.detail.formSubmission.fetchRequest.headers[k] = h[k];
});
});

// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
document.addEventListener("turbo:submit-end", function (event) {
removeCsrfToken(event.detail.formSubmission.formElement);
});

export function generateCsrfToken(formElement) {
const csrfField = formElement.querySelector(
'input[data-controller="csrf-protection"], input[name="_csrf_token"]',
);

if (!csrfField) {
return;
}

let csrfCookie = csrfField.getAttribute(
"data-csrf-protection-cookie-value",
);
let csrfToken = csrfField.value;

if (!csrfCookie && nameCheck.test(csrfToken)) {
csrfField.setAttribute(
"data-csrf-protection-cookie-value",
(csrfCookie = csrfToken),
);
csrfField.defaultValue = csrfToken = btoa(
String.fromCharCode.apply(
null,
(window.crypto || window.msCrypto).getRandomValues(
new Uint8Array(18),
),
),
);
}
csrfField.dispatchEvent(new Event("change", { bubbles: true }));

if (csrfCookie && tokenCheck.test(csrfToken)) {
const cookie =
csrfCookie +
"_" +
csrfToken +
"=" +
csrfCookie +
"; path=/; samesite=strict";
document.cookie =
window.location.protocol === "https:"
? "__Host-" + cookie + "; secure"
: cookie;
}
}

export function generateCsrfHeaders(formElement) {
const headers = {};
const csrfField = formElement.querySelector(
'input[data-controller="csrf-protection"], input[name="_csrf_token"]',
);

if (!csrfField) {
return headers;
}

const csrfCookie = csrfField.getAttribute(
"data-csrf-protection-cookie-value",
);

if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
headers[csrfCookie] = csrfField.value;
}

return headers;
}

export function removeCsrfToken(formElement) {
const csrfField = formElement.querySelector(
'input[data-controller="csrf-protection"], input[name="_csrf_token"]',
);

if (!csrfField) {
return;
}

const csrfCookie = csrfField.getAttribute(
"data-csrf-protection-cookie-value",
);

if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
const cookie =
csrfCookie +
"_" +
csrfField.value +
"=0; path=/; samesite=strict; max-age=0";

document.cookie =
window.location.protocol === "https:"
? "__Host-" + cookie + "; secure"
: cookie;
}
}

/* stimulusFetch: 'lazy' */
export default "csrf-protection-controller";
17 changes: 17 additions & 0 deletions assets/controllers/hello_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
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";
}
}
8 changes: 8 additions & 0 deletions assets/stimulus_bootstrap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { startStimulusApp } from "@symfony/stimulus-bundle";

const app = startStimulusApp();
import { startStimulusApp } from "@symfony/stimulus-bundle";

const app = startStimulusApp();
// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);
1 change: 1 addition & 0 deletions assets/styles/app.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import "tailwindcss";
14 changes: 11 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,23 @@
"php": ">=8.4",
"ext-ctype": "*",
"ext-iconv": "*",
"symfony/asset": "^8.1",
"symfony/asset-mapper": "^8.1",
"symfony/console": "~8.1.0",
"symfony/dotenv": "~8.1.0",
"symfony/flex": "^2",
"symfony/framework-bundle": "~8.1.0",
"symfony/runtime": "~8.1.0",
"symfony/yaml": "~8.1.0"
"symfony/stimulus-bundle": "^3.1",
"symfony/twig-bundle": "~8.1.0",
"symfony/yaml": "~8.1.0",
"symfonycasts/tailwind-bundle": "^0.13.0",
"twig/extra-bundle": "^2.12 || ^3.0",
"twig/twig": "^2.12 || ^3.0"
},
"require-dev": {
"ergebnis/composer-normalize": "^2.52",
"friendsofphp/php-cs-fixer": "^3.95",
"friendsofphp/php-cs-fixer": "^3.95.5",
"phpunit/phpunit": "^11.5",
"rregeer/phpunit-coverage-check": "^0.3.1",
"symfony/browser-kit": "~8.1.0",
Expand Down Expand Up @@ -76,7 +83,8 @@
],
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
"assets:install %PUBLIC_DIR%": "symfony-cmd",
"importmap:install": "symfony-cmd"
}
}
}
Loading