diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7e57281 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,90 @@ +# AGENTS.md + +Operational notes for AI coding agents (Claude Code, Codex, Cursor, …) working on this repo. Treat as authoritative — overrides default assumptions where they conflict. + +Tool-specific entrypoint files (`CLAUDE.md`, `.cursorrules`, etc.) just point here so the source of truth stays in one place. + +## Maintaining this file + +Go-style brevity. Bullets, not paragraphs. Add only what saves the next session real time: + +- **Add** a note when you hit a non-obvious gotcha or pin a convention the codebase relies on. +- **Don't add** restatement of README content, narration of what the codebase does, or one-off task context. README owns "what the project does"; AGENTS.md owns "how to work on it". +- **Cap ~150 lines.** Past that, the whole file gets skimmed instead of read. + +## Project shape + +A Twig 3 extension (`Parisek\Twig\AttributeExtension`) that exposes a `create_attribute()` Twig function, backed by a vendored port of Drupal 11.x's `Attribute` class under `Drupal\Component\Attribute\`. + +- `src/` — vendored Drupal sources (`AttributeCollection`, `AttributeValueBase`, `AttributeArray`, `AttributeBoolean`, `AttributeString`, `MarkupInterface`). +- `src/Internal/` — minimal shims that let the package drop `drupal/core-render` + `drupal/core-utility`: `Escape::html()`, `NestedArray::mergeDeep[Array]()`, `PlainTextOutput::renderFromHtml()`. +- `AttributeExtension.php` — root-level, `final`, the Twig extension entrypoint. Tiny wrapper. +- `tests/` — PHPUnit 11. `AttributeTest.php` is the upstream Drupal test ported (alias `AttributeCollection as Attribute`); `EscapeTest.php` byte-matches against `htmlspecialchars`; `SmokeTest.php` exercises the Twig integration end-to-end. +- `.upstream/` — gitignored scratch dir for the next refresh; fetch from `git.drupalcode.org/project/drupal/-/raw/11.x/core/lib/Drupal/Core/Template/`. + +PHP ^8.3. Twig ^3.0. No Drupal dependencies, no Symfony dependencies beyond what Twig itself pulls. + +## Commands + +```bash +composer install +vendor/bin/phpunit # 41 tests / 110 assertions +vendor/bin/phpstan analyse # level 5, clean +composer validate --strict +``` + +`composer.json` carries no `scripts` block — run the binaries directly. + +## CI + +`.github/workflows/ci.yml` runs `phpunit` + `phpstan` on PHP 8.3 + 8.4 matrix. `.github/workflows/dependency-review.yml` runs on PRs. + +## Refreshing from Drupal 11.x upstream + +The five source files in `src/` are vendored from Drupal core. When upstream changes meaningfully: + +1. Fetch all 6 files (5 sources + the upstream test) into `.upstream/` from `git.drupalcode.org/project/drupal/-/raw/11.x/core/lib/Drupal/Core/Template/` and `core/tests/Drupal/Tests/Core/Template/`. +2. Audit `grep -hE "^use Drupal\\\\(Component|Core)\\\\" .upstream/*.php | sort -u`. Outside symbols must terminate in PHP builtins via inline shim, **not** pull `drupal/core-*` back in. +3. Port file by file: rewrite namespace `Drupal\Core\Template` → `Drupal\Component\Attribute`, rename `class Attribute` → `class AttributeCollection` (BC), swap `Html::escape` → `Escape::html`, drop `#[JsonSchema(...)]` PHP attribute, update `@see` docblocks. +4. The test fixture (`tests/AttributeTest.php`) carries its own local `MarkupInterface` + `Markup` — **do not import from `drupal/core-render`** when refreshing the test; the file is intentionally decoupled. + +## Inline shim discipline + +The package's whole reason to drop `drupal/core-*` is to terminate every dep chain at PHP builtins. + +- `Internal\Escape::html` — `htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')`. Byte-identical to Drupal's `Html::escape`. +- `Internal\PlainTextOutput::renderFromHtml(\Stringable|string)` — `html_entity_decode(strip_tags((string) $string), ENT_QUOTES, 'UTF-8')`. Drop `implements OutputStrategyInterface` — we don't carry the interface. +- `Internal\NestedArray` — only `mergeDeep` + `mergeDeepArray`. Don't add `getValue`/`setValue`/`unsetValue`/`keyExists`/`filter` from upstream; nothing in the attribute classes uses them. +- `Drupal\Component\Attribute\MarkupInterface` — at `src/MarkupInterface.php` (public namespace, not `Internal\`), empty `interface … extends \JsonSerializable, \Stringable`. `AttributeCollection` `implements` it. + +If a refresh would require a fifth shim or a shim exceeding ~30 LOC, stop and reconsider — the prune-both-drupal-deps strategy assumes shims stay minimal. + +## PHPStan level + +Level 5, not 6. Level 6 surfaces 17 `missingType.iterableValue` / `missingType.parameter` / `missingType.return` errors against the ported Drupal sources (untyped `array` parameters / no inner generics). Upgrade path: + +1. Annotate `src/*.php` with `array` / `array` generics as appropriate. +2. Remove `treatPhpDocTypesAsCertain: false` from `phpstan.neon` (added to silence a spurious `instanceof.alwaysTrue` in `__toString()`). +3. Bump `phpstan.neon` to `level: 6`. + +## Per-PR conventions + +- **CHANGELOG.md**: every behavior-affecting PR adds an entry under `## [Unreleased]` with [Keep a Changelog](https://keepachangelog.com/) categories. +- **Squash-merge PRs** into `master` so the merge commit subject ends with `(#N)`. The existing tag history (`v1.0.0`–`v1.6.0`) is built on this convention. + +## Release process + +Currently manual: + +1. Stamp the `[Unreleased]` heading in `CHANGELOG.md` to `[X.Y.Z] - YYYY-MM-DD`. +2. `git tag -a vX.Y.Z -m "..."` + `git push origin vX.Y.Z`. +3. Packagist auto-imports (~60s; webhook wired). +4. Create the GitHub Release (`gh release create vX.Y.Z --notes-file <(awk …)`) — use `--latest=false` for back-dated patches so they don't steal the Latest badge. + +No release-automation workflow yet. If one lands, mirror `parisek/timber-kit`'s `release-stamp.yml` + `release.yml` shape. + +## Style + +- Vendored sources in `src/` keep Drupal core's indent (2-space) and brace style. Don't reformat — refresh diffs stay readable. +- Our own code (`src/Internal/`, `src/MarkupInterface.php`, `AttributeExtension.php`, `tests/`) is PSR-12, 4-space indent, `final` by default, `declare(strict_types=1);` at top. +- WHY-not-WHAT comments. Don't reference task numbers / PRs / call sites in code comments — those rot. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9a36486 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# CLAUDE.md + +See [AGENTS.md](./AGENTS.md) — tool-agnostic operational notes for any AI coding agent working on this repo. Source of truth lives there; this file exists so Claude Code's default discovery still picks it up. diff --git a/docs/refresh-decisions.md b/docs/refresh-decisions.md deleted file mode 100644 index 7654be0..0000000 --- a/docs/refresh-decisions.md +++ /dev/null @@ -1,106 +0,0 @@ -# Refresh decisions — 1.6.0 - -Captured from upstream Drupal 11.x sources fetched on 2026-05-25 (HEAD of `11.x`). - -## Outside symbols reached by upstream - -| Symbol | Used in | Call sites | Strategy | -|---|---|---|---| -| `Drupal\Component\Utility\Html::escape()` | `AttributeValueBase::render()`, `AttributeString::__toString()`, `AttributeArray::__toString()`, `AttributeBoolean::__toString()` | 4 | Already handled: replace with `Parisek\Twig\Internal\Escape::html()` (Task 2). | -| `Drupal\Component\Render\MarkupInterface` | `Attribute` (implements), `Attribute::createAttributeValue()` (type check guard in the fork — upstream switched to `\Stringable`) | Interface only; class declares `implements MarkupInterface` | Define `Drupal\Component\Attribute\MarkupInterface` as a minimal interface extending `\JsonSerializable, \Stringable` with `__toString(): string`. ~10 LOC. | -| `Drupal\Component\Render\PlainTextOutput::renderFromHtml()` | `Attribute::createAttributeValue()` | 1 | Inline as `Parisek\Twig\Internal\PlainTextOutput::renderFromHtml()`. Body is `html_entity_decode(strip_tags((string) $string), ENT_QUOTES, 'UTF-8')` — zero further Drupal deps. ~10 LOC. | -| `Drupal\Component\Utility\NestedArray::mergeDeep()` | `Attribute::merge()` | 1 | Inline as `Parisek\Twig\Internal\NestedArray` carrying only `mergeDeep()` + `mergeDeepArray()`. Pure PHP, no outside deps. ~30 LOC. | -| `Drupal\Core\Serialization\Attribute\JsonSchema` | `Attribute::__toString()` (PHP attribute `#[JsonSchema(...)]`) | 1 (decorative) | Drop the `#[JsonSchema(...)]` annotation from the ported class. It carries no runtime effect; it exists only for Drupal's JSON Schema discovery tooling. No shim needed. | - -## Key upstream diff vs. fork (AttributeCollection.php) - -The upstream `Attribute.php` changed the `createAttributeValue()` guard in one notable way: - -- **Fork** (`AttributeCollection.php` line 142): `elseif ($value instanceof MarkupInterface)` -- **Upstream** (`Attribute.php` line 152): `elseif ($value instanceof \Stringable)` - -This is a deliberate upstream broadening — any `Stringable` object gets its HTML stripped via `PlainTextOutput::renderFromHtml()`, not just `MarkupInterface` objects. The ported class will follow the upstream behaviour (`\Stringable`), which means `MarkupInterface` still works transitively (it extends `\Stringable`), but no import of `MarkupInterface` is required inside `createAttributeValue()`. The class itself still `implements MarkupInterface` for API compatibility. - -The upstream `offsetGet()` also adds a lazy-init guard for the `class` key (returns an empty `AttributeArray` instead of `NULL`). The port will adopt this too. - -## Dep chain termination - -All inline shims terminate at plain PHP builtins: - -- `PlainTextOutput::renderFromHtml` → `strip_tags()` + `html_entity_decode()` — PHP core only. -- `NestedArray::mergeDeep/mergeDeepArray` — pure PHP array operations, no further imports. -- `MarkupInterface` — extends `\Stringable` + `\JsonSerializable` — PHP core only. -- `Escape::html()` (already done, Task 2) → `htmlspecialchars()` — PHP core only. - -No chain exceeds one level. Escalation threshold not reached. - -## Per-symbol inline plan - -### `Parisek\Twig\Internal\PlainTextOutput` - -File: `src/Internal/PlainTextOutput.php` - -Methods needed: `renderFromHtml(string|object $string): string` - -Body (from `Html::decodeEntities(strip_tags(...))`, expanding `decodeEntities` inline): -```php -return html_entity_decode(strip_tags((string) $string), ENT_QUOTES, 'UTF-8'); -``` - -Estimated LOC: ~10 (class shell + docblock + method). No further deps. - -### `Drupal\Component\Attribute\MarkupInterface` - -File: `src/MarkupInterface.php` (public namespace, not `Internal\` — it forms part of the package's public API because consumers may pass objects implementing it) - -Interface: extends `\JsonSerializable, \Stringable`, declares `public function __toString(): string`. - -Estimated LOC: ~10. No further deps. - -### `Parisek\Twig\Internal\NestedArray` - -File: `src/Internal/NestedArray.php` - -Methods needed: `mergeDeep(...$arrays): array` and `mergeDeepArray(array $arrays, bool $preserve_integer_keys = false): array`. - -The remaining methods in upstream `NestedArray` (`getValue`, `setValue`, `unsetValue`, `keyExists`, `filter`) are **not** referenced by the attribute classes and must not be included — keep the shim minimal. - -Estimated LOC: ~30 (two method bodies copied verbatim from upstream). No further deps. - -### `Drupal\Core\Serialization\Attribute\JsonSchema` — DROP - -The `#[JsonSchema(...)]` PHP attribute on `__toString()` is Drupal-internal tooling for JSON Schema discovery. It has no runtime effect. Dropping it from the ported class does not change behaviour. No shim. No import. - -## LOC estimate summary - -| Shim | Est. LOC | -|---|---| -| `Internal\PlainTextOutput` | ~10 | -| `MarkupInterface` (interface) | ~10 | -| `Internal\NestedArray` (mergeDeep only) | ~30 | -| `Internal\Escape` (already done) | 5 (done) | -| **Total new** | **~50** | - -Well within the 120 LOC escalation threshold. No escalation needed. - -## Adopted divergences - -### PHPStan level 5 (not 6) - -Level 6 was attempted first and produced **17 errors**, all `missingType.iterableValue` / `missingType.parameter` / `missingType.return` on ported Drupal source files (`src/AttributeCollection.php`, `src/AttributeArray.php`, `src/AttributeValueBase.php`, `src/Internal/NestedArray.php`, `AttributeExtension.php`). These are missing generic-typed array annotations and untyped constructor parameters inherited verbatim from upstream Drupal 11.x. - -Fixing them in Task 10 would touch `src/` files (out of scope for the PHPStan task). The parent `parisek/styleguide` repo also runs PHPStan at level 6 via `phpstan/phpstan ^2.0`, but that codebase uses PHPStan extensions and is fully typed. Since dialing down was explicitly allowed by the task plan when the count exceeds ~5, **level 5** was chosen. - -Level 5 produced **0 errors** after two targeted fixes to `src/AttributeCollection.php` (the `@implements ArrayAccess` generic annotation and `offsetSet` parameter type), which were logic-correctness fixes unrelated to the type-annotation gap: - -- `@implements \ArrayAccess` (was `AttributeValueBase`) — `offsetSet` intentionally accepts raw input that is then converted by `createAttributeValue()`. -- `offsetSet($name, mixed $value)` explicit type hint — mirrors the above. -- `treatPhpDocTypesAsCertain: false` in `phpstan.neon` — silences a spurious `instanceof.alwaysTrue` where PHPDoc type narrowing causes PHPStan to see an `instanceof` check that is always true. - -Upgrading to level 6 later: annotate the ported `src/` files with `array` / `array` generics as appropriate, remove `treatPhpDocTypesAsCertain: false`, and bump `phpstan.neon` to `level: 6`. - -## Other notes - -- The upstream `Attribute.php` has `#[\ReturnTypeWillChange]` attributes removed — return types are now explicit throughout. The port will adopt the explicit return types. -- `@internal` markers: none present on the upstream `Attribute*` classes themselves. The `Internal\*` shim classes will be marked `@internal` per package convention. -- The upstream test file (`AttributeTest.php`) imports `Drupal\Core\Render\Markup` and `Drupal\Tests\UnitTestCase` which are Drupal-specific. Task 4 will audit that test and strip/replace those deps before adopting it as the package test baseline.