---
description: "Generate a BlockRegistry JSON block file for a component's Twig template"
allowed_tools: ["Read", "Write", "Edit", "Bash", "Glob", "Grep"]
---

# Block Registry JSON Generator

Generate a BlockRegistry JSON block file for a component, creating the block definition with fields, template assignments, and category.

## Usage
```
/mtc-block-registry <component-name> [template-assignments]
```

### Examples
```
/mtc-block-registry faq_showcase
/mtc-block-registry faq_showcase home.twig,content.twig
```

## Arguments

- `$ARGUMENTS` — Component name (snake_case, matching the Twig template filename) and optionally comma-separated template assignments. If no templates specified, defaults to `home.twig`.

## Process

### Step 1: Parse Arguments

Extract the component name and optional template assignments from the arguments.

### Step 2: Locate and Analyze the Twig Template

Find the component template by checking these paths in order:
1. `sites/default/templates/includes/block_registry/{name}.twig` — where all
   BlockRegistry block templates live. New blocks go here.
2. `sites/default/templates/includes/page_blocks/{name}.twig`
3. `sites/default/templates/includes/elements/{name}.twig`
4. `sites/default/templates/includes/partials/{name}.twig`
5. `sites/default/templates/includes/pages/**/{name}.twig`

Paths 2-5 are legacy CMS templates. A handful are still wired up as block
templates, but do not add new ones there — and do not extend or import from
them, because they read data through the `p.pagedata` compatibility bridge
rather than `block_data`.

Read the template and extract:
- All `block_data["Field Name"]` references — these are the block fields
- All `block_data["Field Name"]["rows"]` references — these indicate repeater fields
- For repeaters, find the `{% for %}` loop body to identify sub-fields from `item["SubField"]["value"]` or `faq["Question"]["value"]` patterns
- Also check included sub-templates (e.g., `{% include "includes/pages/faqs/faq.twig" %}`) for field patterns

**`banner` is not a BlockRegistry variable.** Legacy templates that read
`banner["Field Name"]` establish it themselves with a line like
`{% set banner = p.pagedata['Image Banner'][0] %}` (see
`includes/page_blocks/image_banner.twig:27`). `EntityBlockService::prepareBlockDataForTemplate()`
provides `block`, `block_name`, `block_slug`, `block_data`, `fields`, `slides`
and `p.pagedata` — nothing else. New templates must read `block_data` directly.

### Step 3: Determine Field Types

Map each field to its BlockRegistry field type:

| Pattern | Field Type | Notes |
|---------|-----------|-------|
| `Title`, `Eyebrow`, `Name`, `Label` | `text` | Simple text input |
| `Content`, `Description`, `Answer`, `Quote` | `textarea` | Add `css_class: 'simpletinymce'` for rich text |
| `Image`, `Poster Image`, `Background Image` | `image` | Image with crop/alt support |
| `Video`, `Video File` | `file` | Add `mime: 'video/mp4'` |
| `Button`, `Secondary Button`, `Link` | `link` | Carries `url`, `title` and `target` in one field |
| `Icon` | `icon` | Visual picker; stores the full CSS class string |
| A single chosen page, or a container whose children are listed | `page_selector` | See the settings shape below |
| Fields accessed via `["rows"]` or used in `{% for %}` loops | `repeater` | Create sub-fields for loop variables |
| Boolean-like fields | `checkbox` | |
| Fields with fixed options | `select` | Add `options` in `value|label` format (see below) |

**Field naming conventions — these are effectively permanent**, because a field
`name` is the storage key in `entity_blocks.data`. Renaming one orphans the
content already saved under the old name.

- **`Eyebrow`** for the small text above a heading. Never `Overline`,
  `Subtitle` or `Label`.
- **`Title` as `text`**, never `Heading`, and never `textarea`.
- **A single `link` field named `Button`**, then `Secondary Button` for a
  second. Never a `Button Text` + `Button Link` pair — the `link` type already
  carries `url`, `title` and `target`. Leave both optional and guard in the
  template on `url` and `title` both being present.
- No `Title Highlight` / `Title Suffix` split. The starter has no
  colour-highlighted headings.

The 21 default blocks in `modules/BlockRegistry/blocks/` follow all of the
above — read one before authoring a new block.

### Product data — use `product_selector`, not manual fields

If a block displays product information (title, description, image, price, URL), **do not** create manual text/image fields for that data. Instead, use a `product_selector` field and resolve the product at render time via `get_products_by_ids()`. Only add manual fields for data that is **not part of the product** (e.g. step labels, feature icons, custom descriptions specific to this block's context).

**Pattern in Twig:**
```twig
{# Collect product IDs from repeater rows #}
{% set product_ids = [] %}
{% for row in block_data["Items"]["rows"] | default([]) %}
    {% if row["Product"]["value"] is defined and row["Product"]["value"] is not empty %}
        {% set product_ids = product_ids | merge([row["Product"]["value"]]) %}
    {% endif %}
{% endfor %}
{% set resolved_products = get_products_by_ids(product_ids) %}

{# Index by ID for lookup #}
{% set products_by_id = {} %}
{% for product in resolved_products %}
    {% set products_by_id = products_by_id | merge({ (product.id ~ ''): product }) %}
{% endfor %}

{# Use in loop #}
{% set product = products_by_id[row["Product"]["value"] ~ ''] | default(null) %}
{% if product %}
    {{ product.name }}
    {{ product.getImagePath('small') }}
    {{ product.getUrlAttribute() }}
    {{ product.getFromPrice() }}
{% endif %}
```

### Step 4: Generate the JSON Block File

Create the JSON file at:
```
modules/BlockRegistry/blocks/{slug}.json
```

Where `{slug}` is the kebab-case version of the component name (e.g., `faq_showcase` → `faq-showcase`).

The JSON must match this exact schema (used by `BlockSyncService`):

```json
{
    "name": "Human Readable Name",
    "slug": "kebab-case-slug",
    "description": "Brief description of what the block does",
    "icon": "fa-icon-class",
    "template": "block_registry/component_name.twig",
    "is_system": false,
    "is_active": true,
    "order_index": 0,
    "category": {"name": "Banners & Heroes", "slug": "banners-heroes", "icon": "fa-image", "order_index": 0},
    "fields": [
        {
            "name": "Field Name",
            "label": "Field Name",
            "type": "text",
            "order_index": 0,
            "required": false,
            "css_class": null,
            "settings": null,
            "options": null,
            "regex": null,
            "mime": null,
            "default_value": null,
            "info": null
        },
        {
            "name": "Repeater Name",
            "label": "Repeater Name",
            "type": "repeater",
            "order_index": 1,
            "required": false,
            "css_class": null,
            "settings": null,
            "options": null,
            "regex": null,
            "mime": null,
            "default_value": null,
            "info": null,
            "repeater_min": 1,
            "repeater_max": 10,
            "repeater_text": "Add Item",
            "sub_fields": [
                {
                    "name": "Sub Field Name",
                    "label": "Sub Field Name",
                    "type": "text",
                    "order_index": 0,
                    "required": false,
                    "css_class": null,
                    "settings": null,
                    "options": null,
                    "regex": null,
                    "mime": null,
                    "default_value": null,
                    "info": null
                }
            ]
        }
    ],
    "template_assignments": [
        {
            "template_name": "home.twig",
            "is_default": false,
            "order_index": 0
        }
    ]
}
```

**Key rules for the JSON:**
- `template` path should NOT include the `includes/` prefix or `sites/default/templates/` — just the path from `block_registry/` (e.g. `block_registry/faq_showcase.twig`). `BlockRegistry::getTemplatePath()` adds the `includes/` prefix
- **`category` is mandatory.** Never `null`. Discovery at `/component` groups by category, so a null-category block lands in an `Uncategorised` orphan bucket. Use one of the seven, copying the `icon` and `order_index` exactly as below:

  | Category | Slug | Icon | order_index |
  |---|---|---|---|
  | Banners & Heroes | `banners-heroes` | `fa-image` | 0 |
  | Text & Content | `text-content` | `fa-align-left` | 1 |
  | CTAs | `ctas` | `fa-bullhorn` | 2 |
  | Products & Shop | `products-shop` | `fa-shopping-basket` | 3 |
  | Social Proof & Trust | `social-proof-trust` | `fa-star` | 4 |
  | Listings & FAQs | `listings-faqs` | `fa-list` | 5 |
  | Layout & Structure | `layout-structure` | `fa-th-large` | 6 |

  There is deliberately no `Misc`. If a block fits none of these, raise it rather than inventing a category. Categories are auto-created on import via `firstOrCreate` on slug, so `name`, `icon` and `order_index` are only applied when the category does not exist yet — keep them consistent across every block JSON that uses the same category.
- Repeater fields include `repeater_min`, `repeater_max`, `repeater_text`, and `sub_fields` array
- Non-repeater fields omit repeater-specific properties
- All nullable fields should be explicitly set to `null`
- **`default_value` must always be `null`.** Export and diff both compare it, but `BlockRegistryService::createField()` never writes it, so any other value produces a permanent phantom diff that no re-import can clear. Put defaults in the template instead
- `template_assignments[].template_name` is the CMS template basename **with** `.twig` (e.g. `home.twig`), except the two shop templates which are the bare strings `shop_item` and `shop_category`
- Only assign a block to templates that actually render blocks. Today that is `home.twig`, `content_sidebar.twig`, `shop_item` and `shop_category` — everything else can hold an assignment but will never output it, because those templates have no `render_entity_blocks()` call
- Field `name` values must match exactly what the Twig template references in `block_data["Field Name"]`

Field `settings` shapes, for the types that need them:

```json
"settings": {"image_settings": {"crop_enabled": true, "aspect_ratio": "16:9", "srcset_sizes": [400, 800, 1200], "quality": 85}}
"settings": {"page_selector_settings": {"mode": "auto", "container_id": null, "limit": 50}}
"settings": {"visibility": {"field": "Video Source", "values": ["youtube"]}}
```

`aspect_ratio: ""` means free crop. `mode` is `manual` (one chosen page) or
`auto` (the chosen page's published children). `visibility` works on any field
type and may sit alongside the type-specific key, but it must name a
**`select`** field at the same level.

### Step 5: Author `example_data`

Every block gets an `example_data` key **except** blocks driven by a
`product_selector` or `page_selector`, whose stored IDs do not map across
environments — those keep an inline `{% if show_dummy_data %}` block in the
template instead.

`example_data` is file-only: import ignores it, diff never compares it, and
export preserves it. It drives both the `/component` preview and the
"Fill with example content" button at `/admin/blocks/data/{id}`.

Author it **nested and human-readable** — `EntityBlock::normalizeExampleData()`
converts it to the stored shape:

```json
"example_data": {
    "Title": "Expert pharmacy care",
    "Content": "<p>Rich text keeps its HTML.</p>",
    "Icon": "icon icon--shield",
    "Button": { "url": "/how-it-works", "title": "How it works", "target": "" },
    "Image": { "url": "/sites/default/images/placeholders/default-hero.png", "alt": "Describe the image" },
    "FAQs": [
        { "Question": "How long does delivery take?", "Answer": "<p>Two to three working days.</p>" }
    ]
}
```

- scalars for `text`, `textarea`, `select`, `checkbox` (`"1"`/`"0"`), `datetime`, `colorpicker` and `icon`
- objects for `link`, `image` and `file`; arrays of row objects for `repeater`, with nested `link`/`image` sub-fields still as objects
- **omit `product_selector` and `page_selector` keys entirely**
- **`link` URLs must be relative and environment-neutral** — `/about-us`, never `http://localhost…` and never an ID path like `/category/123`. `sanitizeExampleData()` deliberately does not blank `link` fields, so a bad URL here survives into a client site on prefill
- image URLs should point at an existing file under `/sites/default/images/placeholders/`

### Step 6: Import the Block

Import the JSON file into the database:
```bash
docker exec pharmacy-starter-web php artisan blocks:import {slug} --force
```

### Step 7: Verify

Confirm the block was imported and is in sync:
```bash
docker exec pharmacy-starter-web php artisan blocks:diff {slug}
```

This should show the block as "In Sync".

Then confirm the block appears at http://localhost:7070/component **under the
expected category**. This is the stronger check: `/component` is driven by
`block_registry`, so it exercises the category, the template path and the
preview all at once. A block missing from that page means one of:

- `is_active` is false — inactive blocks are excluded from `/component` entirely
- `template` is empty, or the file does not exist under `sites/default/templates/includes/` (check the log for a "skipping in component registry" warning)
- the import did not run

Click through to the preview and confirm it renders rather than showing a Twig
error or a blank panel.

No cache-clear step is needed. `BlockRegistry` and `BlockRegistryCategory` clear
the `/component` cache on save and delete, so an import shows immediately.

## Output

**Created File:**
- `modules/BlockRegistry/blocks/{slug}.json`

**Database Records (after import):**
- 1 row in `block_registry`
- N rows in `block_registry_fields` (top-level + repeater sub-fields)
- 1+ rows in `block_registry_template_assignments`

## Field Type Reference

### Supported Types
- `text` — Single-line text input
- `textarea` — Multi-line text (add `css_class: 'simpletinymce'` for WYSIWYG)
- `image` — Image upload with crop/alt/srcset support
- `file` — File upload (specify `mime` for validation)
- `checkbox` — Boolean toggle
- `select` — Dropdown (requires `options` in pipe-delimited format: `"value1|Label 1\nvalue2|Label 2"`, NOT JSON)
- `link` — Internal/external link picker; stores `url`, `title` and `target` together
- `icon` — Visual picker for FA5 Pro icons and the site icons in `sites/default/css/base/icons.less`. Stores the **complete CSS class string** (`icon icon--shield`, `fal fa-star`), so render it straight into `<i class="…">`. Adding an `&--iconName` entry to `icons.less` makes it available in the picker automatically, via `/admin/blocks/ajax/icon-manifest`
- `product_selector` — Product search/select; resolve with `get_products_by_ids()`
- `page_selector` — Page picker. `manual` mode resolves to one page (`["page"]`); `auto` mode resolves the chosen page's published children, newest first, into `["pages"]`
- `repeater` — Repeatable group of sub-fields (no nesting)
- `datetime` — Date/time picker
- `colorpicker` — Color selection

The definitive list is `BlockRegistryField::FIELD_TYPES`. Note that
`validateBlockJson()` only checks types on top-level fields — a typo in a
sub-field `type` imports silently and simply fails to render.

### Repeater Configuration
- `repeater_min` — Minimum rows (typically 1)
- `repeater_max` — Maximum rows
- `repeater_text` — "Add" button label (e.g., "Add FAQ", "Add Card")

## Critical Rules

- Follow the **exact JSON schema** shown above — it must match what `BlockSyncService::blockToArray()` produces
- Reference existing JSON files in `modules/BlockRegistry/blocks/` for examples
- Field `name` values must match exactly what the Twig template references in `block_data["Field Name"]`. Never `banner[...]` — that variable does not exist unless the template sets it itself
- `category` is mandatory and must be one of the seven listed above
- Template path format: `block_registry/{name}.twig` (no `includes/` prefix)
- Field names follow the conventions in Step 3: `Eyebrow`, `Title` as `text`, a single `link` named `Button`
- `example_data` is mandatory unless the block is `product_selector`- or `page_selector`-driven, in which case use an inline `show_dummy_data` block
- `default_value` is always `null`
- Always run `blocks:import` after creating the JSON to populate the database
- Always run `blocks:diff` to verify the block is in sync
- Always confirm the block appears at http://localhost:7070/component under the expected category

## Template Fix-Up (MANDATORY after generating JSON)

After generating the JSON, you MUST also update the Twig template to be BlockRegistry-compatible. The `/mtc-extract` command generates templates with dummy-data-only patterns that break when fed real BlockRegistry data. Apply these fixes:

### Image fields — use `["url"]` with fallback chain
BlockRegistry image fields provide a full URL in `["url"]`, NOT just a filename in `["value"]`. Always use this pattern for image fields (both top-level and in repeaters):

```twig
{% if item["Image"]["url"] is defined and item["Image"]["url"] is not empty %}
    {% set item_image = item["Image"]["url"] %}
{% elseif show_dummy_data is defined and show_dummy_data == true %}
    {% set item_image = "/sites/default/images/placeholders/" ~ item["Image"]["value"] %}
{% else %}
    {% set item_image = item["Image"]["value"] | default("") %}
{% endif %}

<img src="{{ item_image }}"
     alt="{{ item['Image']['alt'] | default('') }}"
     {% if item["Image"]["srcset"] is defined and item["Image"]["srcset"] is not empty %}srcset="{{ item["Image"]["srcset"] }}"{% endif %}
     loading="lazy">
```

**NEVER** use `src="/sites/default/images/{{ item['Image']['value'] }}"` — this only works for dummy data and breaks with real BlockRegistry uploads.

Top-level `image` and `link` fields arrive **already decoded** as arrays, so
`responsive_image(block_data["Image"])` and `block_data["Button"].url` work
directly — no `json_decode`. The fallback chain above still matters inside a
`show_dummy_data` branch, where only `["value"]` is populated.

### Rich text fields — use `|raw` filter and `<div>` wrapper
Any field with `css_class: 'simpletinymce'` contains HTML (e.g. `<p>` tags). You MUST:
1. Use `|raw` filter: `{{ item["Content"]["value"] | default("") | raw }}`
2. Use `<div>` not `<p>` as the wrapper element (TinyMCE outputs `<p>` tags, nesting `<p>` inside `<p>` is invalid HTML)

### Reuse the shared chrome
Do not re-implement heading markup per block. Include the shared header partial
with `only`, so the caller's scope cannot leak in:

```twig
{% include 'includes/block_registry/partials/block_header.twig' with {
    eyebrow: block_data['Eyebrow']['value']|default(''),
    title: block_data['Title']['value']|default(''),
    content: block_data['Content']['value']|default(''),
    button: block_data['Button']|default({}),
    centered: true
} only %}
```

The same applies to behaviour — accordions, tabs and carousels all already
exist, so a new block should need close to zero new JS:

| Need | Reuse | Required markup |
|---|---|---|
| Accordion | `includes/elements/accordion.twig` (`mtc_accordions.js`) | pass `list` of rows keyed `Title`/`Content` |
| Tabs | `core/js/plugins/mtc_tabs.js`, already initialised by `script.js` | `.js_tabs > .tabsContentWrap > .tabContent > .tabTitle` |
| Carousel | `includes/elements/carousel.twig` | pass `items`, `cssClass` and `itemTemplate` — never `{% embed %}` |
| Video overlay | `site_scripts/mtc_video_banner.js` | an `<a href="{video}" class="js_video_trigger">` around the poster |

New block LESS goes in `sites/default/css/block_registry/{name}.less` and is
registered by one `@import` line in that directory's `index.less`. Use the
`.blockSection()` and `.blockGrid()` mixins from `block_registry/mixins.less`
rather than repeating the responsive padding and grid boilerplate. Prefix the
root class with `block` (e.g. `.blockCardGrid`) — several legacy class names
such as `.cardGrid`, `.ctaBanner` and `.titleBanner` are still live.

## Reference

- **Existing JSON blocks**: `modules/BlockRegistry/blocks/*.json`
- **Existing block templates**: `sites/default/templates/includes/block_registry/`
- **Block Registry module**: `modules/BlockRegistry/`
- **Sync service**: `modules/BlockRegistry/Services/BlockSyncService.php`
- **Template rendering**: `modules/BlockRegistry/Services/EntityBlockService.php`

ARGUMENTS: $ARGUMENTS
