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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/php.yml
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions .github/workflows/tag.yml
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/vendor/
/var/
composer.lock
.claude/settings.local.json
.php-cs-fixer.cache
.phpunit.cache/
31 changes: 31 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 49 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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] ...`
11 changes: 11 additions & 0 deletions ChamberOrchestraMetaBundle.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace ChamberOrchestra\MetaBundle;

use Symfony\Component\HttpKernel\Bundle\Bundle;

class ChamberOrchestraMetaBundle extends Bundle
{
}
19 changes: 19 additions & 0 deletions Cms/Form/Dto/MetaDto.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

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 ?RobotsBehaviour $robotsBehaviour = null;
public ?string $metaTitle = null;
public ?string $metaDescription = null;
public ?string $metaKeywords = null;
public ?File $metaImage = null;
}
21 changes: 21 additions & 0 deletions Cms/Form/Dto/MetaTranslatableDto.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace ChamberOrchestra\MetaBundle\Cms\Form\Dto;

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()
{
$this->translations = new DtoCollection(MetaDto::class);
parent::__construct(MetaTranslatableType::class);
}
}
30 changes: 30 additions & 0 deletions Cms/Form/Type/MetaTranslatableType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace ChamberOrchestra\MetaBundle\Cms\Form\Type;

use ChamberOrchestra\MetaBundle\Cms\Form\Dto\MetaTranslatableDto;
use ChamberOrchestra\TranslationBundle\Cms\Form\Type\TranslationsType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class MetaTranslatableType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->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,
]);
}
}
86 changes: 86 additions & 0 deletions Cms/Form/Type/MetaType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

namespace ChamberOrchestra\MetaBundle\Cms\Form\Type;

use ChamberOrchestra\FileBundle\Cms\Form\Type\ImageType;
use ChamberOrchestra\MetaBundle\Cms\Form\Dto\MetaDto;
use ChamberOrchestra\MetaBundle\Entity\Helper\RobotsBehaviour;
use Symfony\Component\Form\AbstractType;
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;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Image;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;

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([
'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),
],
]);
}
}
15 changes: 15 additions & 0 deletions DependencyInjection/ChamberOrchestraMetaExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace ChamberOrchestra\MetaBundle\DependencyInjection;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;

class ChamberOrchestraMetaExtension extends Extension
{
public function load(array $configs, ContainerBuilder $container): void
{
}
}
23 changes: 23 additions & 0 deletions Entity/Helper/RobotsBehaviour.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace ChamberOrchestra\MetaBundle\Entity\Helper;

enum RobotsBehaviour: int
{
case IndexFollow = 0;
case IndexNoFollow = 1;
case NoIndexFollow = 2;
case NoIndexNoFollow = 3;

public function format(): string
{
return match ($this) {
self::IndexFollow => 'index, follow',
self::IndexNoFollow => 'index, nofollow',
self::NoIndexFollow => 'noindex, follow',
self::NoIndexNoFollow => 'noindex, nofollow',
};
}
}
Loading
Loading