# BW Dev — Architecture

**Version:** 0.1.0 | **Last Updated:** 2026-05-11

## Overview

BW Dev is a modular plugin. A small core (settings, module registry, brand resolver) loads and conditionally activates feature modules. Each module is self-contained: its own hooks, assets, settings tab, and sanitize callbacks.

The core constraint: a disabled module must not register any WordPress hooks or enqueue any assets. This is achieved by deferring all `add_action`/`add_filter` calls into a module's `register()` method, which is only called by the core if the module's toggle is on.

## File Layout

```
bw-dev/
├── bw-dev.php                       Main file: header, constants, autoload bootstrap on plugins_loaded
├── uninstall.php                     Drops bw_dev_settings, runs each module's uninstall hook
├── CLAUDE.md, README.md, CHANGELOG.md, LICENSE
├── includes/
│   ├── class-bw-dev-plugin.php           Core: bootstraps settings, brand, modules
│   ├── class-bw-dev-settings.php         Reads/writes bw_dev_settings; per-module sanitize dispatch
│   ├── class-bw-dev-brand.php            Resolves bw_dev_brand filter; exposes brand to JS
│   ├── class-bw-dev-admin-page.php       Renders Settings → BW Dev (tabbed)
│   ├── interface-bw-dev-module.php       Module contract (slug, label, register, sanitize, render_tab)
│   └── modules/
│       ├── class-bw-dev-module-admin-columns.php
│       ├── class-bw-dev-module-favicon.php
│       ├── class-bw-dev-module-sticky.php
│       ├── class-bw-dev-module-post-link.php
│       └── class-bw-dev-module-youtube.php
├── admin/
│   ├── views/                       PHP partials for each settings tab
│   └── tab-renderers/               One renderer file per module if its tab is non-trivial
├── assets/
│   ├── css/admin.css                Shared admin styles for the settings page
│   ├── css/sticky.css               Module-specific (only enqueued when sticky is on)
│   ├── js/admin-columns.js, js/sticky.js, js/branding.js
│   └── images/
├── blocks/
│   ├── post-link/
│   │   ├── block.json, index.js, render.php, style.css
│   │   └── item/                    Child block bundle
│   └── youtube/
│       ├── block.json, index.js, render.php, style.css
├── languages/                       .pot translation template
├── vendor/plugin-update-checker/    Vendored, do not edit
└── docs/                            Dev docs, not shipped to users
```

## Core Classes

### `BW_Dev_Plugin` (`includes/class-bw-dev-plugin.php`)

Singleton bootstrapper.
- Instantiates `BW_Dev_Settings` and `BW_Dev_Brand`.
- Builds the module registry (slug → instance) — but does NOT call `register()` on them yet.
- Reads `bw_dev_settings['modules']` for the enable map.
- For each enabled module, calls `$module->register()`.
- Hooks `admin_menu` to instantiate `BW_Dev_Admin_Page` (regardless of which modules are on — the settings page itself must always be accessible).
- Exposes the registry via `BW_Dev_Plugin::instance()->modules()` for the admin page.

### `BW_Dev_Settings` (`includes/class-bw-dev-settings.php`)

- Single option name constant: `OPTION = 'bw_dev_settings'`.
- `get( $module_slug, $key = null, $default = null )` — typed accessor.
- `update( $module_slug, $data )` — merges into the option, runs the module's `sanitize()`.
- Registers the WordPress setting via `register_setting()` with a dispatching sanitize callback that routes per-module data through each module's `sanitize()`.

### `BW_Dev_Brand` (`includes/class-bw-dev-brand.php`)

- `resolve()` returns the brand config array (`plugin_display_name`, `block_category_label`, `block_title_prefix`), passing it through the `bw_dev_brand` filter.
- `inline_for_js()` writes a `<script>` tag with `window.bwDevBrand = {...}` via `wp_add_inline_script` before `wp-blocks`.
- Applied in PHP to the Settings submenu label and the plugin row meta.

### `BW_Dev_Module` (interface)

Every module implements:
- `slug(): string` — e.g. `'favicon'`.
- `label(): string` — e.g. `'Favicon'`.
- `register(): void` — called only if enabled. All `add_action` / `add_filter` happen here.
- `sanitize( array $data ): array` — called by settings dispatcher on save.
- `render_tab(): void` — outputs the settings tab body.
- `default_settings(): array` — initial data when first installed.
- `uninstall(): void` — called by `uninstall.php` (cleanup beyond just removing the root option).

### `BW_Dev_Admin_Page`

Renders `Settings → BW Dev`:
- Top of page: tab nav with `Modules`, `Branding`, then one tab per *enabled* module.
- Form posts to `options.php`; nonce + capability checks handled by Settings API.
- The "Modules" tab handles only the enable map. The "Branding" tab handles brand keys. Each module tab handles its own data via the module's `render_tab()`.

## Module Lifecycle

```
plugins_loaded (priority 10)
  └─ BW_Dev_Plugin::instance()
       ├─ settings = new BW_Dev_Settings();           // reads bw_dev_settings
       ├─ brand    = new BW_Dev_Brand( settings );    // applies bw_dev_brand filter
       ├─ modules  = build registry (5 instances)
       └─ foreach module:
            if settings->get( 'modules', $module->slug() ) === true:
              $module->register();   // module hooks itself in
admin_menu
  └─ BW_Dev_Admin_Page renders Settings → BW Dev
```

## Hooks

### Actions fired by BW Dev

- `bw_dev_loaded` — after modules register, for site-specific code that needs to know which modules are active.
- `bw_dev_module_registered` — per module, after `register()` returns.

### Filters exposed by BW Dev

- `bw_dev_brand` — resolve white-label config. See `BW_Dev_Brand::resolve()`.
- `bw_dev_modules` — array of module instances; can be filtered to add a custom module from a sibling plugin.
- `bw_dev_module_enabled` — `(bool $enabled, string $slug)` override per-module enable.
- `bw_dev_{$slug}_settings` — per-module sanitized settings on read.

## Data Storage

| Option / meta | Type | Purpose |
|---|---|---|
| `bw_dev_settings` | array | All persisted state, nested by module slug |
| `bw_dev_settings['modules']` | array | `[slug => bool]` enable map |
| `bw_dev_settings['brand']` | array | White-label keys |
| `bw_dev_settings['favicon']` | array | `['url' => string]` |
| `bw_dev_settings['admin_columns']` | array | (mirrors legacy `bw_admin_column_settings`) |
| `bw_dev_settings['sticky']` | array | (mirrors legacy `bw_sticky_settings`) |
| `bw_dev_settings['youtube']` | array | `['acf_field' => string]` global default |
| `bw_dev_settings['post_link']` | array | Reserved; pure block plugin has no persisted state yet |

No custom tables. No post meta authored by BW Dev itself (modules may add post meta for their own features — e.g. admin columns reading existing meta is fine).

## External Dependencies

- `vendor/plugin-update-checker` — YahnisElsts plugin update checker v5. Auto-vendored by `tools/new-plugin.sh`.

## Security Notes

- All admin handlers go through the Settings API → nonce + capability automatic.
- AJAX endpoints (admin columns module: `bw_dev_set_featured_image`, `bw_dev_scan_meta_keys`) must call `check_ajax_referer()` and `current_user_can( 'edit_posts' )`. Audit during port.
- REST endpoint (`bw-dev/v1/post-types`) requires `edit_posts` via `permission_callback`.
- White-label brand strings are sanitized as text on save, escaped on output. They are never used as HTML attribute values without `esc_attr()`.
- Block render callbacks use `esc_url()`, `esc_html()`, `wp_kses_post()` as appropriate.
- No `eval`, no dynamic `include`, no `base64_decode` on user input.
