# Page Builder

How page sections work, and how to add a new block type. Implements MP §4. Decisions: ADR-006, ADR-028 to ADR-031.

## 1. Moving parts

| Piece | Where | Role |
|---|---|---|
| `content_sections` table | migration `…_create_pages_tables` | One ordered store for every owner (Page, Project case study, Article). `block_type` + JSON `data` |
| `BlockContract` / `AbstractBlock` | `app/Domain/Content/Blocks/` | What a block is: key, label, scopes, admin schema, validation rules, normalisation, references, resolve → view model, `shouldRender`, warnings |
| Block classes (39) | `app/Domain/Content/Blocks/Definitions/` | One class per block type |
| `config/blocks.php` | | The catalogue. Order here doesn't matter |
| `BlockRegistry` | singleton | Looks blocks up by key; builds the grouped, scope- and permission-filtered picker |
| `SectionsField` | `app/Filament/Schemas/` | The admin builder: a relationship Repeater; the chosen block type drives the item's form |
| `SaveSections` | `app/Domain/Content/Actions/` | Server-side gate on every save: scope, block permission, the block's own rules (on normalised data), unique anchors, locked blocks kept |
| `GuardsContentSaves` | `app/Filament/Concerns/` | Runs `SaveSections` (and the publish-permission guard) after form validation, before anything is written, inside the page's DB transaction |
| `SectionRenderer` | `app/Domain/Content/Rendering/` | Front end: visible sections → batch-load references (one query per entity type) → `resolve()` → drop blocks whose `shouldRender()` is false → list of `RenderedBlock` |
| `BlockImageField` | `app/Filament/Schemas/` | Images inside blocks: media on the **section** (conversions and responsive images), tied to a named slot, with required alt text in `data` |

## 2. Rules the system enforces for you

- **Absent means nothing.** If `shouldRender()` is false (no body, no live items, unverified-only metrics, zero recommendations), the block renders nothing. No empty headings (PRD §11.3, §17.4).
- **Live only.** Referenced projects, articles and recommendations are loaded through the live scope. Unpublished or deleted references silently drop out.
- **Locked blocks** (listing blocks on system pages) can be moved but not deleted, cloned or retyped. This is enforced in the UI, in `SaveSections`, and by a model guard.
- **Sanitised twice.** Rich-text fields declared in `htmlFields()` are cleaned on save, and `<x-rich-text>` cleans them again at render.
- **Unknown block types** (e.g. a block class removed later) are skipped and logged, never fatal.
- **Order and visibility changes** are written to the activity log (`sections`). Content snapshots are taken on publish.

## 3. Adding a block type (worked example)

Say the owner wants a **"Testimonial strip"** that shows one recommendation's short excerpt on any page.

**Step 1: the class.** `app/Domain/Content/Blocks/Definitions/TestimonialStripBlock.php`:

```php
final class TestimonialStripBlock extends AbstractBlock
{
    public function key(): string { return 'testimonial_strip'; }        // stored in the DB: never change it
    public function label(): string { return 'Testimonial strip'; }
    public function icon(): string { return 'heroicon-o-chat-bubble-left'; }
    public function group(): BlockGroup { return BlockGroup::Evidence; }
    public function scopes(): array { return [BlockScope::Homepage, BlockScope::Page]; }

    public function schema(): array
    {
        return [BlockFields::recommendationSelect()->required()];       // admin form (fields relative to data)
    }

    public function rules(): array
    {
        return ['recommendation_id' => ['required', 'integer']];       // enforced server-side by SaveSections
    }

    public function references(array $data): array
    {
        return ['recommendation' => [(int) ($data['recommendation_id'] ?? 0)]];   // batch-loaded, live + consented only
    }

    public function resolve(array $data, BlockContext $context): BlockViewModel
    {
        $found = $context->referenced('recommendation', [(int) ($data['recommendation_id'] ?? 0)]);

        return $this->viewModel($data, $context, ['recommendation' => $found[0] ?? null]);
    }

    public function shouldRender(BlockViewModel $vm): bool
    {
        return $vm->resolved('recommendation') !== null;               // unpublished ⇒ the block disappears
    }
}
```

**Step 2: the view.** `resources/views/blocks/testimonial-strip.blade.php` (the default view name is `blocks.` + the key with dashes). It receives `$vm` (`BlockViewModel`). Use only `$vm->get()` / `$vm->resolved()`. Never query, never hardcode copy, and use `<x-rich-text>` for HTML. Blocks use `h2` and below.

**Step 3: register it.** Add `Definitions\TestimonialStripBlock::class` to `config/blocks.php`.

That's all: no migration, controller, or page template change. `BlockSystemTest` automatically checks that it is registered, has a scope, a view name, and that any seeded data passes its rules. Add a focused test for its `shouldRender()` behaviour.

**Optional hooks:**
- `htmlFields()`: rich-text paths to sanitise (`['body']`, or `['items.*.text']`).
- `defaults()`: initial data when the block is picked.
- `warnings()`: admin-only hints (e.g. "more than five steps").
- `requiresPermission()`: restrict a block to a permission (e.g. a future super-admin-only embed).
- `headingFor()`: the admin item label and the in-page section navigation label.

## 4. Images in blocks

Use `BlockImageField::single('photo', 'Photo')` (stores `photo` = slot name and `photo_alt`) or `BlockImageField::gallery('images')` (each item has `slot`, `alt`, `caption`). Declare those keys in `rules()`. In `resolve()`, call `$context->image($slot)` to get the media item. A missing file renders nothing rather than a broken image.

Duplicating a whole page or project (`DuplicateContent`) copies section images. The in-builder "clone section" button copies data only, so re-attach images on the clone.

## 5. Scopes

| Owner | Scope used by the picker |
|---|---|
| The page flagged as homepage | `homepage` |
| Any other page | `page` |
| Project (Case study tab) | `case_study` |
| Article (Extra sections tab) | `article` |
