From bb8f676d0202d7ad9462d0a5a67c2e4bdfd688ad Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 13 Feb 2026 20:26:07 +0300 Subject: [PATCH 1/4] =?UTF-8?q?[master]=20initial=20release=20=E2=80=94=20?= =?UTF-8?q?meta-bundle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/php.yml | 43 ++++ .github/workflows/tag.yml | 41 ++++ .gitignore | 4 + AGENTS.md | 31 +++ CLAUDE.md | 47 ++++ ChamberOrchestraMetaBundle.php | 11 + Cms/Form/Dto/MetaDto.php | 20 ++ Cms/Form/Dto/MetaTranslatableDto.php | 20 ++ Cms/Form/Type/MetaTranslatableType.php | 30 +++ Cms/Form/Type/MetaType.php | 79 +++++++ DependencyInjection/DevMetaExtension.php | 19 ++ Entity/Helper/RobotsBehaviour.php | 41 ++++ Entity/MetaInterface.php | 18 ++ Entity/MetaTrait.php | 83 ++++++++ Exception/ExceptionInterface.php | 9 + Exception/LogicException.php | 9 + Exception/OutOfBoundsException.php | 9 + LICENSE | 201 ++++++++++++++++++ Resources/config/services.yaml | 5 + Resources/translations/cms.en.yml | 18 ++ Resources/translations/cms.ru.yml | 18 ++ bin/phpunit | 4 + composer.json | 52 +++++ phpunit.xml.dist | 29 +++ .../Entity/Helper/RobotsBehaviourTest.php | 81 +++++++ tests/Unit/Entity/MetaTraitTest.php | 151 +++++++++++++ 26 files changed, 1073 insertions(+) create mode 100644 .github/workflows/php.yml create mode 100644 .github/workflows/tag.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 ChamberOrchestraMetaBundle.php create mode 100644 Cms/Form/Dto/MetaDto.php create mode 100644 Cms/Form/Dto/MetaTranslatableDto.php create mode 100644 Cms/Form/Type/MetaTranslatableType.php create mode 100644 Cms/Form/Type/MetaType.php create mode 100644 DependencyInjection/DevMetaExtension.php create mode 100644 Entity/Helper/RobotsBehaviour.php create mode 100644 Entity/MetaInterface.php create mode 100644 Entity/MetaTrait.php create mode 100644 Exception/ExceptionInterface.php create mode 100644 Exception/LogicException.php create mode 100644 Exception/OutOfBoundsException.php create mode 100644 LICENSE create mode 100644 Resources/config/services.yaml create mode 100644 Resources/translations/cms.en.yml create mode 100644 Resources/translations/cms.ru.yml create mode 100755 bin/phpunit create mode 100644 composer.json create mode 100644 phpunit.xml.dist create mode 100644 tests/Unit/Entity/Helper/RobotsBehaviourTest.php create mode 100644 tests/Unit/Entity/MetaTraitTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..44106e5 --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,43 @@ +name: PHP Composer + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP 8.5 + uses: shivammathur/setup-php@v2 + with: + php-version: "8.5" + tools: composer:v2 + coverage: none + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: | + vendor + ~/.composer/cache/files + key: ${{ runner.os }}-php-8.5-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-8.5-composer- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run test suite + run: composer run-script test diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 0000000..8feaddc --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,41 @@ +name: Tag Release + +on: + pull_request: + types: [closed] + branches: [master] + +permissions: + contents: write + +jobs: + tag: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + ref: master + fetch-depth: 0 + fetch-tags: true + + - name: Get latest tag and compute next patch version + id: version + run: | + latest=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) + if [ -z "$latest" ]; then + echo "next=v0.0.1" >> "$GITHUB_OUTPUT" + else + major=$(echo "$latest" | cut -d. -f1) + minor=$(echo "$latest" | cut -d. -f2) + patch=$(echo "$latest" | cut -d. -f3) + next_patch=$((patch + 1)) + echo "next=${major}.${minor}.${next_patch}" >> "$GITHUB_OUTPUT" + fi + echo "Latest tag: ${latest:-none}, next: $(cat "$GITHUB_OUTPUT" | grep next | cut -d= -f2)" + + - name: Create and push tag + run: | + git tag "${{ steps.version.outputs.next }}" + git push origin "${{ steps.version.outputs.next }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..492bc82 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/vendor/ +composer.lock +.phpunit.cache +.claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..99f443f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- Entity layer (`Entity/`) provides `MetaInterface`, `MetaTrait`, and `RobotsBehaviour` enum under `ChamberOrchestra\MetaBundle`. +- CMS form layer (`Cms/Form/`) provides DTOs and Symfony form types — requires external `dev/*` packages. +- Bundle entry point is `DevMetaBundle.php`; DI extension in `DependencyInjection/`. +- Tests belong in `tests/` (autoloaded as `Tests\`); tools are in `bin/` (`bin/phpunit`). +- Autoloading is PSR-4 from the package root (no `src/` directory). +- Requirements: PHP 8.5+, Doctrine ORM ^3.0, Symfony 8.0. + +## Build, Test, and Development Commands +- Install dependencies: `composer install`. +- Run the suite: `./bin/phpunit` (uses `phpunit.xml.dist`). Add `--filter ClassNameTest` or `--filter testMethodName` to scope. +- `composer test` is an alias for `vendor/bin/phpunit`. +- Quick lint: `php -l path/to/File.php`; keep code PSR-12 even though no fixer is bundled. + +## Coding Style & Naming Conventions +- Follow PSR-12: 4-space indent, one class per file, strict types (`declare(strict_types=1);`). +- Use typed properties and return types; favor `readonly` where appropriate. +- Keep constructors light; prefer small, composable services injected via Symfony DI. + +## Testing Guidelines +- Use PHPUnit (13.x). Name files `*Test.php` mirroring the class under test. +- Unit tests live in `tests/Unit/` extending `TestCase`. +- Keep tests deterministic; use data providers where appropriate. +- Cover entity trait behavior, enum choices/formatting, and edge cases. + +## Commit & Pull Request Guidelines +- Commit messages: short, action-oriented, optionally bracketed scope (e.g., `[fix] handle null meta description`, `[master] bump version`). +- Keep commits focused; avoid unrelated formatting churn. +- Pull requests should include: purpose summary, key changes, test results. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b02a103 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,47 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +A Symfony bundle providing a reusable SEO meta data layer — entity interface, Doctrine ORM trait, enum, and optionally CMS form types/DTOs — designed to mix into content entities. + +**Requirements:** PHP ^8.5, Doctrine ORM ^3.0, Symfony 8.0 + +**Namespace:** `ChamberOrchestra\MetaBundle` (PSR-4 from package root — no `src/` directory) + +## Commands + +```bash +composer install # Install dependencies +./bin/phpunit # Run all tests +./bin/phpunit --filter ClassName # Run a specific test class +./bin/phpunit --filter testMethodName # Run a specific test method +composer test # Alias for vendor/bin/phpunit +``` + +## Architecture + +### Entity Layer (standalone) + +- `MetaInterface` — contract for `getTitle()`, `getMetaTitle()`, `getMetaDescription()`, `getMetaKeywords()`, `getRobotsBehaviour(): int` +- `MetaTrait` — Doctrine ORM implementation with mapped properties: `title`, `metaTitle`, `metaImagePath`, `metaDescription`, `metaKeywords`, `robotsBehaviour` (smallint). Helper `getMeta(): array` returns clean associative meta values. +- `RobotsBehaviour` — int-backed enum: `IndexFollow(0)`, `IndexNoFollow(1)`, `NoIndexFollow(2)`, `NoIndexNoFollow(3)`. Provides `choices()` for form ChoiceType and `getFormattedBehaviour()` for robots meta tag strings. + +### CMS/Form Layer (requires external packages) + +- `MetaDto` / `MetaType` — admin forms (requires `dev/cms-bundle`, `dev/file-bundle`) +- `MetaTranslatableDto` / `MetaTranslatableType` — multi-language support (requires `dev/translation-bundle`) + +## Testing + +- PHPUnit 13.x; tests in `tests/` autoloaded as `Tests\` +- Unit tests in `tests/Unit/` extend `TestCase` +- Cms/Form layer excluded from coverage (depends on external packages) + +## Code Conventions + +- PSR-12, `declare(strict_types=1)`, 4-space indent +- Typed properties and return types; favor `readonly` +- Constructor injection only; autowiring and autoconfiguration +- Commit style: short, action-oriented with optional bracketed scope — `[fix] ...`, `[master] ...` diff --git a/ChamberOrchestraMetaBundle.php b/ChamberOrchestraMetaBundle.php new file mode 100644 index 0000000..c2b420b --- /dev/null +++ b/ChamberOrchestraMetaBundle.php @@ -0,0 +1,11 @@ +translations = new DtoCollection(MetaDto::class); + parent::__construct($typeClass); + } +} diff --git a/Cms/Form/Type/MetaTranslatableType.php b/Cms/Form/Type/MetaTranslatableType.php new file mode 100644 index 0000000..d0b05bc --- /dev/null +++ b/Cms/Form/Type/MetaTranslatableType.php @@ -0,0 +1,30 @@ +setDefaults([ + 'data_class' => MetaTranslatableDto::class, + 'translation_domain' => 'cms', + 'label_format' => 'meta.field.%name%', + ]); + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('translations', TranslationsType::class, [ + 'entry_type' => MetaType::class, + ]); + } +} diff --git a/Cms/Form/Type/MetaType.php b/Cms/Form/Type/MetaType.php new file mode 100644 index 0000000..b66844f --- /dev/null +++ b/Cms/Form/Type/MetaType.php @@ -0,0 +1,79 @@ +setDefaults([ + 'data_class' => MetaDto::class, + 'translation_domain' => 'cms', + 'label_format' => 'meta.field.%name%', + ]); + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('metaImage', ImageType::class, [ + 'required' => false, + 'constraints' => [ + new Image(), + ], + ]) + ->add('title', TextType::class, [ + 'required' => true, + 'attr' => ['maxlength' => $max = 127], + 'constraints' => [ + new NotBlank(), + new Length(['max' => $max]), + ], + ]) + ->add('robotsBehaviour', ChoiceType::class, [ + 'required' => true, + 'expanded' => false, + 'multiple' => false, + 'choices' => RobotsBehaviour::choices(), + 'constraints' => [ + new NotBlank(), + ], + ]) + ->add('metaTitle', TextType::class, [ + 'required' => false, + 'attr' => ['maxlength' => $max = 127], + 'constraints' => [ + new Length(['max' => $max]), + ], + ]) + ->add('metaDescription', TextareaType::class, [ + 'required' => false, + 'attr' => ['maxlength' => $max = 255], + 'constraints' => [ + new Length(['max' => $max]), + ], + ]) + ->add('metaKeywords', TextareaType::class, [ + 'required' => false, + 'attr' => ['maxlength' => $max = 255], + 'constraints' => [ + new Length(['max' => $max]), + ], + ]); + } +} diff --git a/DependencyInjection/DevMetaExtension.php b/DependencyInjection/DevMetaExtension.php new file mode 100644 index 0000000..4bef4e7 --- /dev/null +++ b/DependencyInjection/DevMetaExtension.php @@ -0,0 +1,19 @@ +load('services.yaml'); + } +} diff --git a/Entity/Helper/RobotsBehaviour.php b/Entity/Helper/RobotsBehaviour.php new file mode 100644 index 0000000..c196bea --- /dev/null +++ b/Entity/Helper/RobotsBehaviour.php @@ -0,0 +1,41 @@ + self::IndexFollow->value, + 'robots_behaviour.indexnofollow' => self::IndexNoFollow->value, + 'robots_behaviour.noindexfollow' => self::NoIndexFollow->value, + 'robots_behaviour.noindexnofollow' => self::NoIndexNoFollow->value, + ]; + } + + public static function getFormattedBehaviour(int $behaviour): string + { + $case = self::tryFrom($behaviour); + + if ($case === null) { + throw new OutOfBoundsException(sprintf("No behaviour with type '%d'", $behaviour)); + } + + return match ($case) { + self::IndexFollow => 'index, follow', + self::IndexNoFollow => 'index, nofollow', + self::NoIndexFollow => 'noindex, follow', + self::NoIndexNoFollow => 'noindex, nofollow', + }; + } +} diff --git a/Entity/MetaInterface.php b/Entity/MetaInterface.php new file mode 100644 index 0000000..78a078d --- /dev/null +++ b/Entity/MetaInterface.php @@ -0,0 +1,18 @@ +value; + + public function getTitle(): ?string + { + return $this->title; + } + + public function getMetaTitle(): ?string + { + return $this->metaTitle; + } + + public function getMetaDescription(): ?string + { + return $this->metaDescription; + } + + public function getMetaKeywords(): ?string + { + return $this->metaKeywords; + } + + public function getRobotsBehaviour(): int + { + return $this->robotsBehaviour; + } + + public function getMetaImage(): ?File + { + return $this->metaImage; + } + + public function getMetaImagePath(): ?string + { + return $this->metaImagePath; + } + + public function getFormattedRobotsBehaviour(): string + { + return RobotsBehaviour::getFormattedBehaviour($this->robotsBehaviour); + } + + public function getMeta(): array + { + return [ + 'pageTitle' => $this->title, + 'title' => $this->metaTitle, + 'image' => $this->metaImagePath, + 'description' => $this->metaDescription !== null ? strip_tags($this->metaDescription) : null, + 'keywords' => $this->metaKeywords, + ]; + } +} diff --git a/Exception/ExceptionInterface.php b/Exception/ExceptionInterface.php new file mode 100644 index 0000000..431b8dd --- /dev/null +++ b/Exception/ExceptionInterface.php @@ -0,0 +1,9 @@ + + + + + + + + + + tests + + + + + . + + + tests + vendor + Cms + + + diff --git a/tests/Unit/Entity/Helper/RobotsBehaviourTest.php b/tests/Unit/Entity/Helper/RobotsBehaviourTest.php new file mode 100644 index 0000000..784ecb5 --- /dev/null +++ b/tests/Unit/Entity/Helper/RobotsBehaviourTest.php @@ -0,0 +1,81 @@ + [0, 'index, follow']; + yield 'IndexNoFollow' => [1, 'index, nofollow']; + yield 'NoIndexFollow' => [2, 'noindex, follow']; + yield 'NoIndexNoFollow' => [3, 'noindex, nofollow']; + } + + public function testGetFormattedBehaviourThrowsOnInvalidValue(): void + { + $this->expectException(OutOfBoundsException::class); + $this->expectExceptionMessage("No behaviour with type '99'"); + + RobotsBehaviour::getFormattedBehaviour(99); + } + + public function testGetFormattedBehaviourThrowsOnNegativeValue(): void + { + $this->expectException(OutOfBoundsException::class); + + RobotsBehaviour::getFormattedBehaviour(-1); + } + + public function testEnumCasesHaveCorrectValues(): void + { + self::assertSame(0, RobotsBehaviour::IndexFollow->value); + self::assertSame(1, RobotsBehaviour::IndexNoFollow->value); + self::assertSame(2, RobotsBehaviour::NoIndexFollow->value); + self::assertSame(3, RobotsBehaviour::NoIndexNoFollow->value); + } + + public function testTryFromReturnsNullForInvalid(): void + { + self::assertNull(RobotsBehaviour::tryFrom(42)); + } + + public function testFromReturnsCase(): void + { + self::assertSame(RobotsBehaviour::IndexFollow, RobotsBehaviour::from(0)); + self::assertSame(RobotsBehaviour::NoIndexNoFollow, RobotsBehaviour::from(3)); + } +} diff --git a/tests/Unit/Entity/MetaTraitTest.php b/tests/Unit/Entity/MetaTraitTest.php new file mode 100644 index 0000000..eae6967 --- /dev/null +++ b/tests/Unit/Entity/MetaTraitTest.php @@ -0,0 +1,151 @@ +title = $title; + } + + public function setMetaTitle(?string $metaTitle): void + { + $this->metaTitle = $metaTitle; + } + + public function setMetaDescription(?string $metaDescription): void + { + $this->metaDescription = $metaDescription; + } + + public function setMetaKeywords(?string $metaKeywords): void + { + $this->metaKeywords = $metaKeywords; + } + + public function setRobotsBehaviour(int $robotsBehaviour): void + { + $this->robotsBehaviour = $robotsBehaviour; + } + + public function setMetaImagePath(?string $path): void + { + $this->metaImagePath = $path; + } + }; + } + + public function testDefaultValues(): void + { + $entity = $this->createEntity(); + + self::assertNull($entity->getTitle()); + self::assertNull($entity->getMetaTitle()); + self::assertNull($entity->getMetaDescription()); + self::assertNull($entity->getMetaKeywords()); + self::assertNull($entity->getMetaImage()); + self::assertNull($entity->getMetaImagePath()); + self::assertSame(RobotsBehaviour::IndexNoFollow->value, $entity->getRobotsBehaviour()); + } + + public function testGettersReturnSetValues(): void + { + $entity = $this->createEntity(); + $entity->setTitle('Page Title'); + $entity->setMetaTitle('SEO Title'); + $entity->setMetaDescription('SEO Description'); + $entity->setMetaKeywords('key1, key2'); + + self::assertSame('Page Title', $entity->getTitle()); + self::assertSame('SEO Title', $entity->getMetaTitle()); + self::assertSame('SEO Description', $entity->getMetaDescription()); + self::assertSame('key1, key2', $entity->getMetaKeywords()); + } + + public function testGetFormattedRobotsBehaviourDefault(): void + { + $entity = $this->createEntity(); + + self::assertSame('index, nofollow', $entity->getFormattedRobotsBehaviour()); + } + + public function testGetFormattedRobotsBehaviourCustom(): void + { + $entity = $this->createEntity(); + $entity->setRobotsBehaviour(RobotsBehaviour::NoIndexNoFollow->value); + + self::assertSame('noindex, nofollow', $entity->getFormattedRobotsBehaviour()); + } + + public function testGetMetaWithAllValues(): void + { + $entity = $this->createEntity(); + $entity->setTitle('Page Title'); + $entity->setMetaTitle('SEO Title'); + $entity->setMetaDescription('Description text'); + $entity->setMetaKeywords('key1, key2'); + $entity->setMetaImagePath('/images/meta.jpg'); + + $meta = $entity->getMeta(); + + self::assertSame('Page Title', $meta['pageTitle']); + self::assertSame('SEO Title', $meta['title']); + self::assertSame('/images/meta.jpg', $meta['image']); + self::assertSame('Description text', $meta['description']); + self::assertSame('key1, key2', $meta['keywords']); + } + + public function testGetMetaWithNullValues(): void + { + $entity = $this->createEntity(); + + $meta = $entity->getMeta(); + + self::assertNull($meta['pageTitle']); + self::assertNull($meta['title']); + self::assertNull($meta['image']); + self::assertNull($meta['description']); + self::assertNull($meta['keywords']); + } + + public function testGetMetaStripsHtmlFromDescription(): void + { + $entity = $this->createEntity(); + $entity->setMetaDescription('

Hello world

'); + + $meta = $entity->getMeta(); + + self::assertSame('Hello world', $meta['description']); + } + + public function testGetMetaPreservesNullDescription(): void + { + $entity = $this->createEntity(); + $entity->setMetaDescription(null); + + $meta = $entity->getMeta(); + + self::assertNull($meta['description']); + } + + public function testGetMetaImagePath(): void + { + $entity = $this->createEntity(); + $entity->setMetaImagePath('/uploads/seo/image.png'); + + self::assertSame('/uploads/seo/image.png', $entity->getMetaImagePath()); + } +} From d67aee29b58445af082fe25ff18bdee53853f2bd Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 17 Feb 2026 18:20:11 +0300 Subject: [PATCH 2/4] fix Length constraint to use named arguments for Symfony 8 Co-Authored-By: Claude Opus 4.6 --- Cms/Form/Type/MetaType.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cms/Form/Type/MetaType.php b/Cms/Form/Type/MetaType.php index b66844f..9ab9245 100644 --- a/Cms/Form/Type/MetaType.php +++ b/Cms/Form/Type/MetaType.php @@ -42,7 +42,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'attr' => ['maxlength' => $max = 127], 'constraints' => [ new NotBlank(), - new Length(['max' => $max]), + new Length(max: $max), ], ]) ->add('robotsBehaviour', ChoiceType::class, [ @@ -58,21 +58,21 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'attr' => ['maxlength' => $max = 127], 'constraints' => [ - new Length(['max' => $max]), + new Length(max: $max), ], ]) ->add('metaDescription', TextareaType::class, [ 'required' => false, 'attr' => ['maxlength' => $max = 255], 'constraints' => [ - new Length(['max' => $max]), + new Length(max: $max), ], ]) ->add('metaKeywords', TextareaType::class, [ 'required' => false, 'attr' => ['maxlength' => $max = 255], 'constraints' => [ - new Length(['max' => $max]), + new Length(max: $max), ], ]); } From 514db4bd45d434de0c0569719c0db2932a8b8dda Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 17 Feb 2026 19:54:46 +0300 Subject: [PATCH 3/4] modernize bundle for Symfony 8 / Doctrine ORM 3 / PHP 8.5 - Rename namespace to ChamberOrchestra\MetaBundle, DI extension to ChamberOrchestraMetaExtension - Store RobotsBehaviour as native enum (enumType on SMALLINT), remove choices()/getFormattedBehaviour() static methods - Add File $metaImage with #[UploadableProperty] for file-bundle upload integration - Switch MetaType to Symfony EnumType, add MAX_STRING_LENGTH/MAX_DESCRIPTION_LENGTH constants - MetaDto: remove dead $slug/$seoDescription, type $robotsBehaviour as ?RobotsBehaviour - Delete unused Exception/ namespace, empty services.yaml; simplify DI extension - Rename .yml translations to .yaml - Add unit tests (trait, mapping, bundle, DI extension, enum) and integration tests (boot, metadata, persistence) - Update CI workflows for 8.0 branch, fix cache key, add tag.yml safety check - Update composer.json: add file-bundle dep, keywords, SEO description - Rewrite README with full usage docs, file-upload integration, property table Co-Authored-By: Claude Opus 4.6 --- .github/workflows/php.yml | 9 +- .github/workflows/tag.yml | 13 +- .gitignore | 3 +- AGENTS.md | 2 +- CLAUDE.md | 14 +- Cms/Form/Dto/MetaDto.php | 5 +- Cms/Form/Dto/MetaTranslatableDto.php | 5 +- Cms/Form/Type/MetaType.php | 35 ++-- ....php => ChamberOrchestraMetaExtension.php} | 6 +- Entity/Helper/RobotsBehaviour.php | 22 +-- Entity/MetaInterface.php | 14 +- Entity/MetaTrait.php | 21 ++- Exception/ExceptionInterface.php | 9 - Exception/LogicException.php | 9 - Exception/OutOfBoundsException.php | 9 - README.md | 137 +++++++++++++- Resources/config/services.yaml | 5 - .../translations/{cms.en.yml => cms.en.yaml} | 0 .../translations/{cms.ru.yml => cms.ru.yaml} | 0 composer.json | 20 +- phpunit.xml.dist | 13 +- tests/Integrational/BundleBootTest.php | 34 ++++ tests/Integrational/DoctrineMetadataTest.php | 57 ++++++ tests/Integrational/Entity/TestArticle.php | 70 +++++++ tests/Integrational/EntityPersistenceTest.php | 178 ++++++++++++++++++ tests/Integrational/TestKernel.php | 72 +++++++ tests/Unit/ChamberOrchestraMetaBundleTest.php | 34 ++++ .../ChamberOrchestraMetaExtensionTest.php | 34 ++++ .../Entity/Helper/RobotsBehaviourTest.php | 52 +---- tests/Unit/Entity/MetaMappingTest.php | 104 ++++++++++ tests/Unit/Entity/MetaTraitTest.php | 76 ++++++-- 31 files changed, 890 insertions(+), 172 deletions(-) rename DependencyInjection/{DevMetaExtension.php => ChamberOrchestraMetaExtension.php} (51%) delete mode 100644 Exception/ExceptionInterface.php delete mode 100644 Exception/LogicException.php delete mode 100644 Exception/OutOfBoundsException.php delete mode 100644 Resources/config/services.yaml rename Resources/translations/{cms.en.yml => cms.en.yaml} (100%) rename Resources/translations/{cms.ru.yml => cms.ru.yaml} (100%) create mode 100644 tests/Integrational/BundleBootTest.php create mode 100644 tests/Integrational/DoctrineMetadataTest.php create mode 100644 tests/Integrational/Entity/TestArticle.php create mode 100644 tests/Integrational/EntityPersistenceTest.php create mode 100644 tests/Integrational/TestKernel.php create mode 100644 tests/Unit/ChamberOrchestraMetaBundleTest.php create mode 100644 tests/Unit/DependencyInjection/ChamberOrchestraMetaExtensionTest.php create mode 100644 tests/Unit/Entity/MetaMappingTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 44106e5..512030b 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -2,9 +2,9 @@ name: PHP Composer on: push: - branches: [ "master" ] + branches: [ "master", "8.0" ] pull_request: - branches: [ "master" ] + branches: [ "master", "8.0" ] permissions: contents: read @@ -17,6 +17,7 @@ jobs: - uses: actions/checkout@v4 - name: Setup PHP 8.5 + id: setup-php uses: shivammathur/setup-php@v2 with: php-version: "8.5" @@ -32,9 +33,9 @@ jobs: path: | vendor ~/.composer/cache/files - key: ${{ runner.os }}-php-8.5-composer-${{ hashFiles('**/composer.lock') }} + key: ${{ runner.os }}-php-${{ steps.setup-php.outputs.php-version }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: | - ${{ runner.os }}-php-8.5-composer- + ${{ runner.os }}-php-${{ steps.setup-php.outputs.php-version }}-composer- - name: Install dependencies run: composer install --prefer-dist --no-progress --no-interaction diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index 8feaddc..f3a5b49 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -3,7 +3,7 @@ name: Tag Release on: pull_request: types: [closed] - branches: [master] + branches: [master, "8.0"] permissions: contents: write @@ -16,7 +16,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - ref: master + ref: ${{ github.event.pull_request.base.ref }} fetch-depth: 0 fetch-tags: true @@ -37,5 +37,10 @@ jobs: - name: Create and push tag run: | - git tag "${{ steps.version.outputs.next }}" - git push origin "${{ steps.version.outputs.next }}" + next="${{ steps.version.outputs.next }}" + if [[ -z "$next" ]]; then + echo "::error::Version computation produced an empty tag — aborting" + exit 1 + fi + git tag "$next" + git push origin "$next" diff --git a/.gitignore b/.gitignore index 492bc82..b2f9bc9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /vendor/ +/var/ composer.lock -.phpunit.cache .claude/settings.local.json +.phpunit.cache/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 99f443f..73da4fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## Project Structure & Module Organization - Entity layer (`Entity/`) provides `MetaInterface`, `MetaTrait`, and `RobotsBehaviour` enum under `ChamberOrchestra\MetaBundle`. - CMS form layer (`Cms/Form/`) provides DTOs and Symfony form types — requires external `dev/*` packages. -- Bundle entry point is `DevMetaBundle.php`; DI extension in `DependencyInjection/`. +- Bundle entry point is `ChamberOrchestraMetaBundle.php`; DI extension in `DependencyInjection/ChamberOrchestraMetaExtension.php`. - Tests belong in `tests/` (autoloaded as `Tests\`); tools are in `bin/` (`bin/phpunit`). - Autoloading is PSR-4 from the package root (no `src/` directory). - Requirements: PHP 8.5+, Doctrine ORM ^3.0, Symfony 8.0. diff --git a/CLAUDE.md b/CLAUDE.md index b02a103..94071c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,10 +6,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co A Symfony bundle providing a reusable SEO meta data layer — entity interface, Doctrine ORM trait, enum, and optionally CMS form types/DTOs — designed to mix into content entities. -**Requirements:** PHP ^8.5, Doctrine ORM ^3.0, Symfony 8.0 +**Requirements:** PHP ^8.5, Doctrine ORM ^3.0, Symfony ^8.0, `chamber-orchestra/file-bundle` ^8.0 **Namespace:** `ChamberOrchestra\MetaBundle` (PSR-4 from package root — no `src/` directory) +**Bundle class:** `ChamberOrchestraMetaBundle` — DI extension is `ChamberOrchestraMetaExtension` (no services registered; entity layer is pure PHP) + ## Commands ```bash @@ -22,15 +24,15 @@ composer test # Alias for vendor/bin/phpunit ## Architecture -### Entity Layer (standalone) +### Entity Layer -- `MetaInterface` — contract for `getTitle()`, `getMetaTitle()`, `getMetaDescription()`, `getMetaKeywords()`, `getRobotsBehaviour(): int` -- `MetaTrait` — Doctrine ORM implementation with mapped properties: `title`, `metaTitle`, `metaImagePath`, `metaDescription`, `metaKeywords`, `robotsBehaviour` (smallint). Helper `getMeta(): array` returns clean associative meta values. -- `RobotsBehaviour` — int-backed enum: `IndexFollow(0)`, `IndexNoFollow(1)`, `NoIndexFollow(2)`, `NoIndexNoFollow(3)`. Provides `choices()` for form ChoiceType and `getFormattedBehaviour()` for robots meta tag strings. +- `MetaInterface` — contract for `getTitle()`, `getMetaTitle()`, `getMetaDescription()`, `getMetaKeywords()`, `getMetaImage()`, `getMetaImagePath()`, `getRobotsBehaviour()`, `getMeta()` +- `MetaTrait` — Doctrine ORM implementation with mapped properties: `title`, `metaTitle`, `metaImage` (transient, `#[UploadableProperty]`), `metaImagePath`, `metaDescription`, `metaKeywords`, `robotsBehaviour` (smallint with `enumType: RobotsBehaviour`). The `metaImage` File property integrates with file-bundle's upload system via `#[Upload\UploadableProperty(mappedBy: 'metaImagePath')]`. +- `RobotsBehaviour` — int-backed enum: `IndexFollow(0)`, `IndexNoFollow(1)`, `NoIndexFollow(2)`, `NoIndexNoFollow(3)`. Provides `format(): string` for robots meta tag strings. ### CMS/Form Layer (requires external packages) -- `MetaDto` / `MetaType` — admin forms (requires `dev/cms-bundle`, `dev/file-bundle`) +- `MetaDto` / `MetaType` — admin forms using Symfony `EnumType` for robots behaviour (requires `dev/cms-bundle`, `dev/file-bundle`) - `MetaTranslatableDto` / `MetaTranslatableType` — multi-language support (requires `dev/translation-bundle`) ## Testing diff --git a/Cms/Form/Dto/MetaDto.php b/Cms/Form/Dto/MetaDto.php index 07aadfd..ce7e122 100644 --- a/Cms/Form/Dto/MetaDto.php +++ b/Cms/Form/Dto/MetaDto.php @@ -5,16 +5,15 @@ namespace ChamberOrchestra\MetaBundle\Cms\Form\Dto; use ChamberOrchestra\CmsBundle\Form\Dto\AbstractDto; +use ChamberOrchestra\MetaBundle\Entity\Helper\RobotsBehaviour; use Symfony\Component\HttpFoundation\File\File; class MetaDto extends AbstractDto { public ?string $title = null; - public ?string $slug = null; - public ?int $robotsBehaviour = null; + public ?RobotsBehaviour $robotsBehaviour = null; public ?string $metaTitle = null; public ?string $metaDescription = null; public ?string $metaKeywords = null; - public ?string $seoDescription = null; public ?File $metaImage = null; } diff --git a/Cms/Form/Dto/MetaTranslatableDto.php b/Cms/Form/Dto/MetaTranslatableDto.php index 9cfae3f..b1ae129 100644 --- a/Cms/Form/Dto/MetaTranslatableDto.php +++ b/Cms/Form/Dto/MetaTranslatableDto.php @@ -6,15 +6,16 @@ use ChamberOrchestra\CmsBundle\Form\Dto\AbstractDto; use ChamberOrchestra\CmsBundle\Form\Dto\DtoCollection; +use ChamberOrchestra\MetaBundle\Cms\Form\Type\MetaTranslatableType; use ChamberOrchestra\TranslationBundle\Cms\Form\Dto\TranslatableDtoTrait; class MetaTranslatableDto extends AbstractDto { use TranslatableDtoTrait; - public function __construct(string $typeClass) + public function __construct() { $this->translations = new DtoCollection(MetaDto::class); - parent::__construct($typeClass); + parent::__construct(MetaTranslatableType::class); } } diff --git a/Cms/Form/Type/MetaType.php b/Cms/Form/Type/MetaType.php index 9ab9245..5aeac8b 100644 --- a/Cms/Form/Type/MetaType.php +++ b/Cms/Form/Type/MetaType.php @@ -8,7 +8,7 @@ use ChamberOrchestra\MetaBundle\Entity\Helper\RobotsBehaviour; use ChamberOrchestra\FileBundle\Cms\Form\Type\ImageType; use Symfony\Component\Form\AbstractType; -use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\EnumType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; @@ -19,6 +19,9 @@ class MetaType extends AbstractType { + private const int MAX_STRING_LENGTH = 255; + private const int MAX_DESCRIPTION_LENGTH = 160; + public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ @@ -39,40 +42,44 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]) ->add('title', TextType::class, [ 'required' => true, - 'attr' => ['maxlength' => $max = 127], + 'attr' => ['maxlength' => self::MAX_STRING_LENGTH], 'constraints' => [ new NotBlank(), - new Length(max: $max), + new Length(max: self::MAX_STRING_LENGTH), ], ]) - ->add('robotsBehaviour', ChoiceType::class, [ + ->add('robotsBehaviour', EnumType::class, [ + 'class' => RobotsBehaviour::class, 'required' => true, - 'expanded' => false, - 'multiple' => false, - 'choices' => RobotsBehaviour::choices(), + 'choice_label' => static fn(RobotsBehaviour $case): string => match ($case) { + RobotsBehaviour::IndexFollow => 'robots_behaviour.indexfollow', + RobotsBehaviour::IndexNoFollow => 'robots_behaviour.indexnofollow', + RobotsBehaviour::NoIndexFollow => 'robots_behaviour.noindexfollow', + RobotsBehaviour::NoIndexNoFollow => 'robots_behaviour.noindexnofollow', + }, 'constraints' => [ new NotBlank(), ], ]) ->add('metaTitle', TextType::class, [ 'required' => false, - 'attr' => ['maxlength' => $max = 127], + 'attr' => ['maxlength' => self::MAX_STRING_LENGTH], 'constraints' => [ - new Length(max: $max), + new Length(max: self::MAX_STRING_LENGTH), ], ]) ->add('metaDescription', TextareaType::class, [ 'required' => false, - 'attr' => ['maxlength' => $max = 255], + 'attr' => ['data-maxlength' => self::MAX_DESCRIPTION_LENGTH, 'rows' => 3], 'constraints' => [ - new Length(max: $max), + new Length(max: self::MAX_DESCRIPTION_LENGTH), ], ]) - ->add('metaKeywords', TextareaType::class, [ + ->add('metaKeywords', TextType::class, [ 'required' => false, - 'attr' => ['maxlength' => $max = 255], + 'attr' => ['maxlength' => self::MAX_STRING_LENGTH], 'constraints' => [ - new Length(max: $max), + new Length(max: self::MAX_STRING_LENGTH), ], ]); } diff --git a/DependencyInjection/DevMetaExtension.php b/DependencyInjection/ChamberOrchestraMetaExtension.php similarity index 51% rename from DependencyInjection/DevMetaExtension.php rename to DependencyInjection/ChamberOrchestraMetaExtension.php index 4bef4e7..a8cd2db 100644 --- a/DependencyInjection/DevMetaExtension.php +++ b/DependencyInjection/ChamberOrchestraMetaExtension.php @@ -4,16 +4,12 @@ namespace ChamberOrchestra\MetaBundle\DependencyInjection; -use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; -use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; -class DevMetaExtension extends Extension +class ChamberOrchestraMetaExtension extends Extension { public function load(array $configs, ContainerBuilder $container): void { - $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); - $loader->load('services.yaml'); } } diff --git a/Entity/Helper/RobotsBehaviour.php b/Entity/Helper/RobotsBehaviour.php index c196bea..2a633d1 100644 --- a/Entity/Helper/RobotsBehaviour.php +++ b/Entity/Helper/RobotsBehaviour.php @@ -4,8 +4,6 @@ namespace ChamberOrchestra\MetaBundle\Entity\Helper; -use ChamberOrchestra\MetaBundle\Exception\OutOfBoundsException; - enum RobotsBehaviour: int { case IndexFollow = 0; @@ -13,25 +11,9 @@ enum RobotsBehaviour: int case NoIndexFollow = 2; case NoIndexNoFollow = 3; - public static function choices(): array - { - return [ - 'robots_behaviour.indexfollow' => self::IndexFollow->value, - 'robots_behaviour.indexnofollow' => self::IndexNoFollow->value, - 'robots_behaviour.noindexfollow' => self::NoIndexFollow->value, - 'robots_behaviour.noindexnofollow' => self::NoIndexNoFollow->value, - ]; - } - - public static function getFormattedBehaviour(int $behaviour): string + public function format(): string { - $case = self::tryFrom($behaviour); - - if ($case === null) { - throw new OutOfBoundsException(sprintf("No behaviour with type '%d'", $behaviour)); - } - - return match ($case) { + return match ($this) { self::IndexFollow => 'index, follow', self::IndexNoFollow => 'index, nofollow', self::NoIndexFollow => 'noindex, follow', diff --git a/Entity/MetaInterface.php b/Entity/MetaInterface.php index 78a078d..1919baa 100644 --- a/Entity/MetaInterface.php +++ b/Entity/MetaInterface.php @@ -4,6 +4,9 @@ namespace ChamberOrchestra\MetaBundle\Entity; +use ChamberOrchestra\MetaBundle\Entity\Helper\RobotsBehaviour; +use Symfony\Component\HttpFoundation\File\File; + interface MetaInterface { public function getTitle(): ?string; @@ -14,5 +17,14 @@ public function getMetaDescription(): ?string; public function getMetaKeywords(): ?string; - public function getRobotsBehaviour(): int; + public function getMetaImage(): ?File; + + public function getMetaImagePath(): ?string; + + public function getRobotsBehaviour(): RobotsBehaviour; + + /** + * @return array{pageTitle: ?string, title: ?string, image: ?string, description: ?string, keywords: ?string} + */ + public function getMeta(): array; } diff --git a/Entity/MetaTrait.php b/Entity/MetaTrait.php index bf85b2b..57dccfc 100644 --- a/Entity/MetaTrait.php +++ b/Entity/MetaTrait.php @@ -4,31 +4,34 @@ namespace ChamberOrchestra\MetaBundle\Entity; +use ChamberOrchestra\FileBundle\Mapping\Annotation as Upload; use ChamberOrchestra\MetaBundle\Entity\Helper\RobotsBehaviour; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\HttpFoundation\File\File; trait MetaTrait { - #[ORM\Column(type: "string", length: 255, nullable: true)] + #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] protected ?string $title = null; - #[ORM\Column(type: "string", length: 255, nullable: true)] + #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] protected ?string $metaTitle = null; + #[Upload\UploadableProperty(mappedBy: 'metaImagePath')] protected ?File $metaImage = null; - #[ORM\Column(type: 'string', length: 255, nullable: true)] + #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] protected ?string $metaImagePath = null; - #[ORM\Column(type: 'text', nullable: true)] + #[ORM\Column(type: Types::TEXT, nullable: true)] protected ?string $metaDescription = null; - #[ORM\Column(type: "string", length: 255, nullable: true)] + #[ORM\Column(type: Types::STRING, length: 255, nullable: true)] protected ?string $metaKeywords = null; - #[ORM\Column(type: 'smallint', nullable: false)] - protected int $robotsBehaviour = RobotsBehaviour::IndexNoFollow->value; + #[ORM\Column(type: Types::SMALLINT, nullable: false, enumType: RobotsBehaviour::class)] + protected RobotsBehaviour $robotsBehaviour = RobotsBehaviour::IndexNoFollow; public function getTitle(): ?string { @@ -50,7 +53,7 @@ public function getMetaKeywords(): ?string return $this->metaKeywords; } - public function getRobotsBehaviour(): int + public function getRobotsBehaviour(): RobotsBehaviour { return $this->robotsBehaviour; } @@ -67,7 +70,7 @@ public function getMetaImagePath(): ?string public function getFormattedRobotsBehaviour(): string { - return RobotsBehaviour::getFormattedBehaviour($this->robotsBehaviour); + return $this->robotsBehaviour->format(); } public function getMeta(): array diff --git a/Exception/ExceptionInterface.php b/Exception/ExceptionInterface.php deleted file mode 100644 index 431b8dd..0000000 --- a/Exception/ExceptionInterface.php +++ /dev/null @@ -1,9 +0,0 @@ -` rendering, with automatic HTML stripping on descriptions +- **Native Doctrine enum mapping** — `RobotsBehaviour` is stored as `SMALLINT` with `enumType`, hydrated directly as an enum case +- **File-bundle integration** — transient `File $metaImage` property with `#[UploadableProperty]` for automatic image upload handling via `chamber-orchestra/file-bundle` +- **CMS form types** (optional) — `MetaType` with Symfony `EnumType`, image upload, and validation constraints aligned to column lengths +- **Translatable support** (optional) — `MetaTranslatableType` / `MetaTranslatableDto` for multi-language meta data +- **Translation files** — ships with English and Russian labels for form fields and robots choices + +## Requirements + +- PHP ^8.5 +- Doctrine ORM ^3.0 +- Symfony ^8.0 +- `chamber-orchestra/file-bundle` ^8.0 + +Optional (for CMS form layer): + +- `chamber-orchestra/cms-bundle` +- `chamber-orchestra/translation-bundle` (for i18n) + +## Installation + +```bash +composer require chamber-orchestra/meta-bundle +``` + +## Usage + +### 1. Add meta fields to your entity + +```php +use ChamberOrchestra\FileBundle\Mapping\Annotation as Upload; +use ChamberOrchestra\MetaBundle\Entity\MetaInterface; +use ChamberOrchestra\MetaBundle\Entity\MetaTrait; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +#[Upload\Uploadable(prefix: 'article')] +class Article implements MetaInterface +{ + use MetaTrait; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + private ?int $id = null; + + // your entity fields... +} +``` + +The `#[Uploadable]` class attribute is required for file-bundle to handle the `metaImage` upload automatically. + +This adds the following columns and properties to your entity: + +| Property | Type | Persisted | Description | +|--------------------|------------|-----------|-----------------------------------| +| `title` | `string` | yes | Page title (H1) | +| `metaTitle` | `string` | yes | `` / og:title | +| `metaImage` | `File` | no | Transient upload (file-bundle) | +| `metaImagePath` | `string` | yes | Social share image path | +| `metaDescription` | `text` | yes | Meta description | +| `metaKeywords` | `string` | yes | Meta keywords | +| `robotsBehaviour` | `smallint` | yes | Robots enum (default: 1) | + +### 2. Render meta tags in Twig + +```twig +{% set meta = article.meta %} + +<title>{{ meta.title ?? meta.pageTitle }} + + + +{% if meta.image %} + +{% endif %} +``` + +The `getMeta()` method automatically strips HTML tags from the description. + +### 3. Robots behaviour enum + +```php +use ChamberOrchestra\MetaBundle\Entity\Helper\RobotsBehaviour; + +$entity->getRobotsBehaviour(); // RobotsBehaviour::IndexNoFollow +$entity->getFormattedRobotsBehaviour(); // "index, nofollow" + +RobotsBehaviour::NoIndexNoFollow->format(); // "noindex, nofollow" +``` + +Available cases: + +| Case | Value | Output | +|-------------------|-------|----------------------| +| `IndexFollow` | 0 | `index, follow` | +| `IndexNoFollow` | 1 | `index, nofollow` | +| `NoIndexFollow` | 2 | `noindex, follow` | +| `NoIndexNoFollow` | 3 | `noindex, nofollow` | + +### 4. CMS admin forms (optional) + +Embed the meta form type in your admin form: + +```php +use ChamberOrchestra\MetaBundle\Cms\Form\Type\MetaType; + +$builder->add('meta', MetaType::class); +``` + +For translatable entities: + +```php +use ChamberOrchestra\MetaBundle\Cms\Form\Type\MetaTranslatableType; + +$builder->add('meta', MetaTranslatableType::class); +``` + +## Testing + +```bash +composer install +composer test +``` + +## License + +Apache-2.0 diff --git a/Resources/config/services.yaml b/Resources/config/services.yaml deleted file mode 100644 index d4aa8ac..0000000 --- a/Resources/config/services.yaml +++ /dev/null @@ -1,5 +0,0 @@ -services: - _defaults: - autoconfigure: true - autowire: true - public: false \ No newline at end of file diff --git a/Resources/translations/cms.en.yml b/Resources/translations/cms.en.yaml similarity index 100% rename from Resources/translations/cms.en.yml rename to Resources/translations/cms.en.yaml diff --git a/Resources/translations/cms.ru.yml b/Resources/translations/cms.ru.yaml similarity index 100% rename from Resources/translations/cms.ru.yml rename to Resources/translations/cms.ru.yaml diff --git a/composer.json b/composer.json index 00ee12b..ca4fde6 100644 --- a/composer.json +++ b/composer.json @@ -1,12 +1,19 @@ { "name": "chamber-orchestra/meta-bundle", "type": "symfony-bundle", - "description": "Symfony bundle providing SEO meta data entity traits, forms, and DTOs", + "description": "Symfony 8 bundle providing reusable SEO meta data layer — Doctrine ORM trait, interface, robots enum, and optional CMS admin form types with i18n support", "keywords": [ "symfony", + "symfony8", "seo", "meta", + "meta-tags", + "open-graph", + "file-upload", + "robots", "doctrine", + "orm", + "trait", "bundle" ], "license": "Apache-2.0", @@ -20,14 +27,17 @@ ], "require": { "php": "^8.5", + "chamber-orchestra/file-bundle": "^8.0", "doctrine/orm": "^3.0", - "symfony/http-foundation": "8.0.*", - "symfony/http-kernel": "8.0.*" + "symfony/http-foundation": "^8.0", + "symfony/http-kernel": "^8.0" }, "require-dev": { - "phpunit/phpunit": "^13.0" + "doctrine/doctrine-bundle": "^3.2", + "phpunit/phpunit": "^13.0", + "symfony/framework-bundle": "^8.0", + "symfony/yaml": "^8.0" }, - "suggest": {}, "autoload": { "psr-4": { "ChamberOrchestra\\MetaBundle\\": "" diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9c4de18..30d15dc 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -10,6 +10,10 @@ + + + + @@ -18,12 +22,9 @@ - . + Entity + DependencyInjection + ChamberOrchestraMetaBundle.php - - tests - vendor - Cms - diff --git a/tests/Integrational/BundleBootTest.php b/tests/Integrational/BundleBootTest.php new file mode 100644 index 0000000..7143500 --- /dev/null +++ b/tests/Integrational/BundleBootTest.php @@ -0,0 +1,34 @@ +getEnvironment()); + } + + public function testMetaBundleIsRegistered(): void + { + self::bootKernel(); + + $bundles = self::$kernel->getBundles(); + + self::assertArrayHasKey('ChamberOrchestraMetaBundle', $bundles); + self::assertInstanceOf(ChamberOrchestraMetaBundle::class, $bundles['ChamberOrchestraMetaBundle']); + } +} diff --git a/tests/Integrational/DoctrineMetadataTest.php b/tests/Integrational/DoctrineMetadataTest.php new file mode 100644 index 0000000..009c522 --- /dev/null +++ b/tests/Integrational/DoctrineMetadataTest.php @@ -0,0 +1,57 @@ +get(EntityManagerInterface::class); + $metadata = $em->getClassMetadata(TestArticle::class); + + self::assertSame('test_article', $metadata->getTableName()); + } + + public function testMetaTraitFieldsAreInMetadata(): void + { + self::bootKernel(); + $em = self::getContainer()->get(EntityManagerInterface::class); + $metadata = $em->getClassMetadata(TestArticle::class); + + $expectedFields = ['title', 'metaTitle', 'metaImagePath', 'metaDescription', 'metaKeywords', 'robotsBehaviour']; + + foreach ($expectedFields as $field) { + self::assertTrue( + $metadata->hasField($field), + sprintf('Field "%s" is missing from metadata', $field), + ); + } + } + + public function testRobotsBehaviourEnumTypeMapping(): void + { + self::bootKernel(); + $em = self::getContainer()->get(EntityManagerInterface::class); + $metadata = $em->getClassMetadata(TestArticle::class); + + $mapping = $metadata->fieldMappings['robotsBehaviour']; + + self::assertSame(Types::SMALLINT, $mapping->type); + self::assertSame(RobotsBehaviour::class, $mapping->enumType); + } +} diff --git a/tests/Integrational/Entity/TestArticle.php b/tests/Integrational/Entity/TestArticle.php new file mode 100644 index 0000000..a1e3e61 --- /dev/null +++ b/tests/Integrational/Entity/TestArticle.php @@ -0,0 +1,70 @@ +id; + } + + public function setTitle(?string $title): self + { + $this->title = $title; + + return $this; + } + + public function setMetaTitle(?string $metaTitle): self + { + $this->metaTitle = $metaTitle; + + return $this; + } + + public function setMetaDescription(?string $metaDescription): self + { + $this->metaDescription = $metaDescription; + + return $this; + } + + public function setMetaKeywords(?string $metaKeywords): self + { + $this->metaKeywords = $metaKeywords; + + return $this; + } + + public function setMetaImagePath(?string $metaImagePath): self + { + $this->metaImagePath = $metaImagePath; + + return $this; + } + + public function setRobotsBehaviour(RobotsBehaviour $robotsBehaviour): self + { + $this->robotsBehaviour = $robotsBehaviour; + + return $this; + } +} diff --git a/tests/Integrational/EntityPersistenceTest.php b/tests/Integrational/EntityPersistenceTest.php new file mode 100644 index 0000000..428994f --- /dev/null +++ b/tests/Integrational/EntityPersistenceTest.php @@ -0,0 +1,178 @@ +em = self::getContainer()->get(EntityManagerInterface::class); + + $schemaTool = new SchemaTool($this->em); + $schemaTool->createSchema([$this->em->getClassMetadata(TestArticle::class)]); + } + + protected function tearDown(): void + { + $schemaTool = new SchemaTool($this->em); + $schemaTool->dropSchema([$this->em->getClassMetadata(TestArticle::class)]); + + parent::tearDown(); + restore_exception_handler(); + } + + public function testPersistAndRetrieveWithAllFields(): void + { + $article = (new TestArticle()) + ->setTitle('Test Page') + ->setMetaTitle('SEO Title') + ->setMetaDescription('

Description with HTML

') + ->setMetaKeywords('php, symfony') + ->setMetaImagePath('/images/test.jpg') + ->setRobotsBehaviour(RobotsBehaviour::NoIndexNoFollow); + + $this->em->persist($article); + $this->em->flush(); + + $id = $article->getId(); + self::assertNotNull($id); + + $this->em->clear(); + + $loaded = $this->em->find(TestArticle::class, $id); + + self::assertNotNull($loaded); + self::assertSame('Test Page', $loaded->getTitle()); + self::assertSame('SEO Title', $loaded->getMetaTitle()); + self::assertSame('

Description with HTML

', $loaded->getMetaDescription()); + self::assertSame('php, symfony', $loaded->getMetaKeywords()); + self::assertSame('/images/test.jpg', $loaded->getMetaImagePath()); + self::assertSame(RobotsBehaviour::NoIndexNoFollow, $loaded->getRobotsBehaviour()); + } + + public function testPersistWithDefaultValues(): void + { + $article = new TestArticle(); + + $this->em->persist($article); + $this->em->flush(); + $this->em->clear(); + + $loaded = $this->em->find(TestArticle::class, $article->getId()); + + self::assertNotNull($loaded); + self::assertNull($loaded->getTitle()); + self::assertNull($loaded->getMetaTitle()); + self::assertNull($loaded->getMetaDescription()); + self::assertNull($loaded->getMetaKeywords()); + self::assertNull($loaded->getMetaImagePath()); + self::assertSame(RobotsBehaviour::IndexNoFollow, $loaded->getRobotsBehaviour()); + } + + public function testRobotsBehaviourEnumRoundTrip(): void + { + foreach (RobotsBehaviour::cases() as $case) { + $article = (new TestArticle()) + ->setRobotsBehaviour($case); + + $this->em->persist($article); + $this->em->flush(); + $this->em->clear(); + + $loaded = $this->em->find(TestArticle::class, $article->getId()); + + self::assertSame($case, $loaded->getRobotsBehaviour(), sprintf('Round-trip failed for %s', $case->name)); + } + } + + public function testGetMetaAfterPersistAndReload(): void + { + $article = (new TestArticle()) + ->setTitle('Page Title') + ->setMetaTitle('Meta Title') + ->setMetaDescription('Bold text') + ->setMetaKeywords('keyword1, keyword2') + ->setMetaImagePath('/img/social.png'); + + $this->em->persist($article); + $this->em->flush(); + $this->em->clear(); + + $loaded = $this->em->find(TestArticle::class, $article->getId()); + $meta = $loaded->getMeta(); + + self::assertSame('Page Title', $meta['pageTitle']); + self::assertSame('Meta Title', $meta['title']); + self::assertSame('/img/social.png', $meta['image']); + self::assertSame('Bold text', $meta['description']); + self::assertSame('keyword1, keyword2', $meta['keywords']); + } + + public function testUpdateEntity(): void + { + $article = (new TestArticle()) + ->setTitle('Original') + ->setRobotsBehaviour(RobotsBehaviour::IndexFollow); + + $this->em->persist($article); + $this->em->flush(); + + $article->setTitle('Updated'); + $article->setRobotsBehaviour(RobotsBehaviour::NoIndexFollow); + + $this->em->flush(); + $this->em->clear(); + + $loaded = $this->em->find(TestArticle::class, $article->getId()); + + self::assertSame('Updated', $loaded->getTitle()); + self::assertSame(RobotsBehaviour::NoIndexFollow, $loaded->getRobotsBehaviour()); + } + + public function testFormattedRobotsBehaviourAfterReload(): void + { + $article = (new TestArticle()) + ->setRobotsBehaviour(RobotsBehaviour::NoIndexNoFollow); + + $this->em->persist($article); + $this->em->flush(); + $this->em->clear(); + + $loaded = $this->em->find(TestArticle::class, $article->getId()); + + self::assertSame('noindex, nofollow', $loaded->getFormattedRobotsBehaviour()); + } + + public function testNullFieldsPersistCorrectly(): void + { + $article = (new TestArticle()) + ->setTitle('Title') + ->setMetaTitle(null) + ->setMetaDescription(null) + ->setMetaKeywords(null) + ->setMetaImagePath(null); + + $this->em->persist($article); + $this->em->flush(); + $this->em->clear(); + + $loaded = $this->em->find(TestArticle::class, $article->getId()); + + self::assertSame('Title', $loaded->getTitle()); + self::assertNull($loaded->getMetaTitle()); + self::assertNull($loaded->getMetaDescription()); + self::assertNull($loaded->getMetaKeywords()); + self::assertNull($loaded->getMetaImagePath()); + } +} diff --git a/tests/Integrational/TestKernel.php b/tests/Integrational/TestKernel.php new file mode 100644 index 0000000..38512f7 --- /dev/null +++ b/tests/Integrational/TestKernel.php @@ -0,0 +1,72 @@ +extension('framework', [ + 'secret' => 'test_secret', + 'test' => true, + ]); + + $dbalConfig = isset($_ENV['DATABASE_URL']) + ? ['url' => $_ENV['DATABASE_URL'], 'server_version' => '17'] + : [ + 'driver' => 'pdo_pgsql', + 'host' => '/var/run/postgresql', + 'user' => get_current_user(), + 'dbname' => 'meta_bundle_test', + 'server_version' => '17', + ]; + + $container->extension('doctrine', [ + 'dbal' => $dbalConfig, + 'orm' => [ + 'entity_managers' => [ + 'default' => [ + 'mappings' => [ + 'Tests' => [ + 'type' => 'attribute', + 'dir' => '%kernel.project_dir%/tests/Integrational/Entity', + 'prefix' => 'Tests\\Integrational\\Entity', + 'alias' => 'Tests', + ], + ], + ], + ], + ], + ]); + + $container->services() + ->alias(EntityManagerInterface::class, 'doctrine.orm.entity_manager') + ->public(); + } + + public function getProjectDir(): string + { + return \dirname(__DIR__, 2); + } +} diff --git a/tests/Unit/ChamberOrchestraMetaBundleTest.php b/tests/Unit/ChamberOrchestraMetaBundleTest.php new file mode 100644 index 0000000..a61fbd7 --- /dev/null +++ b/tests/Unit/ChamberOrchestraMetaBundleTest.php @@ -0,0 +1,34 @@ +getContainerExtension()); + } + + public function testBundleName(): void + { + $bundle = new ChamberOrchestraMetaBundle(); + + self::assertSame('ChamberOrchestraMetaBundle', $bundle->getName()); + } +} diff --git a/tests/Unit/DependencyInjection/ChamberOrchestraMetaExtensionTest.php b/tests/Unit/DependencyInjection/ChamberOrchestraMetaExtensionTest.php new file mode 100644 index 0000000..289f699 --- /dev/null +++ b/tests/Unit/DependencyInjection/ChamberOrchestraMetaExtensionTest.php @@ -0,0 +1,34 @@ +load([], $container); + + $serviceIds = array_filter( + $container->getServiceIds(), + static fn(string $id): bool => str_starts_with($id, 'ChamberOrchestra\\MetaBundle\\'), + ); + + self::assertSame([], $serviceIds); + } + + public function testExtensionAlias(): void + { + $extension = new ChamberOrchestraMetaExtension(); + + self::assertSame('chamber_orchestra_meta', $extension->getAlias()); + } +} diff --git a/tests/Unit/Entity/Helper/RobotsBehaviourTest.php b/tests/Unit/Entity/Helper/RobotsBehaviourTest.php index 784ecb5..0779b2d 100644 --- a/tests/Unit/Entity/Helper/RobotsBehaviourTest.php +++ b/tests/Unit/Entity/Helper/RobotsBehaviourTest.php @@ -5,59 +5,23 @@ namespace Tests\Unit\Entity\Helper; use ChamberOrchestra\MetaBundle\Entity\Helper\RobotsBehaviour; -use ChamberOrchestra\MetaBundle\Exception\OutOfBoundsException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; final class RobotsBehaviourTest extends TestCase { - public function testChoicesReturnsAllCases(): void + #[DataProvider('formatProvider')] + public function testFormat(RobotsBehaviour $case, string $expected): void { - $choices = RobotsBehaviour::choices(); - - self::assertCount(4, $choices); - self::assertSame(0, $choices['robots_behaviour.indexfollow']); - self::assertSame(1, $choices['robots_behaviour.indexnofollow']); - self::assertSame(2, $choices['robots_behaviour.noindexfollow']); - self::assertSame(3, $choices['robots_behaviour.noindexnofollow']); - } - - public function testChoicesKeysAreTranslationKeys(): void - { - $choices = RobotsBehaviour::choices(); - - foreach (array_keys($choices) as $key) { - self::assertStringStartsWith('robots_behaviour.', $key); - } - } - - #[DataProvider('formattedBehaviourProvider')] - public function testGetFormattedBehaviour(int $value, string $expected): void - { - self::assertSame($expected, RobotsBehaviour::getFormattedBehaviour($value)); + self::assertSame($expected, $case->format()); } - public static function formattedBehaviourProvider(): iterable + public static function formatProvider(): iterable { - yield 'IndexFollow' => [0, 'index, follow']; - yield 'IndexNoFollow' => [1, 'index, nofollow']; - yield 'NoIndexFollow' => [2, 'noindex, follow']; - yield 'NoIndexNoFollow' => [3, 'noindex, nofollow']; - } - - public function testGetFormattedBehaviourThrowsOnInvalidValue(): void - { - $this->expectException(OutOfBoundsException::class); - $this->expectExceptionMessage("No behaviour with type '99'"); - - RobotsBehaviour::getFormattedBehaviour(99); - } - - public function testGetFormattedBehaviourThrowsOnNegativeValue(): void - { - $this->expectException(OutOfBoundsException::class); - - RobotsBehaviour::getFormattedBehaviour(-1); + yield 'IndexFollow' => [RobotsBehaviour::IndexFollow, 'index, follow']; + yield 'IndexNoFollow' => [RobotsBehaviour::IndexNoFollow, 'index, nofollow']; + yield 'NoIndexFollow' => [RobotsBehaviour::NoIndexFollow, 'noindex, follow']; + yield 'NoIndexNoFollow' => [RobotsBehaviour::NoIndexNoFollow, 'noindex, nofollow']; } public function testEnumCasesHaveCorrectValues(): void diff --git a/tests/Unit/Entity/MetaMappingTest.php b/tests/Unit/Entity/MetaMappingTest.php new file mode 100644 index 0000000..559708f --- /dev/null +++ b/tests/Unit/Entity/MetaMappingTest.php @@ -0,0 +1,104 @@ +id; + } +} + +final class MetaMappingTest extends TestCase +{ + private ClassMetadata $metadata; + + protected function setUp(): void + { + $driver = new AttributeDriver([__DIR__]); + $metadata = new ClassMetadata(MetaMappingTestEntity::class); + $metadata->initializeReflection(new \Doctrine\Persistence\Mapping\RuntimeReflectionService()); + $driver->loadMetadataForClass(MetaMappingTestEntity::class, $metadata); + $this->metadata = $metadata; + } + + public function testTitleColumnMapping(): void + { + $mapping = $this->metadata->fieldMappings['title']; + + self::assertSame(Types::STRING, $mapping->type); + self::assertSame(255, $mapping->length); + self::assertTrue($mapping->nullable); + } + + public function testMetaTitleColumnMapping(): void + { + $mapping = $this->metadata->fieldMappings['metaTitle']; + + self::assertSame(Types::STRING, $mapping->type); + self::assertSame(255, $mapping->length); + self::assertTrue($mapping->nullable); + } + + public function testMetaImagePathColumnMapping(): void + { + $mapping = $this->metadata->fieldMappings['metaImagePath']; + + self::assertSame(Types::STRING, $mapping->type); + self::assertSame(255, $mapping->length); + self::assertTrue($mapping->nullable); + } + + public function testMetaDescriptionColumnMapping(): void + { + $mapping = $this->metadata->fieldMappings['metaDescription']; + + self::assertSame(Types::TEXT, $mapping->type); + self::assertTrue($mapping->nullable); + } + + public function testMetaKeywordsColumnMapping(): void + { + $mapping = $this->metadata->fieldMappings['metaKeywords']; + + self::assertSame(Types::STRING, $mapping->type); + self::assertSame(255, $mapping->length); + self::assertTrue($mapping->nullable); + } + + public function testRobotsBehaviourColumnMapping(): void + { + $mapping = $this->metadata->fieldMappings['robotsBehaviour']; + + self::assertSame(Types::SMALLINT, $mapping->type); + self::assertFalse($mapping->nullable); + self::assertSame(RobotsBehaviour::class, $mapping->enumType); + } + + public function testAllExpectedFieldsAreMapped(): void + { + $expectedFields = ['id', 'title', 'metaTitle', 'metaImagePath', 'metaDescription', 'metaKeywords', 'robotsBehaviour']; + + self::assertSame($expectedFields, array_keys($this->metadata->fieldMappings)); + } +} diff --git a/tests/Unit/Entity/MetaTraitTest.php b/tests/Unit/Entity/MetaTraitTest.php index eae6967..ef23d6b 100644 --- a/tests/Unit/Entity/MetaTraitTest.php +++ b/tests/Unit/Entity/MetaTraitTest.php @@ -7,7 +7,9 @@ use ChamberOrchestra\MetaBundle\Entity\Helper\RobotsBehaviour; use ChamberOrchestra\MetaBundle\Entity\MetaInterface; use ChamberOrchestra\MetaBundle\Entity\MetaTrait; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\File\File; final class MetaTraitTest extends TestCase { @@ -36,7 +38,7 @@ public function setMetaKeywords(?string $metaKeywords): void $this->metaKeywords = $metaKeywords; } - public function setRobotsBehaviour(int $robotsBehaviour): void + public function setRobotsBehaviour(RobotsBehaviour $robotsBehaviour): void { $this->robotsBehaviour = $robotsBehaviour; } @@ -45,9 +47,19 @@ public function setMetaImagePath(?string $path): void { $this->metaImagePath = $path; } + + public function setMetaImage(?File $file): void + { + $this->metaImage = $file; + } }; } + public function testEntityImplementsMetaInterface(): void + { + self::assertInstanceOf(MetaInterface::class, $this->createEntity()); + } + public function testDefaultValues(): void { $entity = $this->createEntity(); @@ -58,7 +70,7 @@ public function testDefaultValues(): void self::assertNull($entity->getMetaKeywords()); self::assertNull($entity->getMetaImage()); self::assertNull($entity->getMetaImagePath()); - self::assertSame(RobotsBehaviour::IndexNoFollow->value, $entity->getRobotsBehaviour()); + self::assertSame(RobotsBehaviour::IndexNoFollow, $entity->getRobotsBehaviour()); } public function testGettersReturnSetValues(): void @@ -75,19 +87,21 @@ public function testGettersReturnSetValues(): void self::assertSame('key1, key2', $entity->getMetaKeywords()); } - public function testGetFormattedRobotsBehaviourDefault(): void + #[DataProvider('robotsBehaviourFormatProvider')] + public function testGetFormattedRobotsBehaviour(RobotsBehaviour $case, string $expected): void { $entity = $this->createEntity(); + $entity->setRobotsBehaviour($case); - self::assertSame('index, nofollow', $entity->getFormattedRobotsBehaviour()); + self::assertSame($expected, $entity->getFormattedRobotsBehaviour()); } - public function testGetFormattedRobotsBehaviourCustom(): void + public static function robotsBehaviourFormatProvider(): iterable { - $entity = $this->createEntity(); - $entity->setRobotsBehaviour(RobotsBehaviour::NoIndexNoFollow->value); - - self::assertSame('noindex, nofollow', $entity->getFormattedRobotsBehaviour()); + yield 'IndexFollow' => [RobotsBehaviour::IndexFollow, 'index, follow']; + yield 'IndexNoFollow' => [RobotsBehaviour::IndexNoFollow, 'index, nofollow']; + yield 'NoIndexFollow' => [RobotsBehaviour::NoIndexFollow, 'noindex, follow']; + yield 'NoIndexNoFollow' => [RobotsBehaviour::NoIndexNoFollow, 'noindex, nofollow']; } public function testGetMetaWithAllValues(): void @@ -121,14 +135,38 @@ public function testGetMetaWithNullValues(): void self::assertNull($meta['keywords']); } + public function testGetMetaDoesNotContainRobotsKey(): void + { + $entity = $this->createEntity(); + + self::assertArrayNotHasKey('robots', $entity->getMeta()); + } + public function testGetMetaStripsHtmlFromDescription(): void { $entity = $this->createEntity(); $entity->setMetaDescription('

Hello world

'); - $meta = $entity->getMeta(); + self::assertSame('Hello world', $entity->getMeta()['description']); + } + + public function testGetMetaPreservesHtmlEntitiesInDescription(): void + { + $entity = $this->createEntity(); + $entity->setMetaDescription('Hello & world'); - self::assertSame('Hello world', $meta['description']); + self::assertSame('Hello & world', $entity->getMeta()['description']); + } + + public function testGetMetaStripsScriptTags(): void + { + $entity = $this->createEntity(); + $entity->setMetaDescription('Description'); + + $description = $entity->getMeta()['description']; + + self::assertStringNotContainsString('