{
  "slug": "bw-dev",
  "name": "BW Dev",
  "version": "1.0.0",
  "download_url": "https://plugins.bowden.works/wp-content/uploads/plugin-updates/bw-dev-1.0.0.zip",
  "download_hash": "sha256:c1352c2f5cf72f992723ab49f8cbb0cd988dcaebf8459f936cb10dbbf42e26a3",
  "download_size": 347705,
  "requires": "6.0",
  "tested": "",
  "requires_php": "7.4",
  "last_updated": "2026-05-14",
  "homepage": "https://plugins.bowden.works/bw-dev/",
  "author": "Bowden Works",
  "description": "Bowden Works dev toolkit — admin columns, favicon, sticky elements, post-link blocks, YouTube embed, menu visibility, SVG uploads, admin notes, and Flywheel auto-update support in one actively-maintained plugin. Required for several site features. See Settings → BW Dev → About.",
  "changelog": "## [1.0.0] - 2026-05-14\n\n### Added\n- Phase 1 settings framework: `BW_Dev_Module_Interface`, `BW_Dev_Settings`, `BW_Dev_Admin_Page`, `BW_Dev_Plugin` singleton.\n- Settings → BW Dev page with Modules, Branding, and per-enabled-module tabs.\n- Single root option `bw_dev_settings` with dispatching sanitize callback that routes per-module data.\n- `bw_dev_modules`, `bw_dev_module_enabled`, `bw_dev_module_registered`, `bw_dev_loaded` filters/actions.\n- **Favicon module** (Phase 2): ported from `bw-favicon`. Injects a custom favicon at priority 9999 on `wp_head`, `admin_head`, and `login_head`. Media-picker UI with live preview. Sanitizes saved URL via `esc_url_raw` — rejects `javascript:` and other unsafe protocols (the original was missing this). **Bulletproofed (2026-05-14)**: when a URL is set, also `remove_action()`s WP core's own `wp_site_icon` from all three head actions so our favicon doesn't fight the Customizer Site Icon output (or anything Kadence routes through it). Module's settings-tab copy explains the \"why this exists\" (forcefully fix Kadence/theme favicon failures) and includes status callouts (Currently active / Feature inactive).\n- **Sticky Elements module** (Phase 3): ported from `bw-sticky-settings`. Repeater UI for multiple sticky elements with per-element top offset, margin-bottom, push-up target, z-index, mobile-disable + breakpoint. Frontend `sticky.js` only loads when at least one element is enabled. Localized config name renamed `bwStickyConfig` → `bwDevStickyConfig`; CSS classes renamed `.bw-is-sticky` → `.bw-dev-is-sticky`, `.bw-is-pushed` → `.bw-dev-is-pushed`, `.bw-sticky-placeholder` → `.bw-dev-sticky-placeholder` (see KNOWN-ISSUES for migration impact). Sanitize clamps `mobile_breakpoint` to 320–1200, enforces `z_index` ≥ 1, and skips rows with empty selectors.\n- **YouTube Block module** (Phase 5): ported from `bw-youtube-embed`. Dynamic Gutenberg block `bw-dev/youtube` that pulls a YouTube URL from an ACF field and renders a responsive 16:9 iframe (falls back to featured image when no video is set). NEW: each block instance can override the ACF field name via the sidebar InspectorControls panel — the global default in Settings → BW Dev → YouTube Block applies when the per-block field is empty. Shortcode `[bw_dev_youtube]` plus a backwards-compat `[bw_youtube]` alias that auto-suppresses when the legacy `bw-youtube-embed` plugin is active (no conflict during side-by-side migration). CSS classes renamed `.bw-youtube-block*` → `.bw-dev-youtube-block*` (see KNOWN-ISSUES). Video-ID regex tightened to `[A-Za-z0-9_-]+` (was `[^?&\"'>\\s]+`, which would accept HTML in malformed URLs and rely on downstream escaping).\n- **Post Link Block module** (Phase 6): ported from `bw-pretty-post-link`. Two server-rendered blocks — `bw-dev/post-link-list` (parent, layout + thumbnail width controls) and `bw-dev/post-link-item` (child, internal post pick or external URL). Two layouts: Simple (bulleted text list) and Thumbnail (16:9 thumb + title row). REST endpoint renamed `bw-ppl/v1/post-types` → `bw-dev/v1/post-types`. CSS classes renamed `.bw-ppl*` → `.bw-dev-post-link*`. No persisted settings; module's settings tab renders inline docs.\n- **Admin Columns module** (Phase 4): ported from `bw-admin-column`. Configures custom admin list-table columns per post type — featured image with click-to-change AJAX quick edit, taxonomy columns, and meta-key columns (scanned from `wp_postmeta`, optionally including private `_*` keys). Sortable meta columns via `pre_get_posts`. Settings tab uses a sub-tab nav per post type; the active sub-tab is selected via `&ptype=<slug>` query param. Hidden fields preserve other sub-tabs' data on save. AJAX action names re-prefixed (`bw_set_featured_image` → `bw_dev_set_featured_image`, `bw_scan_meta_keys` → `bw_dev_scan_meta_keys`); nonces re-prefixed; column IDs re-prefixed (`bw_*` → `bw_dev_*`); CSS classes re-prefixed (`bw-admin-column-*` → `bw-dev-admin-columns-*`). AJAX handlers verified to have nonce + capability checks (the original had them — confirmed during port, no fix required).\n- **White-label branding** (Phase 7): `BW_Dev_Brand` resolver exposes three configurable keys — `plugin_display_name`, `block_category_label`, `block_title_prefix` — defaulted to `BW Dev`, `BW Blocks`, `BW `. The `bw_dev_brand` filter lets mu-plugins force a brand. Plugin name is rewritten in the WordPress plugins list via `all_plugins` filter; Settings submenu title uses the brand label. A new stable block category `bw-dev-blocks` is registered with the brand label and all `bw-dev/*` blocks are reassigned to it via a JS-side `blocks.registerBlockType` filter (default `BW ` title prefix is stripped before the configured prefix is applied, so titles never double up). Saved post content is unaffected by brand changes — category slug stays stable.\n- **Activation migration** (Phase 8): `register_activation_hook` runs `BW_Dev_Migration::on_activation()` which reads legacy options (`bw_favicon_url`, `bw_sticky_settings`, `bw_youtube_embed_settings`, `bw_admin_column_settings`) and writes them into the unified `bw_dev_settings` schema. Conservative merge — only fills in module keys NOT already present in `bw_dev_settings`, so partial migration plus manual setting tweaks survive a re-activation. Tracked via `bw_dev_migration_version` so it never re-runs. Legacy options are NOT deleted; the user manually deactivates source plugins once each module is verified.\n- **SVG Upload module** (extra request, not in the original 5-plugin combine): allows .svg and .svgz uploads with two safety gates — (1) a role-based capability check (default: administrators only; toggle to \"anyone with upload_files capability\"), and (2) sanitization on every upload via DOMDocument. The sanitizer strips `<script>`, `<foreignObject>`, `<iframe>`, `<embed>`, `<object>`, `<animate*>`, `<set>`, `<handler>`, `<a>` elements; every `on*` event attribute; `href`/`xlink:href` values starting with `javascript:` or `data:`; `style` attributes containing `expression()` or `javascript:`; and XML processing-instructions + external DTDs (XXE). 8 attack-payload tests pass; legitimate SVGs preserved. No Composer dependency — vanilla libxml.\n- **Admin Note module** (added by request, ported from the standalone `bw-admin-note` deployed on regent + promobix): adds an internal-only note to posts and pages. Editors get a sidebar `PluginDocumentSettingPanel` for editing and a prominent yellow banner above the first block when a note is set. Per-post-type enable map (default: page). Notes index table in the settings tab lists every post/page with a non-empty note. Post-meta key `_bw_admin_note` is intentionally NOT re-prefixed so existing client sites keep their notes when migrating to bw-dev — same grandfathered-prefix exception as bw-ai-schema-pro. Migration carries `bw_admin_note_settings` → `bw_dev_settings.admin_note` and leaves the legacy option in place.\n- **Flywheel Auto-Updates module** (added by request): fixes WordPress auto-updates on Flywheel-hosted sites. Flywheel's managed cron fires `wp_update_plugins` but never `wp_maybe_auto_update`, so plugins/themes flagged for auto-update never actually update. This module hooks `wp_update_plugins` (priority 20) and manually fires `wp_maybe_auto_update` when in a cron context, with a re-entrancy guard via `doing_action()`. No settings beyond the master on/off toggle. Default-on. Safe on non-Flywheel hosts — `wp_maybe_auto_update` is idempotent.\n- **Menu Visibility module** (added by request): per-menu-item role/login visibility controls. Adds a \"Visibility (BW Dev)\" select on every menu item in `Appearance → Menus` with four modes — Everyone (default), Logged-in only, Logged-out only, and Specific roles (multi-select against `wp_roles()`). Filters `wp_get_nav_menu_items` on the front-end to drop items the current user shouldn't see; hidden parents cascade to children. Filtering is skipped in admin and REST contexts so the menu editor and block/site editor previews always show every item. Per-item meta key `_bw_dev_menu_visibility` (underscore prefix hides it from the custom-fields UI; default-state items store no meta). Group: `frontend`.\n- **Settings page reorganized into a vertical sidebar** with grouped sections: Core (About, Modules, Branding, Flywheel Auto-Updates), Editor & Admin (SVG Upload, Admin Note, Sticky Elements, Admin Columns), Front-end (Favicon, Menu Visibility), and Blocks (YouTube Block, Post Link Block). Replaces the single-row horizontal `nav-tab-wrapper` so the page scales as more block modules are added. Existing `?tab=` bookmarks continue to resolve. New `BW_Dev_Module_Interface::group()` method returns one of `core` / `editor_admin` / `frontend` / `blocks`; modules with an unknown group fall back to Core. Submit button is suppressed on info-only sections (About, Post Link Block, Flywheel Auto-Updates, Menu Visibility) so the misleading \"Settings saved\" toast no longer appears for pages with nothing to save.\n- **About section** as the new default landing tab at `Settings → BW Dev`. Explains what BW Dev is and why it should not be disabled, summarises Bowden Works (web dev / digital marketing agency in Courtenay BC, 18+ years), credits the team — Rian Bowden (rian@rian.ca) and Adi Pramono (info@adipramono.com) — and links to https://bowdenworks.com. Plugin description in `bw-dev.php` header rewritten to flag the plugin as \"required for several site features\" and point at the About tab so a client browsing the WordPress plugins list does not delete it. Author URI moved from `bowden.works` to `bowdenworks.com` (the agency's main marketing site; `plugins.bowden.works` remains the dist host).\n- **New \"Security\" sidebar group** added between Front-end and Blocks. New `BW_Dev_Module_Interface::group()` value `'security'`.\n- **Hide Login URL module** (`hide_login`, group `security`) — replaces the standalone \"WPS Hide Login\" plugin. Custom slug serves the WP login form; canonical `/wp-login.php` and `/wp-admin/` (for guests) return 404 by default or redirect to the homepage per setting. Allowlists `admin-ajax.php` and `admin-post.php` so plugins from the front-end keep working. Outgoing wp-login.php URLs in emails, logout redirects, and password-reset links are rewritten via `site_url`/`network_site_url`/`wp_redirect` filters. Default OFF (empty slug) — module can be enabled but the feature stays inert until a slug is configured. Recovery hatch: define `BW_DEV_HIDE_LOGIN_DISABLE` in wp-config.php to bypass the module entirely (documented in the settings tab). Slug validation: 3–50 chars, lowercase letters/digits/hyphens/underscores only.\n- **Security Hardening module** (`security_hardening`, group `security`) — five independent toggles. (1) Block user enumeration: blocks `/?author=N` redirects for guests via `parse_request` and removes `/wp-json/wp/v2/users` from the REST endpoints map for unauthenticated requests via `rest_endpoints`. (2) Disable XML-RPC: `xmlrpc_enabled` filter, removes `X-Pingback` header, drops `rsd_link`/`wlwmanifest_link` from wp_head. (3) Disable file editing: defines `DISALLOW_FILE_EDIT` if not already defined (removes Plugin Editor and Theme Editor). (4) Strip WP version fingerprints: removes `<meta generator>`, empties `the_generator` filter, strips `?ver=` only when it matches the actual WP version (so plugin-supplied cache-busting is preserved). (5) Disable Application Passwords: returns false for `wp_is_application_passwords_available`. **All five default ON** — safe-baseline security posture for a typical Bowden Works deployment. Sites that need XML-RPC (legacy mobile clients) or Application Passwords (headless WP) can uncheck those toggles in the settings tab.\n- **Login Activity Log module** (`login_log`, group `security`) — records every successful + failed login attempt to a dedicated table `{prefix}bw_dev_login_log` (id, ts, user_login, user_id, ip, user_agent, result; indexed by ts). Cloudflare-aware IP detection prefers `HTTP_CF_CONNECTING_IP` over `REMOTE_ADDR`. Storage discipline: daily wp-cron prune (`bw_dev_login_log_prune` hook) drops entries older than the configured retention (default 30 days, range 1–365), plus a hard row-count ceiling (default 10,000, range 100–1,000,000) that deletes oldest-first regardless of date. \"Purge all\" admin-post action with nonce + capability check. Settings tab shows the most recent 50 entries with success/failure highlighting. Privacy notice in the settings tab about IP logging.\n- **Activation + uninstall**: `BW_Dev_Migration::on_activation()` extended to call `BW_Dev_Module_Login_Log::install_table()` (idempotent via `bw_dev_login_log_db_version` option) and `schedule_cron()` (idempotent via `wp_next_scheduled`). `uninstall.php` extended to drop the login log table, drop `_bw_dev_menu_visibility` post-meta, clear the prune cron, and delete the new options.\n- **Subtitle Block module** (`subtitle`, group `blocks`) — server-rendered Gutenberg block `bw-dev/subtitle`. Eyebrow / kicker text with uppercase letter-spaced styling. Attributes: text, alignment (left/center/right), color (custom ColorPicker — default = inherit theme), URL with same-tab/new-tab toggle. Ported from the standalone `rg-subtitle` block in the Regent Grand Kadence-child theme; the original's hardcoded \"gold/gold-light/white/navy\" Regent palette was replaced with a free ColorPicker so the block fits any client site. Uses `Inter` → system-font fallback for typography.\n- **Separator Block module** (`separator`, group `blocks`) — server-rendered Gutenberg block `bw-dev/separator`. Decorative divider: two horizontal lines flanking either a built-in Unicode glyph (13 to choose from: ✦ ✧ ◆ ◇ ★ ☆ • ◉ ❖ ✿ ❀ ⬥ ⬦) or a custom SVG uploaded via the Media Library. Attributes: align, symbolType (`predefined` | `svg`), symbol, svgId, svgUrl, color. Lines and symbol both use `currentColor`; SVG symbols are rendered via CSS `mask-image` with the URL in a CSS variable, so the picked color colors the SVG regardless of its internal fills (works for any uploaded SVG). Editor surfaces a Notice if the user picks SVG mode while the SVG Upload module is disabled (since SVG mode needs the Media Library to accept .svg uploads). Ported from `rg-separator`; the original's two-color (gold/white) dropdown is replaced by the same free ColorPicker.\n- **Modules tab grouped by sidebar group**: the master enable/disable list at `Settings → BW Dev → Modules` now renders one `form-table` per group (Core / Editor & Admin / Front-end / Security / Indexing / Blocks) with the group label as a section heading, instead of one flat table. Same toggles, same field names — just visually bucketed so the list is easier to scan. Empty groups are skipped automatically.\n- **New \"Indexing\" sidebar group** — 6th group in the sidebar between Security and Blocks, for crawler / AI-readiness features. New `BW_Dev_Module_Interface::group()` value `'indexing'`.\n- **Robots.txt Manager module** (`robots_txt`, group `indexing`) — appends a custom-rules textarea to WordPress's virtual robots.txt via the `robots_txt` filter (priority 20 so we land below Yoast's sitemap line). Settings tab shows a live preview of /robots.txt that includes core defaults + every plugin's contribution + your custom rules. Detects and warns when a physical `robots.txt` exists at `ABSPATH` (in which case WP's filter is bypassed). Intentionally does NOT include an AI bot allow/block list — sites running BW Dev want to BE indexed by AI crawlers.\n- **LLMs.txt Generator module** (`llms_txt`, group `indexing`) — serves `/llms.txt` per the [llmstxt.org](https://llmstxt.org) spec (markdown index of the site's content for LLM consumption) AND `/llms-full.txt` (same index plus full plain-text content of each entry inlined) — both on by default, no user action required. URL interception via `init` priority 1 (no rewrite rules registered). Per-post-type inclusion (defaults to posts + pages; CPTs opt-in), max items per type (default 50), full-mode per-post char cap (default 10,000, truncated at the nearest word boundary). Custom title (defaults to site name), summary (defaults to tagline), optional intro markdown. **SEO-plugin-aware**: pulls each post's hand-crafted SEO meta description (Yoast → Rank Math → SEOPress → AIOSEO post-meta keys) for the bullet description in both files, falling back to post excerpt then auto-trimmed body snippet. In `/llms-full.txt`, also surfaces the SEO meta title (when set and different from post title) on a `SEO Title:` line. Skips values containing unresolved `%%placeholder%%` templates. Both files regenerate on every request — content is always current with no manual rebuild step. Content served as `text/plain; charset=utf-8` with `Cache-Control: public, max-age=3600` and `X-Robots-Tag: noindex, follow`. The `/llms-full.txt` toggle remains so very-large content libraries can disable it if regeneration cost ever becomes visible. Filterable for advanced overrides via `bw_dev_llms_post_types`, `bw_dev_llms_txt_content`, `bw_dev_llms_full_txt_content`. No caching layer in v1 — add a transient if needed later.\n- **Per-group default-enabled state** in `BW_Dev_Settings::is_module_enabled()`. New `GROUP_DEFAULTS` table: Core / Editor & Admin / Front-end / Security / Indexing default ON; **Blocks default OFF** (each block adds an editor inserter entry the site might not want, so they're now opt-in). Resolution is a two-step lookup: if the slug is in the saved `modules` map, that explicit user choice wins; otherwise fall back to the per-group default. Once the user saves the Modules tab, defaults no longer apply to that slug. New filter `bw_dev_module_default_enabled` (`$default, $slug, $group`) lets mu-plugins override per-site. Replaces the previous \"everything defaults true\" behavior — but only affects fresh installs and any module slug not yet in the saved map; existing sites with explicit choices saved are unchanged.\n- **New \"Vendors\" sidebar group** (7th, after Blocks). New `BW_Dev_Module_Interface::group()` value `'vendors'`. GROUP_DEFAULTS for vendors → `true` (group defaults ON).\n- **Theme module** (`theme`, group `vendors`) — companion to the Plugins module under the Vendors group. Lists every installed theme via `wp_get_themes()` with screenshot, name, version, slug, and author. Classifies each as Active / Active parent / Recommended (Kadence parent or Kadence-based child) / Other. Top banner reports setup-vs-ideal status (ideal = exactly two themes: Kadence parent + one Kadence-based child active). Per-row Delete button for non-in-use themes via nonce-gated `themes.php?action=delete&stylesheet=...` URL with JS confirm dialog. Capability gate on `delete_themes`. WordPress refuses deletes on the active theme + its active parent regardless, so those buttons are hidden as a courtesy.\n- **Vendors → Plugins** label rename — module slug stays `vendors` (no migration needed; saved state unaffected) but the user-visible label is now \"Plugins\" so the sidebar reads `Vendors > Plugins` + `Vendors > Theme` instead of `Vendors > Vendors`.\n- **Vendors module** (`vendors`, group `vendors`) — surfaces install/activate status for the third-party plugins Bowden Works typically pairs with BW Dev. Two tiers: **Recommended** (Kadence Blocks, Kadence Blocks Pro [paid], ACF PRO [paid], WebP Express, Yoast SEO, Redirection) and **Optional** (Gravity Forms [paid], Better Search Replace, Customizer Export/Import, Post Types Order). Each row shows a colored status badge (Active / Inactive / Not Installed) and an action button. Free plugins use WP's Plugin Information Thickbox modal for one-click install; **paid plugins** (`paid: true` + `vendor_url`) get a \"PAID\" badge next to the name and a \"Get plugin →\" external link to the vendor site instead of a Thickbox install (since they aren't on wordpress.org). The Activate flow works identically for both — once a paid plugin's files are on disk via the vendor's installer/zip, our nonce-gated `plugins.php?action=activate` link activates it. Plugin name links to vendor URL for paid, wordpress.org listing for free. Detection via `is_plugin_active()` + `file_exists( WP_PLUGIN_DIR . '/<file>' )`. `add_thickbox()` is enqueued only on the BW Dev settings page (gated by `settings_page_bw-dev` hook suffix). No persisted state — pure status display. Replaces the originally-considered single-purpose \"WebP module\" with a generalized recommended-plugins surface. — ports the standalone `established-year` plugin (`/srv/apps/newcap/wp-content/plugins/established-year`). Stores a single establishment date and exposes `[bw_dev_year_number]` (numeric, e.g. \"45\") and `[bw_dev_year_word]` (English words, e.g. \"forty-five\") shortcodes for use in body copy. Backwards-compat aliases `[bw_year_number]` and `[bw_year_word]` register only when the legacy plugin is NOT already registering them (`shortcode_exists()` check) — no collision during side-by-side migration. Year count uses `DateTime::diff` for precision (a company founded 1980-12-31 doesn't tick to \"45\" until 2025-12-31, not 2025-01-01). Word converter handles 0-999. Static helpers `BW_Dev_Module_Established_Year::years_since()` + `number_to_words()` for use in PHP templates. Sanitization validates strict `YYYY-MM-DD` format + `checkdate()` for real-calendar dates. Activation migration: legacy `bw_established_date` option auto-imported into `bw_dev_settings.established_year.date`.\n- **Title Override module** (`title_override`, group `editor_admin`) — adds a \"Page Title Override\" meta box to the post-edit screen for selected post types (default: `page` only; posts + CPTs opt-in via the settings tab). Limited HTML allowed in the override (`<em>`, `<strong>`, `<i>`, `<b>`, `<br>`, `<span class=\"…\">`), sanitized via `wp_kses` on save. Theme-agnostic: hooks the standard `the_title` filter, gated to `is_singular() + in_the_loop() + is_main_query() + post_id matches get_queried_object_id()` so the override applies ONLY to the front-end H1 page heading — never the browser tab title, OG/Twitter/SEO meta tags, admin post list, nav menu item labels, or breadcrumb segments. Storage: `_bw_dev_title_override` post meta (underscore prefix hides it from custom-fields UI; empty values delete the meta to keep `wp_postmeta` tidy). `uninstall.php` extended to drop the meta key on plugin removal. Filterable: `bw_dev_title_override_post_types` for exposing private CPTs in the settings list.\n\n### Fixed\n- **SVG Upload: narrow `wp_check_filetype_and_ext` filter to SVG MIMEs.** The handler ran for every upload and an `unset($data, …)` early in the callback destroyed WordPress's MIME detection result, causing the filter to return an empty array for non-SVG files (which would block unrelated uploads on any site where this module is active). The handler now returns `$data` unchanged for non-SVG extensions and additionally requires the libmagic-detected MIME (`$real_mime`, the 5th filter arg) to look SVG-like (`image/svg+xml`, `image/svg`, `text/xml`, `application/xml`, `text/plain`, empty; `application/gzip` for `.svgz`) before re-labeling the upload as `image/svg+xml`. An `.svg`-named file whose real MIME is e.g. `application/pdf` is no longer relabeled — WordPress rejects it normally.\n\n### Removed\n- Throwaway `BW_Dev_Module_Demo` (Phase 1 lifecycle test article)."
}
