diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..512030b --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,44 @@ +name: PHP Composer + +on: + push: + branches: [ "master", "8.0" ] + pull_request: + branches: [ "master", "8.0" ] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP 8.5 + id: setup-php + 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-${{ steps.setup-php.outputs.php-version }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ steps.setup-php.outputs.php-version }}-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..f3a5b49 --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,46 @@ +name: Tag Release + +on: + pull_request: + types: [closed] + branches: [master, "8.0"] + +permissions: + contents: write + +jobs: + tag: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + 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: | + 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 new file mode 100644 index 0000000..bbd4d2b --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/vendor/ +/var/ +composer.lock +.claude/settings.local.json +.php-cs-fixer.cache +.phpunit.cache/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..73da4fe --- /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 `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. + +## 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..94071c0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,49 @@ +# 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, `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 +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 + +- `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 using Symfony `EnumType` for robots behaviour (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(MetaTranslatableType::class); + } +} 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..15c58d3 --- /dev/null +++ b/Cms/Form/Type/MetaType.php @@ -0,0 +1,86 @@ +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' => self::MAX_STRING_LENGTH], + 'constraints' => [ + new NotBlank(), + new Length(max: self::MAX_STRING_LENGTH), + ], + ]) + ->add('robotsBehaviour', EnumType::class, [ + 'class' => RobotsBehaviour::class, + 'required' => true, + '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' => self::MAX_STRING_LENGTH], + 'constraints' => [ + new Length(max: self::MAX_STRING_LENGTH), + ], + ]) + ->add('metaDescription', TextareaType::class, [ + 'required' => false, + 'attr' => ['data-maxlength' => self::MAX_DESCRIPTION_LENGTH, 'rows' => 3], + 'constraints' => [ + new Length(max: self::MAX_DESCRIPTION_LENGTH), + ], + ]) + ->add('metaKeywords', TextType::class, [ + 'required' => false, + 'attr' => ['maxlength' => self::MAX_STRING_LENGTH], + 'constraints' => [ + new Length(max: self::MAX_STRING_LENGTH), + ], + ]); + } +} diff --git a/DependencyInjection/ChamberOrchestraMetaExtension.php b/DependencyInjection/ChamberOrchestraMetaExtension.php new file mode 100644 index 0000000..a8cd2db --- /dev/null +++ b/DependencyInjection/ChamberOrchestraMetaExtension.php @@ -0,0 +1,15 @@ + '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..1919baa --- /dev/null +++ b/Entity/MetaInterface.php @@ -0,0 +1,30 @@ +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(): RobotsBehaviour + { + return $this->robotsBehaviour; + } + + public function getMetaImage(): ?File + { + return $this->metaImage; + } + + public function getMetaImagePath(): ?string + { + return $this->metaImagePath; + } + + public function getFormattedRobotsBehaviour(): string + { + return $this->robotsBehaviour->format(); + } + + public function getMeta(): array + { + return [ + 'pageTitle' => $this->title, + 'title' => $this->metaTitle, + 'image' => $this->metaImagePath, + 'description' => null !== $this->metaDescription ? \strip_tags($this->metaDescription) : null, + 'keywords' => $this->metaKeywords, + ]; + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index b2822d2..a84803e 100644 --- a/README.md +++ b/README.md @@ -1 +1,136 @@ -# meta-bundle +# ChamberOrchestra Meta Bundle + +Reusable SEO meta data layer for Symfony 8 and Doctrine ORM 3. Provides a trait-based approach to add meta title, description, keywords, Open Graph image, and robots behaviour to any Doctrine entity. Includes optional CMS admin form types with translatable (i18n) support. + +## Features + +- **`MetaInterface`** and **`MetaTrait`** — mix SEO fields into any Doctrine entity with zero boilerplate +- **`RobotsBehaviour`** — int-backed PHP enum with `format()` for robots meta tag output (`index, follow`, `noindex, nofollow`, etc.) +- **`getMeta()`** — returns a clean associative array ready for `
` 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 | `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..5baa458 --- /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..0cb5a6f --- /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 new file mode 100644 index 0000000..0779b2d --- /dev/null +++ b/tests/Unit/Entity/Helper/RobotsBehaviourTest.php @@ -0,0 +1,45 @@ +format()); + } + + public static function formatProvider(): iterable + { + 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 + { + 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/MetaMappingTest.php b/tests/Unit/Entity/MetaMappingTest.php new file mode 100644 index 0000000..715b2b4 --- /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 new file mode 100644 index 0000000..ef23d6b --- /dev/null +++ b/tests/Unit/Entity/MetaTraitTest.php @@ -0,0 +1,199 @@ +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(RobotsBehaviour $robotsBehaviour): void + { + $this->robotsBehaviour = $robotsBehaviour; + } + + 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(); + + 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, $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()); + } + + #[DataProvider('robotsBehaviourFormatProvider')] + public function testGetFormattedRobotsBehaviour(RobotsBehaviour $case, string $expected): void + { + $entity = $this->createEntity(); + $entity->setRobotsBehaviour($case); + + self::assertSame($expected, $entity->getFormattedRobotsBehaviour()); + } + + public static function robotsBehaviourFormatProvider(): iterable + { + 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 + { + $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 testGetMetaDoesNotContainRobotsKey(): void + { + $entity = $this->createEntity(); + + self::assertArrayNotHasKey('robots', $entity->getMeta()); + } + + public function testGetMetaStripsHtmlFromDescription(): void + { + $entity = $this->createEntity(); + $entity->setMetaDescription('Hello world
'); + + self::assertSame('Hello world', $entity->getMeta()['description']); + } + + public function testGetMetaPreservesHtmlEntitiesInDescription(): void + { + $entity = $this->createEntity(); + $entity->setMetaDescription('Hello & world'); + + self::assertSame('Hello & world', $entity->getMeta()['description']); + } + + public function testGetMetaStripsScriptTags(): void + { + $entity = $this->createEntity(); + $entity->setMetaDescription('Description'); + + $description = $entity->getMeta()['description']; + + self::assertStringNotContainsString('