# Custom Guest Authors — Changelog

All notable changes to this plugin are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

---

## [2.3.1] — 2026-03-10

### Fixed
- **Unprefixed variables in `uninstall.php` now carry per-line `phpcs:ignore` annotations** (`uninstall.php`). `$options` (line 22) and `$option` (line 32) are genuinely global-scope variables in the uninstall context — WordPress calls `uninstall.php` directly outside any function — so the Plugin Check flag is technically correct. Per-line `// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound` added on each declaration.
- **Direct database query in `uninstall.php` annotated inline** (`uninstall.php`). The `$wpdb->query()` bulk transient `DELETE` at line 42 already had a `phpcs.xml` `<exclude-pattern>` exclusion for both `WordPress.DB.DirectDatabaseQuery.DirectQuery` and `WordPress.DB.DirectDatabaseQuery.NoCaching`, but Plugin Check does not honour `phpcs.xml` exclusions for either rule. Per-line `// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching` added, consistent with the equivalent queries in `includes/cache.php` and `admin/views/settings-page.php`.
- **Unprefixed variables in view templates now suppressed via inline `phpcs:disable` / `phpcs:enable` fences** (`admin/views/settings-page.php`, `admin/views/tab-debug.php`). Both files are loaded via `require_once` inside `cga_render_settings_page()`, so all variables in them exist in function scope, not global scope. Plugin Check cannot trace the `require_once` call-site and incorrectly classifies them as globals. A `phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound` fence — opening after the `ABSPATH` guard and closing at the final line — covers all 83 flagged variables with two directives. A `phpcs:enable` closes each fence at end-of-file inside a `<?php ?>` tag.
- **`phpcs.xml` comment updated** (`phpcs.xml`). The `NonPrefixedVariableFound` rule comment now documents that Plugin Check does not honour `<exclude-pattern>` for this rule and that the inline `phpcs:disable` fences in both view files serve as the Plugin Check fallback; the `<exclude-pattern>` applies to local PHPCS runs only.

---

## [2.3.0] — 2026-03-10

### Added
- **`cga_get_option( $option, $default )`** in `includes/front-end.php` — per-request static cache wrapper around `get_option()`. Results are memoised in a `static $cache = array()` so repeated reads of the same option key within a single PHP request cost one array lookup instead of a `get_option()` call each time. All nine `get_option()` call sites in `includes/front-end.php` replaced with `cga_get_option()`. (Issues A + B)
- **`cga_flush_option_cache()`** in `includes/front-end.php` — resets the static cache array. Called from `custom_guest_authors_invalidate_cache()` in `includes/cache.php` after a post save so that any option read later in the same request (e.g. during a REST response cycle) picks up a fresh value. (Issue A)

### Changed
- **Schema hook registration is now conditional** (`includes/front-end.php`). `add_filter( 'wpseo_schema_graph', 'cga_suppress_yoast_author' )` and `add_filter( 'rank_math/schema/article', 'cga_suppress_rankmath_author' )` are now registered inside an `add_action( 'init', ... )` callback that first checks `cga_get_option( 'cga_suppress_schema', false )`. When the option is disabled, neither filter is ever added to the hook table — no filter invocation overhead on every schema-producing request. The redundant option check previously inside each callback has been removed. (Issue C)
- **`cga_register_post_meta()` wrapped in `function_exists()` guard** (`includes/post-meta.php`). Prevents a fatal error if the function is somehow declared twice (e.g. in a mu-plugins context that includes the file early). (Issue K)
- **Redundant `post_type_exists()` conditions removed** (`includes/post-meta.php`). The `|| 'post' === $post_type || 'page' === $post_type` conditions in the `add_post_type_support()` loop were unnecessary: `post_type_exists()` returns `true` for all registered post types including `post` and `page` at `init` priority 9. Simplified to `if ( post_type_exists( $post_type ) )`. (Issue N)
- **`phpcs.xml` EscapeOutput suppression narrowed from file-level to per-line** (`phpcs.xml`, `admin/views/settings-page.php`). The file-level `<rule ref="WordPress.Security.EscapeOutput.OutputNotEscaped"><exclude-pattern>*/admin/views/settings-page.php</exclude-pattern></rule>` block has been removed. A `// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped` inline annotation is now placed on the specific `echo` statement that outputs the hardcoded SVG string concatenated with `esc_html__()` output. This is the minimum suppression scope permitted by the project's `phpcs.xml` policy. (Issue O)
- **Gutenberg sidebar plugin icon replaced with inline SVG** (`assets/js/gutenberg-sidebar.js`). The `icon: 'admin-users'` dashicon string in `registerPlugin()` has been replaced with an inline SVG constructed via `wp.element.createElement('svg', ...)` — a two-path person silhouette (circle for head, arc for shoulders). This removes the dependency on the WordPress dashicon font being present in the browser, which is not guaranteed in all block editor contexts or third-party admin skins. (Issue I)
- **CSS custom properties scoped from `:root` to wrapper selectors** (`assets/css/settings.css`, `assets/css/meta-box.css`, `assets/css/gutenberg-sidebar.css`). `:root { --wpcp-* }` token declarations in all three files replaced with scoped selectors: `.cga-wrap` (settings page), `#cga-meta-box` (classic editor meta box), `.cga-sidebar-panel` (Gutenberg sidebar). This prevents token values from leaking into the host page and conflicting with other plugins that may declare identically-named custom properties. The previously duplicate `.cga-wrap { max-width; font-family; margin-top }` layout rule in `settings.css` has been merged into the single `.cga-wrap` token block. (Admin Gap 1)

### Fixed
- **Stale transient count after Clear Cache** (`admin/views/settings-page.php`). After the `DELETE FROM wp_options WHERE option_name LIKE '_transient_cga_%'` query, `wp_cache_delete( 'cga_transient_count', 'custom-guest-authors' )` is now called immediately. Without this, the object cache (if a persistent cache such as Redis or Memcached is active) would continue serving the pre-delete count for the remainder of the object cache TTL, causing the Debug tab to show a non-zero count after a successful clear. (Issue F)
- **`$GLOBALS['post']` could leak out of the filter simulation on exception** (`admin/views/tab-debug.php`). Both the automatic diagnostic check (check 13, line ~156) and the manual post-ID tester (line ~246) set `$GLOBALS['post']` to a test post object before calling `custom_guest_authors_name()` and relied on restoring the original value in the line immediately after. If `custom_guest_authors_name()` threw an uncaught exception or triggered a fatal error mid-execution, the restore line would never run, permanently corrupting the `$post` global for the remainder of the request. Both blocks now use `try { ... } finally { $GLOBALS['post'] = $saved_post; }` to guarantee restoration. (Issue M)
- **Toggle switch `focus-visible` outline missing** (`assets/css/settings.css`). The existing `.wpcp-toggle-switch input:focus + .wpcp-toggle-track` rule used `:focus` rather than `:focus-visible`, which caused the focus ring to appear on mouse click as well as keyboard navigation — the opposite of expected browser behaviour. Replaced with `.wpcp-toggle-switch input:focus-visible + .wpcp-toggle-track` and added `outline: 2px solid var(--wpcp-secondary); outline-offset: 2px` to meet WCAG 2.1 Success Criterion 2.4.7 (Focus Visible) for keyboard-only users. (Admin Gap 5)

---


## [2.2.0] — 2026-02-27

### Added
- **`uninstall.php`** — new file executed by WordPress when the plugin is deleted via the admin dashboard (distinct from deactivation). Removes all seven plugin options (`cga_default_guest_author`, `cga_enabled_post_types`, `cga_join_style`, `cga_apply_on`, `cga_cache_ttl`, `cga_suppress_schema`, `cga_cache_version`) via `delete_option()`, then runs a direct `DELETE FROM wp_options WHERE option_name LIKE '_transient_cga_%' OR option_name LIKE '_transient_timeout_cga_%'` to purge all cached author name transients. Without this, options and transients would persist in the database indefinitely after deletion.
- **`assets/js/meta-box.js` rewritten as vanilla JavaScript.** The previous version declared `jquery` as a dependency in `wp_enqueue_script()` and used `jQuery( document ).ready()` to wire up the character counter and clear button on the classic editor meta box. jQuery is now entirely absent from the script; the dependency array in `wp_enqueue_script()` is empty. This reduces the number of HTTP requests on the edit screen for sites that do not otherwise load jQuery in the admin.
- **`@var` contract docblock added to `admin/views/tab-debug.php`.** The file is loaded via `require_once` from inside a conditional block in `admin/views/settings-page.php`, where local PHP variables are set immediately before the `require_once` call and thus become available inside the included file via variable scope inheritance. The docblock documents all eight injected variables (`$enabled_types`, `$all_post_types`, `$join_style`, `$suppress_schema`, `$cache_ttl`, `$transient_count`, `$active_types_labels`, `$clear_cache_url`) with their types, so static analysis tools and IDEs do not flag them as undefined.

### Changed
- **Asset directories consolidated from `css/` and `js/` to `assets/css/` and `assets/js/`** (`admin/admin.php`). The flat `css/` and `js/` directories at the plugin root have been merged under a top-level `assets/` directory, matching the layout convention used by WooCommerce, Jetpack, and WordPress core bundled plugins. All four `wp_enqueue_style()` and `wp_enqueue_script()` call sites in `admin/admin.php` that referenced `CGA_PLUGIN_URL . 'css/'` or `CGA_PLUGIN_URL . 'js/'` updated to use `CGA_PLUGIN_URL . 'assets/css/'` and `CGA_PLUGIN_URL . 'assets/js/'` respectively.
- **`phpcs.xml` audited and corrected throughout.** All `<exclude-pattern>` paths updated to reflect the restructured `assets/` directory layout. Three duplicate rule entries removed. `uninstall.php` added to the `WordPress.DB.DirectDatabaseQuery.DirectQuery` and `WordPress.DB.DirectDatabaseQuery.NoCaching` exclusions to cover the bulk transient DELETE query it runs on plugin deletion. All descriptive comment prose updated to reference current file paths rather than the former flat `css/`/`js/` layout.
- **`CGA_NO_META` constant moved from `includes/front-end.php` to the bootstrap file `custom-guest-authors.php`.** Previously defined at the top of `includes/front-end.php`, the constant was unavailable to `includes/cache.php` and `admin/views/tab-debug.php` unless those files happened to load after `front-end.php` in the require chain. Moving it to the bootstrap guarantees it is defined before any conditional includes are loaded, regardless of load order.
- **Plugin file header completed** (`custom-guest-authors.php`). `Requires at least: 5.7`, `Requires PHP: 8.2`, and `Tested up to: 6.9` fields were absent from the `Plugin Name:` header block. WordPress and Plugin Check both surface warnings when these fields are missing; they are now present and accurate.
- **Indentation in all PHP source files converted from 4-space to tabs** to conform to the WordPress Coding Standards. PHPCS `Generic.WhiteSpace.DisallowSpaceIndent` was producing warnings on every indented line across all files. All six PHP files converted in a single pass; no logic changes.

---

## [2.1.0] — 2026-02-22

### Changed
- **Settings page completely redesigned** (`assets/css/settings.css`, `admin/views/settings-page.php`). The previous design used a gradient card header aesthetic from v2.0.0 that diverged from the rest of the MENJ plugin suite. The redesign aligns the page with the Endmark and Auto Justify Content admin UI patterns established across the suite.
  - **Dark hero header.** The gradient page header replaced with a full-width dark band (`background: #111d27`) spanning the full card width. Contains the plugin name in white, a one-line tagline in muted grey, and a version badge. Matches the Endmark hero pattern exactly.
  - **Underline tab navigation.** Pill-style filled tab buttons replaced with underline-indicator tabs. The active tab is indicated by a `2px solid` bottom border in teal (`#2E6A8E`) rather than a filled background. Tabs sit flush against the bottom edge of the hero header with no visual gap between the two zones. The Debug tab is pushed to the far right via a `margin-left: auto` flexbox spacer.
  - **Plain white card bodies with uppercase muted section labels.** Gradient card headers removed from all `.wpcp-card` blocks. Each settings group is now introduced by a `<p class="wpcp-section-label">` element — `font-size: 0.70rem`, `letter-spacing: 0.10em`, `text-transform: uppercase`, colour `#94a3b8` — matching the `ENDMARK TYPE` label convention used in the Endmark settings page.
  - **AJC-style toggle switch for schema suppression.** The `<input type="checkbox">` for `cga_suppress_schema` replaced with a full-width `.wpcp-toggle-row` — label and description text in a flex column on the left, the switch component on the right — matching the Auto Justify Content layout for its content-justification toggle.
  - **AJC-style Save Settings button.** `text-transform: uppercase` and `letter-spacing` removed. Button is now a solid filled rectangle with sentence-case label, matching the AJC Save button style.
  - **`Developed by MENJ` footer link** added at the bottom of every tab panel, linking to `https://github.com/menj`.
  - **Field label/sublabel split.** Single `<label>` elements carrying both the field name and its description replaced with a two-element structure: `<span class="wpcp-field-label">` for the name and `<span class="wpcp-field-sublabel">` for the description, allowing independent typography control for each layer.
- **`phpcs.xml` updated to scan all plugin PHP files.** The previous `phpcs.xml` excluded `admin/views/` from scanning. The exclusion has been removed and all 34 inline `phpcs:ignore` comments that were previously necessary to suppress PHPCS warnings on a file-by-file basis have been removed. Suppressions are now declared centrally in `phpcs.xml` with documented justifications, making the suppression rationale auditable in one place rather than scattered across source files.

---

## [2.0.9] — 2026-02-21

### Added
- **`CGA_NO_META` constant** defined in `includes/front-end.php` (later moved to bootstrap in v2.2.0). Value: `'__cga_none__'`. Replaces the inline string literal `'__cga_none__'` that was repeated in three separate functions. A single constant definition ensures that a typo in any one location cannot cause a silent sentinel mismatch — if the stored transient value and the comparison value were ever to differ by even one character, the cache would fail to detect confirmed DB misses and would re-query on every request.
- **`cga_get_authors( $post_id )`** (`includes/front-end.php`) — shared helper encapsulating the full transient cache read/write path: cache miss → `get_post_meta()` read → `set_transient()` with `CGA_NO_META` or the real value → return; cache hit with `CGA_NO_META` → return `''`; cache hit with value → return value. Previously, all three functions that needed this logic (`custom_guest_authors_name()`, `custom_guest_authors_name_meta()`, `custom_guest_authors_suppress_url()`) each contained a full copy of the cache read/write block — approximately 20 lines duplicated three times. Any bug fix or TTL change required updating all three copies in sync.
- **`cga_format_authors( $raw )`** (`includes/front-end.php`) — shared helper encapsulating the full explode → trim → `sanitize_text_field` → filter empties → count → join pipeline. Previously duplicated in full inside both `custom_guest_authors_name()` and `custom_guest_authors_name_meta()`. Any change to join logic (e.g. adding a new join style) previously had to be applied in two places.

### Fixed
- **`custom_guest_authors_name_meta()` did not respect the "Show Override On" (`cga_apply_on`) setting** (`includes/front-end.php`). When `cga_apply_on` was set to `singular`, the `the_author` filter path (used by classic themes) correctly suppressed per-post overrides on non-singular contexts via a `$block_on_ctx` gate. The `get_the_author_display_name` filter path (used by all block themes via `core/post-author` and `core/post-author-name` blocks) had no equivalent gate — the check was simply absent. On Twenty Twenty-One through Twenty Twenty-Five and all FSE themes, per-post guest author names were appearing on category, tag, and date archive pages even when the setting was set to singular only. The `$block_on_ctx` gate is now applied identically in both filter callbacks.
- **`custom_guest_authors_strip_link()` was calling `get_post_meta()` directly**, bypassing the transient cache (`includes/front-end.php`). On an archive page listing 10 posts, this added 10 uncached `get_post_meta()` calls per render, one per post per author-link occurrence, regardless of whether the same post's meta had already been read and cached by `custom_guest_authors_name()` earlier in the same request. The function now calls `cga_get_authors()`, which reads from the `cga_{post_id}` transient and falls back to `get_post_meta()` only on a genuine cache miss.
- **`useEntityProp` fallback in `gutenberg-sidebar.js` used `postType || 'post'` as the entity type.** On the initial render pass before the block editor's core-data store has completed hydration, `useSelect( select => select('core/editor').getCurrentPostType() )` returns `undefined`. With the `|| 'post'` fallback, the hook was reading `guest-author` meta from the `post` entity type. On a Page edit screen or a custom post type screen, this would transiently display the `guest-author` meta value of the most recently cached `post` entity in the store — a completely unrelated value — until the correct post type resolved and the hook re-rendered. Changed to `postType || ''`, so the hook passes an empty string on the unresolved pass, reading from a non-existent entity and returning `undefined` harmlessly rather than reading from the wrong type.
- **Version-based cache flush in `includes/cache.php` called `update_option()` after the `DELETE` query.** The flush runs once on `init` when `cga_cache_version` in the database does not match `CGA_VERSION`. If the `DELETE FROM wp_options WHERE option_name LIKE '_transient_cga_%'` query succeeded but the subsequent `update_option( 'cga_cache_version', CGA_VERSION )` call then failed (e.g. due to a transient write error on a DB with table locks), the new version would never be recorded. On the next request, the version check would fail again and the `DELETE` would re-run — on every request, indefinitely, for the lifetime of the install. Order reversed: `update_option()` runs first, then the `DELETE`. If `DELETE` fails, the version is already recorded so the flush does not re-run. A partial flush is acceptable — the next `save_post` will re-prime affected transients correctly.
- **Duplicate docblock** before `cga_render_settings_page()` in `admin/admin.php` removed. A dead PHPDoc stub copied from an earlier refactor iteration was present immediately before the live docblock, producing a doubled comment visible in IDEs and phpDocumentor output.
- **Debug tab transient display** now shows the human-readable label `(cached — no guest-author meta on this post)` instead of the raw sentinel string `__cga_none__` when a post's transient value is `CGA_NO_META`. The raw value was surfaced in the "Cached value" column and was meaningless to anyone reading the Debug tab without knowledge of the internal cache implementation.

---

## [2.0.8] — 2026-02-21

### Added
- **Live diagnostics panel on the Debug tab** (`admin/views/tab-debug.php`). Thirteen server-side checks run on every page load of the Debug tab and render as a pass/fail/info table:
  1. Plugin bootstrap loaded (`CGA_VERSION` defined).
  2. `includes/front-end.php` loaded (function existence check on `custom_guest_authors_name`).
  3. `the_author` filter registered at the expected priority.
  4. `get_the_author_display_name` filter registered at the expected priority.
  5. `cga_enabled_post_types` option value (informational).
  6. `cga_apply_on` option value (informational).
  7. `cga_cache_ttl` option value (informational).
  8. `cga_suppress_schema` option value (informational).
  9. Most recent published post of an enabled type located.
  10. `guest-author` meta value for that post (informational).
  11. `custom-fields` support declared for the post's post type.
  12. `guest-author` meta registered for REST API access (required for Gutenberg sidebar read/write).
  13. Filter simulation: calls `custom_guest_authors_name()` directly with `$GLOBALS['post']` temporarily set to the test post, comparing its return value against the WordPress-registered display name.
  - Manual post-ID tester: a GET-parameter-driven form (`?cga_test_id=N`) that runs the same simulation for any post ID entered by the user, reporting meta value, post type enablement, and filter output side-by-side.

### Fixed
- **Root cause of author name never appearing on block themes (TT25, TT24, and all FSE themes)** (`includes/front-end.php`). WordPress's `get_the_author_meta()` function fires a *dynamic* filter whose name is constructed at runtime: `apply_filters( "get_the_author_{$field}", $value, $user_id, $original_user_id )`. For the `display_name` field, this resolves to the filter name `get_the_author_display_name`. The plugin had been registering `add_filter( 'get_the_author_meta', ... )` since v2.0.5 — a filter name that does not exist anywhere in WordPress core. The callback was never invoked on any request. Block themes render the author name exclusively through the `core/post-author` and `core/post-author-name` blocks, which call `get_the_author_meta( 'display_name', $author_id )` internally — the classic `the_author()` path is never used. Correcting the registration to `add_filter( 'get_the_author_display_name', ... )` fixes author name substitution on all block theme frontends.
- **Stale empty-string transient cache permanently blocking the default guest author** (`includes/front-end.php`, `includes/cache.php`). Versions prior to 2.0.8 stored `""` (empty string) as the `set_transient()` value for posts with no `guest-author` meta, intending it to signal a confirmed DB miss. However, `get_transient()` returns `false` only on a genuine cache miss (key absent or expired); it returns `""` on a cache hit whose stored value is the empty string. The code compared `if ( false === $cached )` to detect misses — so `""` was always treated as a valid hit, the DB read was always skipped, and the default guest author fallback was never reached for posts without per-post meta. The sentinel value is now `'__cga_none__'` (defined as `CGA_NO_META`), a string that cannot legitimately appear as a post meta value, so the miss/hit distinction is unambiguous.
- **Automatic cache flush on plugin update** (`includes/cache.php`). Any site upgrading from a version that stored `""` as the sentinel would have stale transients for every post that had ever been viewed before a guest author was set. These transients would block the default guest author indefinitely until they expired naturally (up to 168 hours). A version-based flush now runs on `init` when `cga_cache_version` in the database does not match `CGA_VERSION`: all `_transient_cga_*` and `_transient_timeout_cga_*` rows are deleted in a single query, forcing a fresh DB read on the next request for each post.
- **Default guest author not appearing on archive and listing pages** (`includes/front-end.php`). The `cga_apply_on = 'singular'` context gate was positioned in the code flow before the `cga_default_guest_author` fallback. On non-singular contexts (archive, category, tag, search pages), the function was hitting the `$block_on_ctx` early-return branch and exiting before ever checking the default. The gate is now scoped exclusively to per-post meta overrides; the site-wide default applies on all page types unconditionally.
- **Filter hooks raised to priority 20** (`includes/front-end.php`). A conflict was identified where another plugin registered a callback on `the_author` at priority 8 (`ent2ncr`) that could transform the display name string before CGA's callback at the default priority 10 had a chance to replace it. Raising CGA's hooks to priority 20 guarantees they run after any early transformations at priorities 1–19.
- **`$post` global resolution hardened in all three front-end filter functions** (`includes/front-end.php`). All three functions previously assumed `$GLOBALS['post']` or the implicit `get_post()` return would be a valid `WP_Post` on every invocation. In FSE (Full Site Editing) contexts, `setup_postdata()` is not always called before blocks render — `$GLOBALS['post']` can be `null` on the first render pass. In nested `WP_Query` loops, `get_post()` returns the inner query's current post, not the outer one. All three functions now call `get_post()` first, then fall back to `get_queried_object()` cast to `WP_Post` if `get_post()` returns `null`, and exit cleanly with the original value if neither resolves.

---

## [2.0.4] — 2026-02-21

### Fixed
- **Guest author names saved via the Gutenberg sidebar panel were silently discarded on save** (`includes/post-meta.php`). WordPress's REST API endpoint for post meta (`POST /wp-json/wp/v2/{post-type}/{id}`) calls `update_post_meta_fields()` internally. That function only writes meta for post types that declare `'custom-fields'` in their registered `supports` array — a requirement separate from and additional to `register_post_meta()` with `show_in_rest => true`. Without the support flag, `useEntityProp()` in the Gutenberg sidebar correctly reads and displays the current meta value and correctly updates its local React state when the editor types a new name, but on save the REST endpoint silently skips writing the meta and returns a `200 OK` response with no error indication to the client. The editor appears to save successfully; the value reverts on next page load. The plugin now calls `add_post_type_support( $post_type, 'custom-fields' )` on `init` at priority 9 — before `cga_register_post_meta()` at priority 10 — for every post type in the `cga_enabled_post_types` option.

---

## [2.0.2] — 2026-02-20

### Changed
- **Plugin architecture refactored from a 1,175-line monolith into conditional includes** (`custom-guest-authors.php` and new includes). The original single-file plugin loaded all 1,175 lines on every WordPress request — 901 lines of admin-only code (meta box rendering, settings page, asset enqueuing, settings registration) were parsed and compiled by PHP's opcode engine on every front-end page view, even though none of it executes outside `is_admin()`. The plugin is now split into purpose-built files loaded via conditional `require_once` calls in the bootstrap:
  - `custom-guest-authors.php` — bootstrap only: constants, `load_plugin_textdomain()`, conditional loader (~51 lines, always parsed).
  - `includes/front-end.php` — `the_author` filter, `get_the_author_display_name` filter, URL suppression, link stripper, schema suppression (always loaded; no admin code).
  - `includes/cache.php` — `save_post`, `updated_post_meta`, `added_post_meta`, `deleted_post_meta` invalidation hooks; version-based flush (always loaded).
  - `includes/post-meta.php` — `register_post_meta()` and `add_post_type_support()` on `init` (always loaded; required for REST API and Gutenberg).
  - `admin/admin.php` — meta box registration, `wp_enqueue_style()`/`wp_enqueue_script()`, `cga_register_settings()`, redirect hooks (admin-only; loaded only when `is_admin()` returns true).
  - `admin/views/settings-page.php` — settings page HTML template (admin-only; loaded on demand inside `cga_render_settings_page()`).

### Fixed
- **`return esc_html( $name )` in the author name filter was returning HTML-escaped text** (`includes/front-end.php`). WordPress filter callbacks that operate on data (as distinct from callbacks that produce HTML output) must return raw, unescaped strings. Escaping is the responsibility of the theme or template at the point of output, not of the filter that manipulates the data. With `esc_html()` applied in the filter, any author name containing `&`, `<`, `>`, `'`, or `"` would display as a literal HTML entity on screen — e.g. a name like `O'Brien` would appear as `O&#039;Brien`, and `Smith & Jones` as `Smith &amp; Jones`. Removed `esc_html()`; values returned from the filter are already sanitized via `sanitize_text_field()` at the point they are saved.
- **`Domain Path: /languages` missing from plugin file header** (`custom-guest-authors.php`). WordPress uses the `Domain Path` header to locate `.mo` translation files relative to the plugin root when `load_plugin_textdomain()` is called without an explicit path argument. Without this header, `load_plugin_textdomain()` defaults to looking in the plugin's root directory rather than the `languages/` subdirectory. Plugin Check also flags its absence as a header completeness warning.

---

## [2.0.1] — 2026-02-20

### Fixed
- **Fatal PHP parse error on activation** (`admin/views/settings-page.php`). A rogue backslash character was present immediately before `$_GET` in the Clear Cache handler — `\$_GET` — which PHP 8.x interprets as an invalid escape sequence inside a double-quoted string context, producing a parse error at compile time. Because WordPress compiles the plugin file before calling the activation hook, the plugin could not be activated at all; the admin showed "Plugin could not be activated because it triggered a fatal error."
- **`load_plugin_textdomain()` was absent from the bootstrap** (`custom-guest-authors.php`). Without this call, WordPress never loads the bundled `ms_MY.mo` translation file and all `__()` / `_e()` wrapped strings remain in English regardless of the site locale. The call has been restored on the `init` hook with the correct `languages` subdirectory path.
- **`$_GET['cga_action']` accessed without `wp_unslash()` or `sanitize_key()`** (`admin/views/settings-page.php`). WordPress wraps all superglobal input in `addslashes()` via `wp_magic_quotes()` on startup. Reading `$_GET` values without first calling `wp_unslash()` can yield double-escaped strings. `sanitize_key()` is additionally required before using the value in a comparison. The Clear Cache handler now uses `sanitize_key( wp_unslash( $_GET['cga_action'] ) )` with a corresponding `phpcs:ignore WordPress.Security.ValidatedSanitizedInput` annotation.
- **Clear Cache success `echo` statement was missing a `phpcs:ignore WordPress.Security.EscapeOutput` annotation** (`admin/views/settings-page.php`). The statement outputs a hardcoded SVG string concatenated with an `esc_html__()` return value. PHPCS cannot statically verify that a string concatenation is safe even when all dynamic parts are escaped, and flags it as a potential `OutputNotEscaped` violation. The annotation has been added on the specific line.
- **Duplicate `global $wpdb` declaration** in the Debug tab handler consolidated (`admin/views/settings-page.php`). `global $wpdb` was declared twice in the same scope — once at the top of the Debug tab block and once inside the Clear Cache conditional. PHP silently ignores duplicate `global` declarations, but PHPCS flags `WordPress.DB.DirectDatabaseQuery` in unexpected ways when the global is re-declared. Consolidated to a single declaration at the top of the block.

### Updated
- **`Requires PHP` corrected from `8.5` to `8.2`** (`custom-guest-authors.php`, `readme.txt`). The field was set to a PHP version that does not exist. The actual minimum tested and supported version is PHP 8.2.
- **`testVersion` in `phpcs.xml` corrected from `8.5-` to `8.2-`**. The PHPCompatibility sniff uses `testVersion` to determine which PHP version compatibility rules to apply. An incorrect `8.5-` value caused PHPCompatibility to flag several PHP 8.2-valid constructs as potentially incompatible.

---

## [2.0.0] — 2026-02-20

### Added
- **Debug tab** (`admin/views/settings-page.php`, `admin/views/tab-debug.php`). Debug Information, which previously occupied a read-only card at the bottom of the Advanced tab, has been promoted to its own fourth tab with a dedicated darker card header variant (`wpcp-card-header--dark`). The tab is separated from the three settings tabs by a `margin-left: auto` spacer in the flexbox tab strip, positioning it at the far right edge.
- **System Information card on the Debug tab.** Displays plugin version (sourced from `CGA_VERSION`), WordPress version (sourced from `get_bloginfo('version')`), and PHP version (sourced from `phpversion()`), each with a colour-coded status pill: teal (`wpcp-status-pill--ok`) when the value meets the declared minimum, stone (`wpcp-status-pill--warn`) when it is below minimum.
- **Cache Status card on the Debug tab.** Displays the configured cache TTL (in hours, sourced from `cga_cache_ttl`) and the current count of active `cga_*` transients (sourced from a `SELECT COUNT(*) FROM wp_options WHERE option_name LIKE '_transient_cga_%'` query, wrapped in `wp_cache_get()`/`wp_cache_set()` to avoid running the count query more than once per page load). A **Clear Cache** button in the card header triggers a nonce-verified `GET` action (`cga_action=clear_cache`) that runs `DELETE FROM wp_options WHERE option_name LIKE '_transient_cga_%' OR option_name LIKE '_transient_timeout_cga_%'` and refreshes the page with a success notice.

### Changed
- **Settings UI completely redesigned** (`assets/css/settings.css`, `admin/views/settings-page.php`). The plain single-page form of previous versions replaced with a tabbed layout. All card headers now use a `linear-gradient(135deg, #1B3C53 0%, #2E6A8E 100%)` navy-to-teal gradient with white text, replacing the previous flat `#f8f9fa` light-grey headers. A warm stone bar (`background: #C8BAB0`) serves as a decorative divider beneath the page header. Tab active state uses a filled navy pill (`background: #1B3C53`, `color: #fff`) rather than the underline indicator style adopted in v2.1.0.

### Fixed
- **Submit button was appearing on the Debug tab** (`admin/views/settings-page.php`). The `<p class="submit">` block containing the Save Settings button was rendered unconditionally after the tab panel content, so it appeared below the read-only debug output even though the Debug tab has no saveable fields. The button is now conditionally rendered only when `$active_tab !== 'debug'`.

---

## [1.9.1] — 2026-02-20

### Added
- **`phpcs.xml`** added to the plugin root. Declares `cga_` and `custom_guest_authors_` as authorised global symbol prefixes for the `WordPress.NamingConventions.PrefixAllGlobals` sniff. Also pins `minimum_wp_version` to `5.7` for the `WordPress.WP.DeprecatedFunctions` sniff and `testVersion` to `8.2-` for the PHPCompatibility sniff.

### Fixed
- **`WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound` warnings persisted in Plugin Check** despite the prefix being declared in `phpcs.xml`. Plugin Check runs PHPCS internally but derives the expected prefix from the plugin slug (`custom-guest-authors`) rather than reading `phpcs.xml`; it expects the prefix `custom_guest_authors_` and flags any function not matching that pattern. All 15 function declarations in the plugin that use the shorter `cga_` prefix now carry individual `// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound` inline annotations. The `phpcs.xml` declaration is retained for local PHPCS runs where both prefixes should be accepted.

### Updated
- **`Requires PHP` bumped from `7.0` to `8.2`** (`custom-guest-authors.php`, `readme.txt`). The plugin uses named arguments, `match` expressions, and `str_contains()` — all PHP 8.0+ features. PHP 7.0 support was never meaningful; corrected to reflect actual runtime requirements.
- **`Tested up to` confirmed at `6.9`** (`readme.txt`). Field updated to reflect the most recent WordPress version against which the plugin has been tested.

---

## [1.8.9] — 2026-02-20

### Added
- **`assets/js/settings.js`** (previously `js/settings.js`). Handles two interactive behaviours on the settings page: (1) a delegated `change` event listener on the settings form that toggles `.selected` on radio card containers and `.wpcp-checkbox-card--checked` on checkbox card containers immediately on interaction, keeping visual state in sync with the underlying input state; (2) a live join-style preview that reads the selected `cga_join_style` radio value and rebuilds the preview string client-side using `cgaSettings.previewNames` (an array of example names) and `cgaSettings.i18nAnd` (the translated conjunction), then writes the result to both preview `<span>` elements. Script is enqueued in the footer via `wp_enqueue_script()` on the `admin_enqueue_scripts` hook, loaded only on the plugin's settings page hook suffix.
- **`wp_localize_script()`** call added in `admin/admin.php` to pass the `cgaSettings` object to `settings.js`. Provides `previewNames` (array of three example author names for the join-style preview, translatable) and `i18nAnd` (the translated "and" conjunction, used to construct the natural-language preview string client-side in a locale-aware way).

### Fixed
- **Radio cards and checkbox cards showed no visual feedback when clicked** (`assets/js/settings.js`). The `.selected` and `.wpcp-checkbox-card--checked` classes that drive the card highlight border and tick-mark were previously only set server-side when the settings page was rendered — correct on initial page load, but never updated on interaction without a full page save. A delegated `change` listener on the form now applies and removes the classes immediately on every input change.
- **Join style preview on the Display tab was static** (`assets/js/settings.js`). The preview showing how multiple author names would appear (e.g. "Alice, Bob and Carol") was rendered server-side once at page load and did not update when the user switched between Natural, Comma, and Ampersand radio cards. The script now rebuilds the preview string on every `change` event and writes it to the DOM without a round-trip to the server.
- **Active settings tab was not reliably preserved after saving** (`admin/admin.php`). The Settings API posts to `options.php`, which calls `wp_safe_redirect()` internally after saving. The existing `cga_settings_redirect` filter was registered on `wp_redirect` only — not on `wp_safe_redirect`. `wp_safe_redirect()` does not call `wp_redirect`; it calls `wp_sanitize_redirect()` and then `header()` directly. The tab `?tab=` query parameter was being silently dropped from the redirect URL. The redirect filter is now registered on both `wp_redirect` and `wp_safe_redirect` at priority 10.
- **`custom_guest_authors_suppress_url()` was calling `get_post_meta()` directly on every author link render**, bypassing the transient cache (`includes/front-end.php`). On an archive page listing 10 posts, the `author_link` filter fires once per post. Each call to `get_post_meta()` produces a DB round-trip unless WordPress's own meta cache has the value already (which it may not on FSE themes). The function now reads from the `cga_{post_id}` transient first, only falling back to `get_post_meta()` on a cache miss, matching the behaviour of the name-substitution filter callbacks.
- **`WordPress.Security.ValidatedSanitizedInput.MissingUnslash` on `$_POST['cga_nonce']`** in `cga_save_meta_box_data()` (`admin/admin.php`). The nonce value was passed directly to `sanitize_text_field()` without first being unslashed via `wp_unslash()`. WordPress wraps all superglobal values in `addslashes()` on startup; reading `$_POST` without `wp_unslash()` can produce a double-escaped nonce string that fails `wp_verify_nonce()`. Fixed to `sanitize_text_field( wp_unslash( $_POST['cga_nonce'] ) )`.
- **`WordPress.DB.DirectDatabaseQuery.DirectQuery` on the transient count query** (`admin/views/settings-page.php`). The `SELECT COUNT(*)` query that populates the cached transient count on the Debug tab was running directly against `$wpdb` on every page load of the Debug tab without any caching layer. Wrapped with `wp_cache_get( 'cga_transient_count', 'custom-guest-authors' )` before the query and `wp_cache_set( 'cga_transient_count', $count, 'custom-guest-authors' )` after, so the query runs at most once per object cache lifetime per page load.

### Removed
- **Explicit `load_plugin_textdomain()` call removed** (`custom-guest-authors.php`). WordPress.org-hosted plugins load translations automatically from the `languages/plugins/` directory via the translation API; an explicit `load_plugin_textdomain()` call is flagged as redundant by Plugin Check for hosted plugins. The call was removed in this version. *Note: restored in v2.0.1, as self-hosted installs require the explicit call to load bundled `.mo` files from the plugin's own `languages/` directory.*

---

## [1.8.1] — 2026-02-19

### Fixed
- **Classic editor meta box was hardcoded to `post` and `page`** (`admin/admin.php`). `add_meta_box()` was called with a fixed `array( 'post', 'page' )` as the `$screen` argument, ignoring the `cga_enabled_post_types` option entirely. On sites with the plugin configured to override custom post types (e.g. `book`, `podcast`), the meta box would not appear in the classic editor for those types. The call now reads `get_option( 'cga_enabled_post_types', array( 'post' ) )` and passes the result as `$screen`.
- **`cga_suppress_schema` toggle not saving correctly when unchecked** (`admin/admin.php`). HTML `<input type="checkbox">` elements omit their `name` key from the `$_POST` array entirely when unchecked — no `0` or `false` value is sent. The Settings API registers `cga_suppress_schema` with a `sanitize_callback`; when the key is absent from `$_POST`, the callback is never invoked and `update_option()` is never called, leaving the option at its previous value. A hidden `<input type="hidden" name="cga_suppress_schema" value="0">` companion input now ensures the key is always present in the submission. The sanitize callback `cga_sanitize_checkbox()` was extracted from the inline anonymous function to a named function for testability.
- **`cga_enabled_post_types` checkboxes could not all be unchecked** (`admin/admin.php`). Same root cause as the checkbox issue above: when all post type checkboxes are unchecked, the `cga_enabled_post_types` key is absent from `$_POST`, and the sanitize callback `cga_sanitize_post_types()` was returning `array( 'post' )` as a hardcoded fallback, making it impossible to save an empty enabled-types array. A hidden sentinel input (`<input type="hidden" name="cga_enabled_post_types[]" value="">`) ensures the key is always present in `$_POST`; the sanitizer now filters out the empty string sentinel, so all-unchecked correctly saves as `[]`.

---

## [1.8.0]

### Added
- **General tab — Post type selection** (`admin/views/settings-page.php`, `admin/admin.php`). A checkbox card grid replaces the single "Override on Pages" boolean toggle. All public post types registered in WordPress (retrieved via `get_post_types( array( 'public' => true ), 'objects' )`) are listed as selectable options. The selection is stored as the `cga_enabled_post_types` option (array of post type slugs). Posts (`post`) are pre-selected by default on first install. Pages and custom post types are opt-in. The front-end filters now gate on `in_array( $post_type, $enabled_types, true )` before applying any override.
- **Display tab — Multi-Author Join Style** (`admin/views/settings-page.php`, `admin/admin.php`). Radio card selector offering three formats for comma-separated author lists: Natural (`A, B and C` — default), Comma (`A, B, C`), Ampersand (`A & B & C`). Stored as `cga_join_style`. The Display tab includes a live preview card showing how the current join style renders a sample set of three author names. `cga_format_authors()` reads this option to determine the join strategy.
- **Display tab — Show Override On** (`admin/views/settings-page.php`, `admin/admin.php`). Radio control offering two values: `all` (apply the override on all views — archives, search, RSS, singular) and `singular` (apply only on `is_singular()` page contexts). Stored as `cga_apply_on`. When set to `singular`, per-post meta overrides are suppressed on non-singular contexts; the site-wide default author is unaffected and still applies everywhere.
- **Advanced tab — Cache Lifetime** (`admin/views/settings-page.php`, `admin/admin.php`). A number input (range 1–168, default 12) sets the `set_transient()` TTL in hours for the `cga_{post_id}` author name cache. Previously hardcoded to `12 * HOUR_IN_SECONDS`. Stored as `cga_cache_ttl`. Validated on save via a `cga_sanitize_cache_ttl()` callback that clamps the value to the 1–168 range.
- **Advanced tab — Suppress author from JSON-LD schema** (`admin/views/settings-page.php`, `admin/admin.php`, `includes/front-end.php`). A checkbox toggle stored as `cga_suppress_schema`. When enabled, adds `add_filter( 'wpseo_schema_graph', 'cga_suppress_yoast_author' )` and `add_filter( 'rank_math/schema/article', 'cga_suppress_rankmath_author' )`. The Yoast callback walks `$data['@graph']` and unsets `author` from any node whose `@type` is `Article`, `WebPage`, `NewsArticle`, or `BlogPosting`. The Rank Math callback unsets `author` from the entity array directly.
- **Advanced tab — Debug Information table** (`admin/views/settings-page.php`). Read-only two-column table displaying: plugin version, active post types (comma-joined labels), join style, cache TTL, number of active `cga_*` transients in `wp_options`, WordPress version, and PHP version. Predecessor to the dedicated Debug tab introduced in v2.0.0.

### Changed
- **`cga_register_settings()` expanded** (`admin/admin.php`). New sanitize callbacks added for all new options: `cga_sanitize_post_types()` (filters the submitted array to only recognised public post type slugs), `cga_sanitize_cache_ttl()` (clamps to 1–168), `cga_sanitize_checkbox()` (casts to `0` or `1`).

---

## [1.7.5]

### Added
- **Malay (Malaysia) translation** — `languages/custom-guest-authors-ms_MY.po` and `languages/custom-guest-authors-ms_MY.mo`. The only translatable string requiring locale-specific handling is the `'and'` conjunction used in natural-language multi-author joining, which outputs as `'dan'` on `ms_MY` locale sites.
- **POT template file** — `languages/custom-guest-authors.pot`. Covers all strings wrapped in `__()`, `_e()`, and `esc_html__()` across all plugin PHP files. Generated with WP-CLI `i18n make-pot`.

### Removed
- **Author name prefix feature** removed (`admin/admin.php`, `includes/front-end.php`). The `cga_author_prefix` option (allowing a configurable string such as "By" to be prepended to the guest author name) produced doubled output on most themes, which already output their own "Written by" or "By" label before calling `the_author()`. The prefix was prepended in the filter return value, so the rendered output was e.g. "By By John Doe". Removed the option, its settings field, its sanitize callback, and its usage in the filter.

### Fixed
- **Author name was output as a hyperlink** (`includes/front-end.php`). The filter was returning the guest author name wrapped in an `<a href="...">` tag pointing to the WordPress author archive URL. Guest authors are not WordPress users and have no author archive page; visiting the URL would display the registered WordPress user's posts, not the guest author's. The hyperlink was unconditionally stripped; the filter now returns the plain name string, and the `author_link` and `the_author_posts_link` filters suppress or strip the anchor at the theme output layer.

---

## [1.7.3]

### Fixed
- **Prefix concatenated with no space**, producing output like `ByJohn Doe` when `cga_author_prefix` was set to `By` (`includes/front-end.php`). The concatenation used `$prefix . $name` with no intervening space. Fixed by applying `rtrim( $prefix )` to strip any trailing whitespace the user may have included, then appending a single space before the name: `rtrim( $prefix ) . ' ' . $name`.

### Changed
- **Multi-author output uses smart natural-language joining** (`includes/front-end.php`). The previous behaviour joined all authors with the separator string uniformly (e.g. `A, B, C`). The join logic now uses the Oxford-comma-adjacent pattern: two authors produce `A and B`; three or more produce `A, B and C` (last element joined with "and", rest joined with ", "). The "and" conjunction is wrapped in `__( 'and', 'custom-guest-authors' )` for translation.

### Removed
- **`cga_separator` option superseded** by the smart join behaviour. The configurable separator (previously stored as `cga_separator`) is no longer necessary; the join strategy is now determined by the Multi-Author Join Style setting introduced in v1.8.0.

---

## [1.7.2]

### Changed
- **Settings page completely redesigned** (`assets/css/settings.css`, `admin/views/settings-page.php`). First adoption of the `wpcp-` CSS class namespace and the slate colour palette (`#64748b` primary, `#0891b2` accent) used across the MENJ plugin suite. Page header now contains an inline SVG icon and a version badge. All dashicons on the page replaced with inline SVGs. Tab navigation converted from JavaScript-toggled `display: none` sections to URL `?tab=` parameter links that reload the page — removing the need for client-side tab-switching JavaScript. Settings form posts to `options.php` via the WordPress Settings API rather than a custom AJAX handler.
- **`assets/css/meta-box.css` and `assets/css/gutenberg-sidebar.css`** updated to use the same `--wpcp-*` CSS custom properties defined in `settings.css`.

### Removed
- **`settings.js` removed.** Tab switching no longer requires client-side JavaScript after the conversion to URL-based tab navigation.

---

## [1.7.1]

### Added
- **Settings page at Settings › Guest Authors** (`admin/admin.php`, `admin/views/settings-page.php`, `assets/css/settings.css`, `assets/js/settings.js`). First introduction of a dedicated settings UI. Two tabs:
  - **General tab** — default guest author name field (`cga_default_guest_author`); toggle for whether the override applies to Pages as well as Posts (`cga_override_on_pages`).
  - **Display tab** — configurable separator character for multi-author lists (`cga_separator`); optional author name prefix (`cga_author_prefix`); live preview card showing how the current settings render a sample name.
- **Settings page assets** — `css/settings.css` and `js/settings.js` separated into their own files and enqueued only on the settings page hook suffix via `admin_enqueue_scripts`.

### Fixed
- **`cga_override_on_pages` was never read in the front-end filter** (`includes/front-end.php`). The option was registered and displayed on the settings page but the filter function never called `get_option( 'cga_override_on_pages' )` — Pages were always overridden regardless of the setting value.
- **Hidden tab input always output `cga-tab-general` on page load.** A `<input type="hidden" name="cga_active_tab">` was hardcoded to output `value="cga-tab-general"` on every render, so saving on any tab always redirected back to General. The hidden input is now empty on GET (initial page load) and populated with the current tab value only after a form submission.
- **Dead `register_setting()` calls removed** (`admin/admin.php`). Several options were registered with `register_setting()` but had no corresponding `add_settings_field()` or sanitize callback — they were stubs from an earlier prototype that were never removed.

---

## [1.6.2]

### Fixed
- **Classic meta box appeared twice in the block editor** (`admin/admin.php`). `add_meta_box()` was called without checking whether the block editor was active for the current post type. WordPress renders both the classic meta box (via the compatibility layer) and any `PluginDocumentSettingPanel` registered in the Gutenberg sidebar simultaneously when both are registered, producing a duplicate author field. A `use_block_editor_for_post_type( $post_type )` check now gates the `add_meta_box()` call; the meta box is only added when the classic editor is in use.
- **JavaScript crash in the Gutenberg sidebar on initial render** (`assets/js/gutenberg-sidebar.js`). `wp.data.select('core/editor').getCurrentPostType()` returned `undefined` on the first render pass before the editor store had initialised. A subsequent call to `useEntityProp( undefined, postId, 'meta' )` threw a runtime error. Added a guard: if `postType` is falsy on the first render, the sidebar panel returns `null` and defers rendering until the store is ready.
- **`use_block_editor_for_post_type()` deprecated since WP 6.5** (`admin/admin.php`). Replaced with `wp_use_block_editor_for_post_type()`. A shim using `function_exists()` maintains backwards compatibility with WordPress versions prior to the rename.
- **Potential PHP warning on `post-new.php`** (`admin/admin.php`). `get_current_screen()->post_type` returns an empty string on `post-new.php` before the post type is determined from the query string. The value is now checked for non-empty before being passed to `use_block_editor_for_post_type()`.
- **Unused `useDispatch` import removed** (`assets/js/gutenberg-sidebar.js`). `var useDispatch = wp.data.useDispatch` was declared at the top of the IIFE but never used. Removed to prevent static analysis warnings.
- **`PluginDocumentSettingPanel` now resolves from `wp.editor` with fallback to `wp.editPost`** (`assets/js/gutenberg-sidebar.js`). WordPress 6.6 moved `PluginDocumentSettingPanel` from `wp.editPost` to `wp.editor` as the canonical location. The previous code used only `wp.editPost.PluginDocumentSettingPanel`, which triggers a deprecation notice in WP 6.6+. Now resolves `( wp.editor && wp.editor.PluginDocumentSettingPanel ) || wp.editPost.PluginDocumentSettingPanel`.

### Changed
- **`wp-editor` added to Gutenberg sidebar script dependencies** (`admin/admin.php`). `wp_enqueue_script()` for `gutenberg-sidebar.js` previously did not declare `wp-editor` as a dependency. On WP 6.6+, `wp.editor` is a separate package that must be explicitly enqueued; without the dependency, `wp.editor` could be `undefined` at runtime.

---

## [1.6]

### Added
- **Classic editor meta box** (`admin/admin.php`, `assets/css/meta-box.css`, `assets/js/meta-box.js`). `add_meta_box( 'cga-guest-authors', 'Guest Author', ... )` registered for the `post` post type. Renders a text input bound to the `guest-author` post meta key. Meta saved via `cga_save_meta_box_data()` on `save_post`, with nonce verification, capability check (`edit_post`), autosave and revision guards, and `sanitize_text_field()` on the submitted value.
- **Gutenberg sidebar panel** (`assets/js/gutenberg-sidebar.js`, `assets/css/gutenberg-sidebar.css`). A `PluginDocumentSettingPanel` registered via `wp.plugins.registerPlugin()`. Uses `wp.data.useSelect()` to read `getCurrentPostType()` and `useEntityProp( 'postType', postType, 'meta' )` to read and write the `guest-author` meta key directly against the REST API. The sidebar panel renders a `TextControl` bound to the meta value.
- **Post meta registered via `register_post_meta()`** (`includes/post-meta.php`). `show_in_rest: true` exposes the `guest-author` key via the `GET /wp-json/wp/v2/posts/{id}` and `POST` REST endpoints, enabling `useEntityProp()` in the sidebar to read and write without a custom REST route.
- **Plugin constants defined** (`custom-guest-authors.php`): `CGA_VERSION`, `CGA_PLUGIN_DIR` (`plugin_dir_path( __FILE__ )`), `CGA_PLUGIN_URL` (`plugin_dir_url( __FILE__ )`).
- **CSS and JS assets separated** into `css/` and `js/` directories (later consolidated into `assets/css/` and `assets/js/` in v2.2.0) and enqueued only on their respective hook suffixes via `admin_enqueue_scripts`.
- **Minimalist slate-toned styling** (`assets/css/meta-box.css`, `assets/css/gutenberg-sidebar.css`). First adoption of the `wpcp-` CSS class namespace and `--wpcp-*` CSS custom properties using the suite's slate palette (`#64748b`).

### Requires
- **WordPress 5.7 minimum.** `useEntityProp` from `@wordpress/core-data` was introduced in WordPress 5.7. Sites running earlier versions will not be able to use the Gutenberg sidebar panel.

---

## [1.5]

### Fixed
- **Transient cache invalidated immediately on post save** (`includes/cache.php`). `delete_transient( 'cga_' . $post_id )` is now called on the `save_post` action. Previously the cached value persisted until its TTL expired, so a guest author name change would not take effect on the front end until the transient aged out — up to 12 hours with the default TTL.
- **Cache invalidation also fires on direct post meta updates** (`includes/cache.php`). In addition to the `save_post` hook, `updated_post_meta`, `added_post_meta`, and `deleted_post_meta` actions now each call `delete_transient()` when the changed meta key is `guest-author`. This covers programmatic writes via `update_post_meta()`, REST API `PATCH` requests, and WP-CLI `post meta update` commands — none of which necessarily fire `save_post`.

### Changed
- **Transient key namespaced** from an unspecified format to `cga_{post_id}` to avoid collisions with other plugins using generic transient key patterns.
- **Default guest author option namespaced** to `cga_default_guest_author` (from an earlier unprefixed option name).
- **Empty author entries filtered** from comma-separated lists before joining, preventing trailing commas or double-comma entries from producing blank author name segments.
- **`ABSPATH` exit guard added** to all PHP files: `if ( ! defined( 'ABSPATH' ) ) { exit; }`.

---

## [1.2]

### Added
- **Support for multiple guest authors** (`includes/front-end.php`). The `guest-author` meta value is now treated as a comma-separated list of author names. The value is exploded on `,`, each segment trimmed and passed through `sanitize_text_field()`, empty entries filtered, and the resulting array joined for display. A single author name continues to work as before; no migration needed.

---

## [1.1]

### Added
- **Transient caching** (`includes/front-end.php`). Guest author names are now cached per post using `set_transient( 'cga_' . $post_id, $name, 12 * HOUR_IN_SECONDS )` after the first `get_post_meta()` read. Subsequent requests for the same post read from the transient rather than querying the database, reducing DB load on high-traffic sites or archive pages listing many posts.
- **Input sanitization.** The `guest-author` meta value is passed through `sanitize_text_field()` on save in the meta box save handler, stripping tags, octets, and invalid UTF-8 sequences.
- **Output escaping.** All author name output in front-end filter callbacks is passed through `esc_html()` before being returned. *(Note: the `esc_html()` wrapper on the filter return value was later identified as incorrect and removed in v2.0.2, as data filters must return raw strings.)*

---

## [1.0]

### Added
- Initial release. Single-file plugin registering one post meta key (`guest-author`) via a classic editor meta box (`add_meta_box()`), one front-end filter (`add_filter( 'the_author', ... )`) that replaces the WordPress display name with the meta value when present, and a site-wide default guest author option (`cga_default_guest_author`) used as a fallback when no per-post meta is set.
