# Builder Package - Claude Code Guidance

## Overview

The **bongo/builder** package provides a visual page builder with 714 pre-built blocks (428 components, 266 designs, 20 layouts — counted 2026-08-11; see the drift warning under Gotchas) and a bespoke vanilla-JS WYSIWYG editor (ContentBox.js v4.1 derivative, modularised into 156 ES modules). Blocks are file-based — there is no database layer in this package.

**Key features**:
- File-based block storage with vendor/resource override pattern
- Single polymorphic model (`BuilderItem`) handling three types: component, design, layout
- Admin CRUD interface for block management (+ icon browser)
- Authenticated editor-feed API and public content API
- Shortcode support via response middleware — company-detail placeholders (`[company_phone]`, `[company_name]`, `[company_email]`, `[company_contact]`, `[registered_address]`, `[office_address]`)
- 2,045 FontAwesome icons (163 regular, 1,392 solid, 490 brand) in config
- A Playwright browser suite plus a vitest unit layer (each runner prints its own counts)

**Dependencies**: PHP 8.2+, Laravel 10+ (`illuminate/contracts ^10.0`), bongo/framework ^3.0, bongo/image ^3.0

**Documentation**:
- [ARCHITECTURE.md](./ARCHITECTURE.md) — PHP architecture, data flow, routes, storage
- [ARCHITECTURE-JS.md](./ARCHITECTURE-JS.md) — JavaScript editor architecture (read this before touching `resources/backend/js`)
- [UPGRADE-4.x.md](./UPGRADE-4.x.md) — 3.x → 4.x migration notes
- [.cursorrules](./.cursorrules) / [.github/copilot-instructions.md](./.github/copilot-instructions.md)

**"The client asked for X — where do I go?"**

| You want to add | Runbook |
|---|---|
| A button on the RTE toolbar | [docs/adding-an-rte-toolbar-button.md](./docs/adding-an-rte-toolbar-button.md) |
| A new element type (tool + settings modal) | [docs/adding-an-element-type.md](./docs/adding-an-element-type.md) |
| A tab or control on the element style panel | [docs/adding-an-element-style-panel.md](./docs/adding-an-element-style-panel.md) |
| A block to the content-block library | [docs/adding-a-content-block.md](./docs/adding-a-content-block.md) |
| A new editor option | [docs/editor-options-reference.md](./docs/editor-options-reference.md) |
| …but should it be a plugin instead? | [docs/adding-a-plugin-or-a-built-in.md](./docs/adding-a-plugin-or-a-built-in.md) |
| A translated string, or a whole language | [docs/adding-a-language.md](./docs/adding-a-language.md) |
| Something the *visitor* sees (animations, parallax) | [docs/the-frontend-runtime-contract.md](./docs/the-frontend-runtime-contract.md) |
| …and then ship it | [docs/building-and-releasing-editor-assets.md](./docs/building-and-releasing-editor-assets.md) |

## Commands

### PHP

```bash
vendor/bin/phpunit           # tests/Unit (no app booted) + tests/Feature (Testbench app)
vendor/bin/pint              # Fix code style   (pint.json)
vendor/bin/pint --test       # Check code style
vendor/bin/phpstan analyse   # Static analysis, level 1 — currently clean (phpstan.neon.dist)
```

### Frontend (package.json)

```bash
npm install
npm run dev            # lint (ESLint + CSS selector linter) + development build
npm run watch          # Rebuild on change (watch-poll / hot also available)
npm run prod           # lint + production build
npm run format         # eslint --fix — the Pint equivalent for JavaScript (no extra dependency)
npm run format:check   # eslint, report only — the `pint --test` equivalent
npm run lint           # 14 gates, all of which must pass before a build. The ones worth knowing:
                       #   lint:css (SCSS selectors) + lint:js (eslint)
                       #   lint:imports (case-sensitive import resolution)
                       #   lint:public (node --check over hand-maintained public/ JS)
                       #   lint:unspecced (every editor module is named by some test)
                       #   lint:transient-attrs (no editor-internal attribute ships in a blade —
                       #     `data-click="true"` on a shipped block dead-locked its own cells)
                       #   lint:emitted-scripts (no `//` INSIDE a template literal that becomes an
                       #     inline script — the file is valid JS, the string it emits is not)
npm run check          # the full gate: lint + test:unit + lint:build (rebuilds and compares the
                       # committed artefacts against source — needs a clean tree to be meaningful)
npm run docs:options   # Regenerate docs/editor-options-reference.md from the defaults modules

npm run test:unit      # jsdom, seconds, no consumer site
npm test               # Playwright browser suite in tests/browser/
npm run test:headed    # Headed browser
npm run test:ui        # Playwright UI mode
```

Playwright needs `.env.testing` (copy `.env.testing.example`) with `TEST_BASE_URL` pointing at a running consumer site; `tests/browser/global-setup.js` logs in once and stores state in `storageState.json`. Single worker, 1 retry, 60s timeouts.

### In a consumer app

```bash
php artisan vendor:publish --tag=builder:assets --force
php artisan cache:clear      # Also clears block caches
php artisan db:seed --class="Bongo\Builder\Seeders\DataSeeder"
```

## Quick Architecture Reference

**BuilderItem** (`src/Models/BuilderItem.php`) — plain PHP class, NOT Eloquent:
- One class, three types (`component` / `design` / `layout`), set via constructor
- Reads `.php` files from vendor path, merges resource-path overrides with `array_replace()` (resource wins)
- `all()` cached 600s under `{type}_files`; `save()`/`delete()` clear the cache
- `save()` always writes to the resource path; `delete()` only removes the resource copy (reverting to vendor)

**BuilderServiceProvider** (`src/BuilderServiceProvider.php`):
- `$module = 'builder'`; extends `AbstractServiceProvider` (bongo/framework) — config/routes/views/translations auto-registered
- Registers singletons `component`, `design`, `layout` + global aliases `Component`, `Design`, `Layout`
- `$middlewares = ['hasShortCodes' => HasShortCodes::class]`
- Publishes `public/` under tag `builder:assets`

**Routes** (prefix/middleware applied by AbstractServiceProvider):
- `api.php` → `/api/builder/*`, `auth:sanctum` — 11 editor-feed endpoints (components, designs, snippets, buttons, files, fonts, images, icons, symbols, styles, `modules/{module}`)
- `backend.php` → `/admin/*`, `auth`+`employee` — CRUD for `components`, `sections` (designs), `layouts` (prefixes config-driven) + `/admin/icons`
- `custom.php` → public, `noIndex` — 11 content endpoints (`post`, `project`, `question`, `review`, `review-summary`, `form-builder`, `menu-builder`, `gallery-builder`, `carousels/{carousel}`, `sliders/{slider}`, `validation`)

**Editor JS** (`resources/backend/js/`): entry `editor.js`; subsystems under `contentbuilder/` — `grid/` (rows/columns/QuickAdd), `rte/` (toolbars/buttons/dropdowns), `elements/` (panel + per-type handlers), `sections/` (BoxEditor/SectionManager/SectionSettings), `ui/` (ModalManager/SidebarManager), `plugins/PluginManager`, `config/`, `core/` (ContentManager + PasteHandler, DocumentEventHandler, CellInteraction, HtmlIo), `openai/OpenAI` (DTB-516, in progress); framework-free helpers in `shared/`. Built by `webpack.mix.js` to `public/backend/`.

## Key Files

| File Path | Purpose |
|-----------|---------|
| `src/Models/BuilderItem.php` | Core file-based model — all block read/write logic |
| `src/BuilderServiceProvider.php` | Singletons, facade aliases, asset publishing |
| `src/Config/builder.php` | Paths, prefixes, categories, 2,045 icon names (2,125 lines) |
| `src/Http/Middleware/HasShortCodes.php` | `ShortCode::parse()` over response content |
| `src/Routes/{api,backend,custom}.php` | Route definitions (see above) |
| `src/Views/frontend/{type}/...` | The block library (`index.blade.php` + `preview.png` each) |
| `src/Views/frontend/{carousels,posts,projects,questions,sliders}/` | `style_1..5.blade.php` fragments for the public module endpoints |
| `src/Views/api/*.blade.php` | Fragments returned to the editor (icons, snippets, fonts, ...) |
| `resources/backend/js/editor.js` | Editor entry point |
| `resources/backend/js/EditorDefaults.js` | `buildEditorDefaults()` — editor option surface |
| `resources/backend/js/shared/` | DomHelper, StringHelper, HtmlSanitiser (Word-noise paste cleaner), UntrustedHtml (`sanitiseUntrustedHtml` — the security scrub for third-party HTML), ... |
| `webpack.mix.js` | Build: editor.css/js, helpers.js, box.css/js |
| `playwright.config.js` + `tests/browser/` | Browser test suite |

## Common Patterns

```php
// Read (all cached 10 minutes)
Component::all();                      // Collection
Component::allByCategory();            // [category][key] => BuilderItem
Component::allAsJson();                // Editor palette JSON
Component::find('header-02');          // BuilderItem|null
Component::findOrFail('header-02');    // abort('404') on miss
Design::findByCategory('banners');     // Collection
Layout::categories();                  // ['headers' => 'Headers', ...]

// Write — always lands in resources/views/vendor/builder/
$item = new BuilderItem('component');
$item->setName('My Block');            // slugified → 'my-block'
$item->setKey('my-block');             // make_key()
$item->setCategory('Headline');        // → 'headline'
$item->setHtml('<div>...</div>');
$item->setThumbnail($binaryPng);
$item->save();                         // writes files + Cache::forget('component_files')

// Render
$item->getHtml();                      // Blade-rendered via builder:: namespace
$item->getRawHtml();                   // Raw template source
$item->getThumbnail();                 // Base64 data URI (placeholder fallback)
```

## Code Style Summary

- **`declare(strict_types=1)` on every PHP file** — enforced by `pint.json`'s `declare_strict_types`. Scalar arguments are no longer coerced, so a value from the request must be cast deliberately (see `BoundsPublicInput`)
- Return types on everything: `all(): Collection`, `save(): void`, `allAsJson(): false|string`
- Short nullable notation: `public ?string $name = null;`
- Paths always use `DIRECTORY_SEPARATOR`
- Errors: `log_exception($e)`, `ComponentException`/`DesignException`, `abort('404')`
- Names/categories auto-slugified in setters — never pre-slugify at call sites
- Config keys snake_case; route names dot-notation (`component.index`); block dirs kebab-case
- JS: vanilla ES modules only (no jQuery/Vue in the editor); ESLint enforced on every build

### Public endpoints must bound their input

`src/Routes/custom.php` is unauthenticated by design. Any controller reading a numeric query
parameter uses `Http\Controllers\Api\Concerns\BoundsPublicInput::boundedInt()`, which clamps to a
ceiling and falls back for anything non-numeric, zero or negative — `?limit=99999999` previously
asked the database for the whole table on an endpoint anyone can call. A test asserts no `Api`
controller reads `limit` directly.

## Gotchas

- **The two PHP suites boot differently, and the base `TestCase` is only for one of them.** `tests/Unit/` extends PHPUnit's own `TestCase` and boots no application — fast, and enough for anything that does not need the container. `tests/Feature/` extends `Bongo\Builder\Tests\TestCase`, which boots a Testbench app. That base class was BROKEN until 2026-08-08: it declared `getPackageProviders(Illuminate\Foundation\Application $app)` against Testbench's untyped `$app`, a fatal signature incompatibility, so any test extending it died before running — which is why the package had no Feature tests at all. It also aliases the `noIndex` middleware by hand, because booting `FrameworkServiceProvider` to get that alias drags in sibling packages (`bongo/referrer` first) that this package does not require.
- **Global helpers come from providers, not from composer autoload.** `make_key()`, `log_exception()`, `package()` and `setting()` are declared in plain `helpers.php` files that their packages' composer.json does NOT list under `autoload.files` — each ServiceProvider `include`s its own at register time. So in any app where that provider does not boot, the function simply does not exist and the first call dies with a bare "Call to undefined function". That is why `tests/TestCase.php` requires those files by hand.
- **Sibling packages are SOFT dependencies by convention across the monorepo — do not "fix" them in bulk.** 18 of the 31 packages reference a sibling's classes at runtime without requiring it in composer.json, and that is deliberate: every consumer installs the whole suite. `bongo/framework` alone references 29 siblings from its own ServiceProvider, and declaring those would be a cycle, since all 29 require framework. So an undeclared sibling reference is NOT prima facie a bug. The question to ask is whether the call site is REACHABLE without the sibling: `cms/builder`'s module controllers are not (they answer only their own endpoints and fail cleanly, see `tests/Feature/ModuleSettingsPagesRenderTest.php`), whereas `BuilderItem`'s `package()` call is on the palette path and runs always. Only the second kind is worth declaring.
- **Siblings a TEST needs go in `require-dev`, never `require`.** `src/Models/BuilderItem.php` calls `package()->isEnabled()` and twenty Blade files call `setting()` — including `src/Views/api/validation.blade.php`, which backs a PUBLIC endpoint. Neither package appeared in composer.json at all until 2026-08-08, and the fix is NOT to add them to `require`: that would assert a runtime dependency the monorepo deliberately does not have, since every consumer installs the whole suite and provides them. They sit in **`require-dev`**, which is what lets the Testbench app boot without changing the release graph by a single package. This is the house pattern — see `~/Packages/bespoke/crm/address/composer.json`, which carries eighteen siblings in `require-dev` for exactly this reason. When a Feature test needs a sibling's models, add it to `require-dev` and register its provider in `tests/TestCase.php`.
- **`PackageManager::__construct()` queries the `packages` table with no guard**, despite importing `Schema` and never using it — so `package()` is fatal on a fresh install before migrations run. That is in `bongo/package`, not here, but this package is what calls it.
- **A Feature test cannot render most of the public content endpoints.** `post`, `project`, `question`, `review`, `gallery-builder` and the rest resolve models from sibling packages that are deliberately not composer dependencies, so they fatal under Testbench. Assert over the ROUTER (middleware, registration) — see `tests/Feature/RouteMiddlewareContractTest.php` — and leave rendering to the Playwright layer on a real consumer.
- **The JavaScript suites remain the real safety net for editor behaviour**: `npm run test:unit` (vitest, fast, no consumer site needed) and `npm test` (Playwright, against a configured consumer site). Editor JS changes need those, not just `vendor/bin/phpunit`.
- **`public/` is only PARTLY build output.** Webpack produces exactly `public/backend/editor.{js,css}`, `public/backend/helpers.js` and `public/frontend/box.{js,css}` — never hand-edit those. Everything else under `public/backend` is hand-maintained **source** that no build touches: `plugins/` (config.js and each plugin.js), `modules/js/` (the iframe module settings pages), `lang/{en,fr}.js` (the entire translation key store) and the standalone iframe pages (`icons/`, `fonts/`, `images/`, `files/`, `designs/`, `snippets/`, `styles/`). Editing those needs no rebuild — but they sit outside ESLint, and they carry no cache-busting query string, so a browser hard-reload is needed to see changes. They are **not** untested any more, though the coverage is uneven and worth knowing precisely: `npm run lint:public` syntax-checks all 40 with `node --check`; `lint:emitted-scripts` reads INSIDE their template literals (a `//` there is invisible to `node --check`, because the FILE is valid — it is the string it emits that is not); and `tests/vitest/modules/` drives several of them for real under jsdom via indirect `eval`, including `fonts.js` and the three form modules. Anything here you change, check for an existing vitest module test before assuming there is nothing to update.
- **Block counts drift** — counts here (427/266/20) reflect the tree at documentation time; count with `find src/Views/frontend/{type} -name index.blade.php | wc -l` rather than trusting docs.
- **`custom.php` endpoints are public** — anything added there needs its own input validation; they intentionally bypass auth for rendered-page consumption.
- **`getBasePath()` throws** if the vendor directory is missing (e.g. wrong `builder.vendor_path` in a consumer app); `getResourcePath()` silently creates its directory instead.
- **Icon lists are config data** — refresh via `/admin/icons` (see comment in `src/Config/builder.php`), don't hand-edit 1,900 lines of icon names. It is **not a screen**: `IconController::index(): array` returns raw JSON for you to paste into the config. Note it fetches `raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/metadata/icons.json` synchronously with a 10s timeout and returns `[]` on failure — so on a client server behind a firewall it is slow and then silently empty.

## Troubleshooting

```bash
# Blocks not appearing / stale after save
php artisan cache:clear     # or Cache::forget('component_files' / 'design_files' / 'layout_files')

# Assets not reflecting changes in consumer app
npm run dev                                                    # in the package
php artisan vendor:publish --tag=builder:assets --force        # in the consumer app

# Playwright cannot log in
rm storageState.json        # force global-setup to re-authenticate
```
