- Add comprehensive test suite for CMS and Asset modules using Pest Framework - Implement ContentTypeService::delete() protection against deletion of in-use content types - Add CannotDeleteContentTypeInUseException for better error handling - Fix DerivatPipelineRegistry::getAllPipelines() to handle object uniqueness correctly - Fix VariantName::getScale() to correctly parse scales with file extensions - Update CMS module documentation with new features, exceptions, and test coverage - Add CmsTestHelpers and AssetTestHelpers for test data factories - Fix BlockTypeRegistry to be immutable after construction - Update ContentTypeService to check for associated content before deletion - Improve BlockRendererRegistry initialization Test coverage: - Value Objects: All CMS and Asset value objects - Services: ContentService, ContentTypeService, SlugGenerator, BlockValidator, ContentLocalizationService, AssetService, DeduplicationService, MetadataExtractor - Repositories: All database repositories with mocked connections - Rendering: Block renderers and ContentRenderer - Controllers: API endpoints for both modules 254 tests passing, 38 remaining (mostly image processing pipeline tests)
363 lines
10 KiB
Markdown
363 lines
10 KiB
Markdown
# Headless CMS Modul
|
|
|
|
Ein Block-basiertes Headless CMS System für das Custom PHP Framework.
|
|
|
|
## Übersicht
|
|
|
|
Das CMS Modul ermöglicht die Verwaltung von Content durch ein flexibles Block-System, ähnlich WordPress Blocks, Storyblok oder Contentful. Content besteht aus einer geordneten Liste von Content-Blöcken, die als JSON in der Datenbank gespeichert werden.
|
|
|
|
## Architektur
|
|
|
|
### Block-basiertes System
|
|
|
|
Content besteht aus einer **geordneten Liste von Content-Blöcken**. Jeder Block hat:
|
|
- **Block Type**: HERO, TEXT, IMAGE, GALLERY, CTA, VIDEO, etc.
|
|
- **Block Data**: Typed Value Object mit block-spezifischen Daten
|
|
- **Block ID**: Eindeutige ID innerhalb des Contents
|
|
- **Block Settings**: Optionale Konfiguration (Styling, Visibility)
|
|
|
|
### Beispiel: Landing Page
|
|
|
|
```php
|
|
use App\Domain\Cms\Entities\Content;
|
|
use App\Domain\Cms\Enums\BlockType;
|
|
use App\Domain\Cms\ValueObjects\*;
|
|
|
|
$content = Content::create(
|
|
clock: $clock,
|
|
contentTypeId: ContentTypeId::fromString('landing_page'),
|
|
slug: ContentSlug::fromString('homepage'),
|
|
title: 'Welcome to Our Product',
|
|
blocks: ContentBlocks::fromArray([
|
|
ContentBlock::create(
|
|
blockType: BlockType::HERO,
|
|
blockId: 'hero-1',
|
|
data: BlockData::fromArray([
|
|
'title' => 'Revolutionary Product',
|
|
'subtitle' => 'Transform your workflow',
|
|
'backgroundImage' => 'img-123', // MediaId als String
|
|
'ctaText' => 'Get Started',
|
|
'ctaLink' => '/signup'
|
|
])
|
|
),
|
|
ContentBlock::create(
|
|
blockType: BlockType::TEXT,
|
|
blockId: 'text-1',
|
|
data: BlockData::fromArray([
|
|
'content' => '<p>Our product is...</p>',
|
|
'alignment' => 'center'
|
|
])
|
|
)
|
|
])
|
|
);
|
|
```
|
|
|
|
## Datenbank-Schema
|
|
|
|
### content_types
|
|
|
|
Speichert Content Type Definitionen:
|
|
- `id` (VARCHAR) - Content Type Identifier
|
|
- `name` (VARCHAR) - Display Name
|
|
- `slug` (VARCHAR) - URL-freundlicher Slug
|
|
- `description` (TEXT) - Beschreibung
|
|
- `is_system` (BOOLEAN) - System Content Type
|
|
|
|
### contents
|
|
|
|
Speichert Content Instanzen:
|
|
- `id` (VARCHAR) - ULID Identifier
|
|
- `content_type_id` (VARCHAR) - Foreign Key zu content_types
|
|
- `slug` (VARCHAR) - URL-freundlicher Slug (unique)
|
|
- `title` (VARCHAR) - Content Titel
|
|
- `blocks` (JSON) - Array von Block-Objekten
|
|
- `meta_data` (JSON) - SEO, Open Graph Metadaten
|
|
- `status` (ENUM) - draft, published, archived
|
|
- `author_id` (VARCHAR) - Author User ID
|
|
- `published_at` (TIMESTAMP) - Veröffentlichungsdatum
|
|
- `created_at`, `updated_at` (TIMESTAMP)
|
|
|
|
## Verwendung
|
|
|
|
### ContentService
|
|
|
|
```php
|
|
use App\Domain\Cms\Services\ContentService;
|
|
use App\Domain\Cms\ValueObjects\*;
|
|
|
|
// Content erstellen
|
|
$content = $contentService->create(
|
|
contentTypeId: ContentTypeId::fromString('landing_page'),
|
|
title: 'Welcome Page',
|
|
blocks: ContentBlocks::fromArray([...]),
|
|
slug: ContentSlug::fromString('homepage')
|
|
);
|
|
|
|
// Content finden
|
|
$content = $contentService->findBySlug(ContentSlug::fromString('homepage'));
|
|
|
|
// Content veröffentlichen
|
|
$published = $contentService->publish($content->id);
|
|
|
|
// Content aktualisieren
|
|
$updated = $contentService->updateBlocks(
|
|
$content->id,
|
|
ContentBlocks::fromArray([...])
|
|
);
|
|
```
|
|
|
|
### ContentTypeService
|
|
|
|
```php
|
|
use App\Domain\Cms\Services\ContentTypeService;
|
|
use App\Domain\Cms\ValueObjects\ContentTypeId;
|
|
|
|
// Content Type erstellen
|
|
$contentType = $contentTypeService->create(
|
|
name: 'Landing Page',
|
|
slug: 'landing_page',
|
|
description: 'Landing pages for marketing campaigns',
|
|
isSystem: false
|
|
);
|
|
|
|
// Content Type aktualisieren
|
|
$updated = $contentTypeService->update(
|
|
id: ContentTypeId::fromString('landing_page'),
|
|
name: 'Updated Name',
|
|
description: 'New description'
|
|
);
|
|
|
|
// Content Type löschen
|
|
// Wirft CannotDeleteContentTypeInUseException wenn noch Content existiert
|
|
// Wirft CannotDeleteSystemContentTypeException für System-Types
|
|
try {
|
|
$contentTypeService->delete(ContentTypeId::fromString('landing_page'));
|
|
} catch (CannotDeleteContentTypeInUseException $e) {
|
|
// Content Type wird noch von Content verwendet
|
|
}
|
|
```
|
|
|
|
## REST API
|
|
|
|
### Endpoints
|
|
|
|
- `GET /api/v1/cms/contents` - Liste aller Contents
|
|
- `GET /api/v1/cms/contents/{id}` - Content Details
|
|
- `POST /api/v1/cms/contents` - Content erstellen
|
|
- `PUT /api/v1/cms/contents/{id}` - Content aktualisieren
|
|
- `DELETE /api/v1/cms/contents/{id}` - Content löschen
|
|
- `POST /api/v1/cms/contents/{id}/publish` - Content veröffentlichen
|
|
- `POST /api/v1/cms/contents/{id}/unpublish` - Content zurückziehen
|
|
|
|
### Beispiel Request
|
|
|
|
```json
|
|
POST /api/v1/cms/contents
|
|
{
|
|
"content_type_id": "landing_page",
|
|
"slug": "homepage",
|
|
"title": "Welcome Page",
|
|
"blocks": [
|
|
{
|
|
"id": "hero-1",
|
|
"type": "hero",
|
|
"data": {
|
|
"title": "Revolutionary Product",
|
|
"subtitle": "Transform your workflow",
|
|
"backgroundImage": "img-123",
|
|
"ctaText": "Get Started",
|
|
"ctaLink": "/signup"
|
|
},
|
|
"settings": {
|
|
"fullWidth": true
|
|
}
|
|
}
|
|
],
|
|
"status": "draft"
|
|
}
|
|
```
|
|
|
|
## Block Types
|
|
|
|
- `HERO` - Hero Section mit Titel, Bild, CTA
|
|
- `TEXT` - Rich Text Block
|
|
- `IMAGE` - Einzelnes Bild mit Caption
|
|
- `GALLERY` - Bildergalerie
|
|
- `CTA` - Call-to-Action Block
|
|
- `VIDEO` - Video Embed
|
|
- `FORM` - Formular Integration
|
|
- `COLUMNS` - Multi-Column Layout
|
|
- `QUOTE` - Zitat Block
|
|
- `SEPARATOR` - Trenner/Divider
|
|
|
|
## Integration mit anderen Domains
|
|
|
|
### Media Domain
|
|
|
|
Block Data kann MediaId als String enthalten:
|
|
```php
|
|
BlockData::fromArray([
|
|
'imageId' => 'img-123', // MediaId als String
|
|
'caption' => 'Product Screenshot'
|
|
])
|
|
```
|
|
|
|
### Meta Domain
|
|
|
|
SEO-Metadaten können in `meta_data` gespeichert werden:
|
|
```php
|
|
$content = $content->withMetaData(BlockData::fromArray([
|
|
'title' => 'SEO Title',
|
|
'description' => 'SEO Description',
|
|
'og_title' => 'OG Title',
|
|
'og_image' => 'og-image.jpg'
|
|
]));
|
|
```
|
|
|
|
## Migration
|
|
|
|
```bash
|
|
php console.php db:migrate
|
|
```
|
|
|
|
Die Migrations erstellen die Tabellen `content_types` und `contents` mit JSON-Spalten für Blocks.
|
|
|
|
## Exceptions
|
|
|
|
Das CMS Modul definiert folgende Domain-Exceptions:
|
|
|
|
### Content Exceptions
|
|
|
|
- **`ContentNotFoundException`** - Wird geworfen wenn Content nicht gefunden wird
|
|
```php
|
|
throw ContentNotFoundException::forId($contentId);
|
|
throw ContentNotFoundException::forSlug($slug);
|
|
```
|
|
|
|
- **`DuplicateSlugException`** - Wird geworfen wenn ein Slug bereits existiert
|
|
```php
|
|
throw DuplicateSlugException::forSlug($slug);
|
|
```
|
|
|
|
- **`InvalidContentStatusException`** - Wird geworfen bei ungültigen Status-Übergängen
|
|
```php
|
|
throw InvalidContentStatusException::invalidTransition($currentStatus, $targetStatus);
|
|
```
|
|
|
|
- **`InvalidBlockException`** - Wird geworfen bei ungültigen Block-Daten
|
|
```php
|
|
throw InvalidBlockException::missingRequiredField($blockId, $field);
|
|
throw InvalidBlockException::invalidFieldType($blockId, $field, $expectedType);
|
|
```
|
|
|
|
### ContentType Exceptions
|
|
|
|
- **`ContentTypeNotFoundException`** - Wird geworfen wenn ContentType nicht gefunden wird
|
|
```php
|
|
throw ContentTypeNotFoundException::forId($id);
|
|
```
|
|
|
|
- **`DuplicateContentTypeSlugException`** - Wird geworfen wenn ein ContentType-Slug bereits existiert
|
|
```php
|
|
throw DuplicateContentTypeSlugException::forSlug($slug);
|
|
```
|
|
|
|
- **`CannotDeleteSystemContentTypeException`** - Wird geworfen beim Versuch, einen System-ContentType zu löschen
|
|
```php
|
|
throw CannotDeleteSystemContentTypeException::forId($id);
|
|
```
|
|
|
|
- **`CannotDeleteContentTypeInUseException`** - Wird geworfen beim Versuch, einen ContentType zu löschen, der noch von Content verwendet wird
|
|
```php
|
|
throw CannotDeleteContentTypeInUseException::forId($id, $contentCount);
|
|
```
|
|
|
|
## ContentTypeService::delete() - Schutz vor versehentlichem Löschen
|
|
|
|
Die `ContentTypeService::delete()` Methode implementiert eine Schutzfunktion, die verhindert, dass ContentTypes gelöscht werden, die noch von Content-Instanzen verwendet werden:
|
|
|
|
```php
|
|
public function delete(ContentTypeId $id): void
|
|
{
|
|
$contentType = $this->contentTypeRepository->findById($id);
|
|
if ($contentType === null) {
|
|
throw ContentTypeNotFoundException::forId($id);
|
|
}
|
|
|
|
// System ContentTypes können nicht gelöscht werden
|
|
if ($contentType->isSystem) {
|
|
throw CannotDeleteSystemContentTypeException::forId($id);
|
|
}
|
|
|
|
// Prüfe ob ContentType noch von Content verwendet wird
|
|
$contents = $this->contentRepository->findByType($id);
|
|
if (count($contents) > 0) {
|
|
throw CannotDeleteContentTypeInUseException::forId($id, count($contents));
|
|
}
|
|
|
|
$this->contentTypeRepository->delete($id);
|
|
}
|
|
```
|
|
|
|
Dies verhindert Datenverlust und stellt sicher, dass keine verwaisten Content-Instanzen entstehen.
|
|
|
|
## Tests
|
|
|
|
Das CMS Modul verfügt über eine umfassende Test-Suite mit Pest Framework:
|
|
|
|
### Test-Struktur
|
|
|
|
```
|
|
tests/
|
|
├── Unit/
|
|
│ └── Domain/
|
|
│ └── Cms/
|
|
│ ├── ValueObjects/ # Value Object Tests
|
|
│ ├── Services/ # Service Tests
|
|
│ ├── Repositories/ # Repository Tests
|
|
│ └── Rendering/ # Rendering Tests
|
|
├── Feature/
|
|
│ └── Application/
|
|
│ └── Cms/ # Controller Tests
|
|
└── Support/
|
|
└── CmsTestHelpers.php # Test Helper Functions
|
|
```
|
|
|
|
### Test-Abdeckung
|
|
|
|
- **Value Objects**: Alle Value Objects (ContentId, ContentSlug, BlockType, etc.)
|
|
- **Services**: ContentService, ContentTypeService, SlugGenerator, BlockValidator, ContentLocalizationService
|
|
- **Repositories**: DatabaseContentRepository, DatabaseContentTypeRepository, DatabaseContentTranslationRepository
|
|
- **Rendering**: BlockRendererRegistry, HeroBlockRenderer, TextBlockRenderer, ImageBlockRenderer, DefaultBlockRenderer, ContentRenderer
|
|
- **Controllers**: ContentsController, ContentTypesController
|
|
|
|
### Tests ausführen
|
|
|
|
```bash
|
|
# Alle CMS Tests
|
|
./vendor/bin/pest tests/Unit/Domain/Cms tests/Feature/Application/Cms
|
|
|
|
# Spezifische Test-Datei
|
|
./vendor/bin/pest tests/Unit/Domain/Cms/Services/ContentServiceTest.php
|
|
```
|
|
|
|
### Test Helpers
|
|
|
|
Die `CmsTestHelpers` Klasse bietet Factory-Methoden für Test-Daten:
|
|
|
|
```php
|
|
use Tests\Support\CmsTestHelpers;
|
|
|
|
$content = CmsTestHelpers::createContent($clock, [
|
|
'title' => 'Test Content',
|
|
'blocks' => $blocks,
|
|
'slug' => ContentSlug::fromString('test-slug')
|
|
]);
|
|
|
|
$contentType = CmsTestHelpers::createContentType([
|
|
'name' => 'Test Type',
|
|
'slug' => 'test_type'
|
|
]);
|
|
```
|
|
|