# Custom Guest Authors

**Version:** 2.3.1 · **PHP:** 8.2+ · **WordPress:** 5.7+ · **Tested up to:** 6.9  
**License:** GPLv2 or later · **Text domain:** `custom-guest-authors`

Replaces the WordPress post author display name with a custom guest author name stored as post meta. Supports multiple comma-separated authors, configurable join styles, per-post-type enablement, transient caching, and optional JSON-LD schema author suppression.

---

## Requirements

| Requirement | Minimum |
|---|---|
| PHP | 8.2 |
| WordPress | 5.7 (introduces `useEntityProp` in `@wordpress/core-data`) |
| Tested up to | 6.9 |

No Composer dependencies. No npm build step. No external services.

---

## File Structure

```
custom-guest-authors/
├── custom-guest-authors.php        Bootstrap: constants, i18n, conditional loader
├── includes/
│   ├── front-end.php               Author filters, link suppression, schema suppression,
│   │                               shared helpers — loaded on every request
│   ├── cache.php                   Transient invalidation hooks, version-based flush
│   │                               — loaded on every request
│   └── post-meta.php               register_post_meta(), add_post_type_support()
│                                   — loaded on every request
├── admin/
│   ├── admin.php                   Classic meta box, asset enqueuing, Settings API,
│   │                               redirect hook — loaded on is_admin() only
│   └── views/
│       ├── settings-page.php       4-tab settings UI (General / Display / Advanced / Debug)
│       └── tab-debug.php           14 live diagnostic checks, manual post-ID tester
├── assets/
│   ├── css/
│   │   ├── settings.css            Settings page styles (875 lines, full CSS custom props)
│   │   ├── meta-box.css            Classic editor meta box styles
│   │   └── gutenberg-sidebar.css   Block editor sidebar panel styles
│   └── js/
│       ├── settings.js             Radio/checkbox card state + live join-style preview
│       │                           (vanilla JS, no jQuery)
│       ├── meta-box.js             Enter-key prevention + pre-submit trim
│       │                           (vanilla JS, no jQuery)
│       └── gutenberg-sidebar.js    PluginDocumentSettingPanel via wp.plugins API
├── languages/
│   ├── custom-guest-authors.pot    POT template
│   ├── custom-guest-authors-ms_MY.po
│   └── custom-guest-authors-ms_MY.mo
├── uninstall.php                   Removes all options and cga_* transients on deletion
├── phpcs.xml                       PHPCS config (WordPress standard, PHP 8.2+, WP 5.7+)
├── readme.txt                      wordpress.org plugin readme
├── readme.md                       This file
├── changelog.md                    Full development changelog (Keep a Changelog format)
└── upgrading.md                    Planned features and architecture notes
```

---

## Constants

Defined in the bootstrap before any `require_once` is called:

```php
define( 'CGA_VERSION',    '2.3.1' );
define( 'CGA_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'CGA_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'CGA_NO_META',    '__cga_none__' );
```

`CGA_NO_META` is the transient sentinel. It is stored in place of an empty string when the database confirms a post has no `guest-author` meta, preventing `get_transient() === false` (cache miss) from being confused with a confirmed empty value.

---

## WordPress Options

All options are registered via the Settings API in `cga_register_settings()`.

| Option key | Type | Default | Sanitise callback | Settings group |
|---|---|---|---|---|
| `cga_default_guest_author` | string | `''` | `sanitize_text_field` | `cga_general` |
| `cga_enabled_post_types` | array | `['post']` | `cga_sanitize_post_types()` | `cga_general` |
| `cga_join_style` | string | `'natural'` | `sanitize_key` | `cga_display` |
| `cga_apply_on` | string | `'all'` | `sanitize_key` | `cga_display` |
| `cga_cache_ttl` | integer | `12` | `absint` | `cga_advanced` |
| `cga_suppress_schema` | integer | `0` | `cga_sanitize_checkbox()` | `cga_advanced` |
| `cga_cache_version` | string | `''` | `update_option()` direct | internal |

`cga_sanitize_post_types()` intersects the submitted array against `get_post_types(['public' => true])`, rejecting any slug not registered on the site. `cga_sanitize_checkbox()` correctly handles HTML form submission semantics where an unchecked checkbox sends no value — a hidden companion input sends `'0'` when unchecked.

---

## Post Meta

The guest author name is stored as a single post meta key:

| Meta key | Type | `show_in_rest` | Sanitise callback | Auth callback |
|---|---|---|---|---|
| `guest-author` | string | `true` | `sanitize_text_field` | `current_user_can('edit_posts')` |

Registered via `register_post_meta('', 'guest-author', ...)` (empty first argument = all post types). This makes the key accessible via the REST API for all post types, which is required for the Gutenberg `useEntityProp` hook to read and write meta. The `add_post_type_support($type, 'custom-fields')` call at `init` priority 9 must precede `register_post_meta()` at priority 10 — without the support flag, the REST endpoint silently discards meta writes from the block editor.

Multiple authors are stored as a single comma-separated string:

```
Zamri Vinoth, Firdaus Wong, Ali Hassan
```

Formatted on read by `cga_format_authors()` according to the `cga_join_style` option.

---

## Filter Hooks

The plugin intercepts author display at two complementary points:

```php
// Classic themes — the_author() / get_the_author()
add_filter( 'the_author', 'custom_guest_authors_name', 20 );

// Block themes (TT21–TT25, all FSE) — get_the_author_meta('display_name')
// WordPress fires: apply_filters("get_the_author_{$field}", ...)
// For 'display_name' this resolves to 'get_the_author_display_name'
add_filter( 'get_the_author_display_name', 'custom_guest_authors_name_meta', 20, 3 );

// Author archive link suppression
add_filter( 'author_link',          'custom_guest_authors_suppress_url', 10, 1 );
add_filter( 'the_author_posts_link', 'custom_guest_authors_strip_link',  10, 1 );

// Optional JSON-LD schema author suppression
add_filter( 'wpseo_schema_graph',       'cga_suppress_yoast_author' );
add_filter( 'rank_math/schema/article', 'cga_suppress_rankmath_author' );
```

Priority 20 on the name filters ensures the substitution runs after `ent2ncr` (registered at priority 8 by some plugins) and other early-priority hooks.

### Filter decision logic

Both `custom_guest_authors_name()` and `custom_guest_authors_name_meta()` follow the same path:

1. Resolve post via `get_post()` → fallback `get_queried_object()` → bail if not `WP_Post`
2. Bail if post type not in `cga_enabled_post_types`
3. If `cga_apply_on = 'singular'` and `!is_singular()` → set `$block_on_ctx = true`
4. Read `cga_get_authors($post_id)` (transient cache)
5. If authors found and `!$block_on_ctx` → return `cga_format_authors($raw)`
6. If `cga_default_guest_author` set → return sanitized default (not gated by `$block_on_ctx`)
7. Return original name

The `$block_on_ctx` gate applies only to per-post overrides. The site-wide default is intentionally exempt and always surfaces, including on archive and listing pages.

---

## Transient Cache

```php
// Key pattern
$transient_key = 'cga_' . $post_id;

// TTL source
$ttl = absint( get_option( 'cga_cache_ttl', 12 ) );  // hours, range 1–168

// Sentinel for confirmed DB miss
set_transient( $transient_key, CGA_NO_META, $ttl * HOUR_IN_SECONDS );
```

### Invalidation triggers

| Trigger | Hook |
|---|---|
| Post saved | `save_post` (skips autosaves and revisions) |
| Meta added programmatically | `added_post_meta` (key-specific: `guest-author` only) |
| Meta updated programmatically | `updated_post_meta` (key-specific) |
| Meta deleted programmatically | `deleted_post_meta` (key-specific) |
| Plugin version change | `init` — version-based bulk DELETE on `cga_cache_version` mismatch |
| Manual | Debug tab → Clear Cache button (nonce-gated GET action) |

The meta hooks cover REST API writes (including Gutenberg save), WP-CLI `post meta update`, and any programmatic `update_post_meta()` call — not just saves via the editor.

### Version-based flush

On every `init`, `cache.php` compares the stored `cga_cache_version` option against `CGA_VERSION`. On mismatch:

```php
update_option( 'cga_cache_version', CGA_VERSION, false );  // record first
$wpdb->query( "DELETE FROM {$wpdb->options}
    WHERE option_name LIKE '_transient_cga_%'
       OR option_name LIKE '_transient_timeout_cga_%'" );
```

`update_option()` is called **before** the `DELETE`. If the `DELETE` fails, the version is already recorded so the flush does not re-run on the next request. A partial flush is safe — `save_post` will re-prime any affected transients on the next edit.

---

## Asset Enqueuing

All assets are enqueued via WordPress APIs with `CGA_VERSION` as the cache-busting query string argument.

| Handle | File | Hook | Condition |
|---|---|---|---|
| `cga-meta-box` (CSS) | `assets/css/meta-box.css` | `admin_enqueue_scripts` | Classic editor, enabled post type |
| `cga-meta-box` (JS) | `assets/js/meta-box.js` | `admin_enqueue_scripts` | Classic editor, enabled post type |
| `cga-gutenberg-sidebar` (JS) | `assets/js/gutenberg-sidebar.js` | `enqueue_block_editor_assets` | Block editor always |
| `cga-gutenberg-sidebar` (CSS) | `assets/css/gutenberg-sidebar.css` | `enqueue_block_editor_assets` | Block editor always |
| `cga-settings` (CSS) | `assets/css/settings.css` | `admin_enqueue_scripts` | Settings page only |
| `cga-settings` (JS) | `assets/js/settings.js` | `admin_enqueue_scripts` | Settings page only |

The Gutenberg sidebar script declares `wp-core-data` as a dependency, which is required for `wp.coreData.useEntityProp` to be available on `window.wp` at runtime.

---

## Gutenberg Sidebar

`gutenberg-sidebar.js` is a vanilla IIFE (no build step, no JSX) that registers a `PluginDocumentSettingPanel` via the WordPress plugins API:

```js
var PluginDocumentSettingPanel =
    ( wp.editor && wp.editor.PluginDocumentSettingPanel )
        ? wp.editor.PluginDocumentSettingPanel   // WP 6.6+ canonical location
        : wp.editPost.PluginDocumentSettingPanel; // backwards-compat fallback

registerPlugin( 'cga-guest-authors', { render: CgaSidebarPanel } );
```

`useEntityProp` is null-guarded (`wp.coreData ? wp.coreData.useEntityProp : null`) and returns early if unavailable. On the initial render pass before the block editor store is hydrated, `postType || ''` is passed as the entity type to prevent reading meta from the wrong entity type (`postType || 'post'` would read `post` meta on Pages and custom post types until `postType` resolved).

---

## Programmatic Usage

### Set a guest author via PHP

```php
update_post_meta( $post_id, 'guest-author', 'Zamri Vinoth, Firdaus Wong' );
```

Cache is invalidated automatically via the `updated_post_meta` hook.

### Set a guest author via WP-CLI

```bash
wp post meta update 42 guest-author "Zamri Vinoth, Firdaus Wong"
```

### Read the formatted output

```php
// Returns formatted string per cga_join_style, or '' if no meta and no default
$raw     = get_post_meta( $post_id, 'guest-author', true );
$display = cga_format_authors( $raw );  // e.g. 'Zamri Vinoth and Firdaus Wong'
```

### Bypass the transient cache

```php
// Direct DB read, no cache
$raw = get_post_meta( $post_id, 'guest-author', true );
```

### Clear the cache for a single post

```php
delete_transient( 'cga_' . $post_id );
```

### Check cache status for a post

```php
$cached = get_transient( 'cga_' . $post_id );

if ( false === $cached ) {
    // Cache miss — next filter call will prime it
} elseif ( CGA_NO_META === $cached ) {
    // DB confirmed: no guest-author meta on this post
} else {
    // $cached is the raw comma-separated author string
}
```

---

## JSON-LD Schema Integration

When `cga_suppress_schema = 1`, the plugin removes the `author` property from Article-type schema nodes:

**Yoast SEO** — hooks `wpseo_schema_graph`, iterates `$data['@graph']`, and unsets `author` on nodes with `@type` in `['Article', 'WebPage', 'NewsArticle', 'BlogPosting']`.

**Rank Math** — hooks `rank_math/schema/article` and unsets `$data['author']` directly.

Both callbacks are no-ops when `cga_suppress_schema = 0` (returns `$data` unchanged).

---

## Uninstall Behaviour

`uninstall.php` is called by WordPress when the plugin is deleted via the Plugins screen. It is gated by `WP_UNINSTALL_PLUGIN` (not `ABSPATH`). It removes:

```php
$options = [
    'cga_default_guest_author',
    'cga_enabled_post_types',
    'cga_join_style',
    'cga_apply_on',
    'cga_cache_ttl',
    'cga_suppress_schema',
    'cga_cache_version',
];
```

And bulk-deletes all `cga_*` transients:

```sql
DELETE FROM wp_options
 WHERE option_name LIKE '_transient_cga_%'
    OR option_name LIKE '_transient_timeout_cga_%'
```

**Post meta (`guest-author`) is intentionally not deleted.** Removing post meta on plugin deletion would be destructive and irreversible for sites with hundreds of posts. If a site admin needs to bulk-delete the meta, this can be done via WP-CLI:

```bash
wp post meta delete --all --meta_key=guest-author
```

---

## PHPCS Configuration

`phpcs.xml` enforces the full `WordPress` ruleset against all PHP files, with PHP 8.2+ and WordPress 5.7+ compatibility targets. Authorised prefixes: `cga_`, `custom_guest_authors_`, `CGA`.

Four `DirectDatabaseQuery` suppressions are declared with documented justifications — all cover bulk transient operations where no WordPress API equivalent exists. `NonceVerification.Recommended` is suppressed for the two settings view files where `$_GET` reads are either read-only UI state or are nonce-verified within the same conditional block that PHPCS cannot statically trace.

---

## Version Bump Checklist

When releasing a new version, update the version string in all five locations:

1. `Version:` header in `custom-guest-authors.php`
2. `define('CGA_VERSION', '...')` in `custom-guest-authors.php`
3. `Stable tag:` in `readme.txt`
4. New entry at the top of `changelog.md` (above the previous `## [x.x.x]` entry)
5. Move any shipped features from `## Planned Features` in `upgrading.md` to `changelog.md`

---

## Extending the Plugin

### Add a new front-end filter

All front-end filter functions follow the same pattern. To add a new context (e.g. a custom `the_guest_author` hook):

```php
function cga_my_custom_filter( $name ) {
    $post = get_post() ?: get_queried_object();
    if ( ! $post instanceof WP_Post ) {
        return $name;
    }
    $enabled = get_option( 'cga_enabled_post_types', [ 'post' ] );
    if ( ! in_array( get_post_type( $post->ID ), (array) $enabled, true ) ) {
        return $name;
    }
    $authors = cga_get_authors( $post->ID );
    if ( $authors ) {
        return cga_format_authors( $authors );
    }
    $default = get_option( 'cga_default_guest_author', '' );
    return $default ? sanitize_text_field( $default ) : $name;
}
add_filter( 'my_author_hook', 'cga_my_custom_filter', 20 );
```

### Add a new option

1. Call `register_setting()` in `cga_register_settings()` with the appropriate group (`cga_general`, `cga_display`, or `cga_advanced`)
2. Add the `delete_option()` call to `uninstall.php`
3. If the option controls front-end behaviour, read it via `get_option()` in the relevant filter callback

### Add a new settings tab

1. Add the tab slug to the `$active_tab` whitelist in `settings-page.php`
2. Add the tab link to the `<nav class="wpcp-tabs">` block
3. Add the `if ('mytab' === $active_tab)` block in the form
4. Register the option group via `register_setting('cga_mytab', ...)` if the tab has saveable settings, or point it at an existing group for read-only tabs

---

## Changelog

See [`changelog.md`](changelog.md) for the full history.  
See [`upgrading.md`](upgrading.md) for planned features and architecture notes.
