WP Manifestindependent plugin directory
manifest / content / book-library

Book Library

OOP WordPress plugin that registers a Book post type with a Genre taxonomy, a Book Details meta box, and a books archive visitors can filter by genre. One class per concern, a shared render hook so the listing and single view never drift, theme-overridable templates, escaping and capability checks throughout.

★ 1stars

Install

Download source zip No release zip yet

This is the repository archive. It installs, but the folder name will carry the branch suffix and updates will not flow.

Install straight from GitHub with WP-CLI:

wp plugin install https://github.com/busayor2020/book-library/archive/refs/heads/main.zip
No built-in updater. New versions won't appear under Dashboard → Updates. Install new releases yourself, or let Git Updater track the repository.

A WordPress plugin submitted for the Fat Beehive WordPress Developer test.

It registers a Book post type with a Genre taxonomy and a Book Details meta box, serves a books archive that can be filtered by genre, and lets an administrator restyle the cards in that listing from Books > Card Appearance.

Live demo

book.ojatiya.com/books/

The plugin running on a live site, seeded with the test content described in Seeding test content. Pick a genre and press Apply to see the filter work.

Version 1.1.0
Requires WordPress 6.0+
Requires PHP 7.4+
Licence GPL-2.0-or-later
Text domain book-library

Contents


Installation

  1. Copy the book-library folder into wp-content/plugins/.
  2. Activate Book Library in Plugins.
  3. Rewrite rules are flushed on activation, so /books/ works straight away. If permalinks ever misbehave, re-save Settings > Permalinks.

What it adds

Post type book, admin menu Books, icon dashicons-book-alt
Supports title, editor, thumbnail
Taxonomy genre, non-hierarchical, admin column shown
Archive /books/, 12 books per page, paginated
Filter /books/?book_genre=<slug>
Meta box Book Details, side panel, Author Name field
Meta key _book_author_name, registered and exposed over REST
Settings Books > Card Appearance, one option row, book_library_card_appearance
Card button Optional <a> per card, linking to the book or to a custom field
Stylesheet assets/css/book-library.css, loaded only on book pages

Post type, taxonomy and meta are all registered with show_in_rest, so the block editor and the REST API see them.


How the requirements are met

Requirement Where
OOP plugin includes/, one class per concern, each implementing the Hookable interface
Custom post type "Book" includes/class-post-type.php
Supports title, editor, thumbnail Post_Type::register(), supports argument
Admin menu "Books" with dashicons-book-alt Post_Type::register(), menu_icon argument
Publicly queryable public and publicly_queryable set to true, archive at /books/
Custom taxonomy "Genre", non-hierarchical includes/class-taxonomy.php, hierarchical set to false
Meta box titled "Book Details" includes/class-meta-box.php
"Author Name" field, saved on post save Meta_Box::render_meta_box() and Meta_Box::save()
Author shown below post content Meta_Box::append_author_to_content() on the the_content filter
Archive template listing books templates/archive-book.php, loaded by includes/class-template-loader.php
Listing filterable by genre Filter form in the template, query handled in includes/class-archive-query.php
Listing reuses the meta box hook Template calls do_action( 'book_library_book_meta', get_the_ID() )
Part 1b debugging debug/part-1b-archive-query-debug.php
Part 2 client response docs/part-2-client-response.md

Card appearance

Books > Card Appearance styles every card in the listing. Nothing has to be saved for the plugin to work: with no option row the built-in defaults apply, which is the state a fresh install is in.

Section Settings
Card Master styling switch, border width, style and colour, corner radius, inner padding, background colour
Shadow Preset (none, subtle, medium, strong, custom), and for custom: horizontal and vertical offset, blur, spread, colour, opacity
Button Show or hide, label, destination, background, text colour, hover background, hover text colour, corner radius, vertical and horizontal padding, full width
Concern Where
Field registry, defaults, sanitisation, CSS output includes/class-card-appearance.php
Admin screen, sections, fields, reset control includes/class-settings-page.php
Button markup and link resolver includes/class-card-button.php
Variable consumption and button styles assets/css/book-library.css
Live preview assets/js/settings-preview.js
Tests tests/test-card-appearance.php

One registry, everything derived from it. Every setting is declared once, in Card_Appearance::get_registry(). The defaults come from wp_list_pluck() over that registry, sanitisation dispatches on the sanitize key each entry names, the admin fields and the live preview are rendered from it, and the CSS custom properties are built by walking it. Adding a setting means adding one entry.

Output is custom properties, not inline styles. The plugin prints one block scoped to .book-library-grid and attaches it to its own stylesheet with wp_add_inline_style(), so it lands in the right place in the cascade and disappears with the stylesheet if a theme dequeues it. assets/css/book-library.css reads every property with a var() fallback, so the cards still render when styling is switched off.

The shadow is composed in PHP. The colour and the opacity become one rgba() value, the preset is authoritative, and the custom component fields are only read when the preset is custom. A preset of none emits no box-shadow declaration at all rather than box-shadow: none, so a theme's own shadow is left alone.

Offsets can be negative. shadow_offset_x, shadow_offset_y and shadow_spread are sanitised with a signed handler. absint() would turn -4 into 4 and flip the shadow to the wrong side of the card without an error anywhere.

Reset clears the row. The reset control deletes the option rather than writing the defaults into it, which keeps the fresh-install path exercised rather than only tested once.

The button. An <a> styled as a button, because it navigates. It links to the book by default, or to the book_library_button_url custom field on each book, falling back to the book page when that field is empty. Setting the destination to None hides it.


Structure

book-library/
├── book-library.php                       Bootstrap, constants, autoloader, activation
├── uninstall.php                          Removes plugin meta and settings, leaves content alone
├── readme.txt                             WordPress plugin readme and changelog
├── phpcs.xml.dist                         WordPress coding standards ruleset
├── includes/
│   ├── class-hookable.php                 Interface every component implements
│   ├── class-plugin.php                   Builds components, registers their hooks
│   ├── class-post-type.php                Book post type
│   ├── class-taxonomy.php                 Genre taxonomy
│   ├── class-meta-box.php                 Book Details meta box, save, front-end output
│   ├── class-archive-query.php            Genre filtering, the corrected Part 1b logic
│   ├── class-template-loader.php          Serves the archive template
│   ├── class-assets.php                   Conditional stylesheet loading
│   ├── class-card-appearance.php          Field registry, sanitisation, CSS custom properties
│   ├── class-card-button.php              The per-card button and its link resolver
│   └── class-settings-page.php            Books > Card Appearance screen and live preview
├── templates/
│   └── archive-book.php                   Books listing, overridable by the theme
├── assets/
│   ├── css/book-library.css               Layout and card presentation, driven by custom properties
│   ├── css/book-library-admin.css         Settings screen furniture only
│   └── js/settings-preview.js             Live preview, no AJAX and no save needed
├── tests/
│   ├── run-tests.php                      Entry point, WP-CLI or standalone
│   ├── class-test-runner.php              Assertions and reporting
│   ├── test-card-appearance.php           Structural invariants and behavioural assertions
│   └── wp-stubs.php                       Enough WordPress to run the suite without WordPress
├── debug/
│   └── part-1b-archive-query-debug.php    Original snippet, faults found, corrected code
└── docs/
    └── part-2-client-response.md          Written response to Bob OldClient

Classes autoload from includes/ on a PSR-4 style map, so BookLibrary\Post_Type resolves to includes/class-post-type.php. The underscore-to-hyphen conversion keeps the file naming aligned with the WordPress coding standards.


Design notes

One class, one concern. Each component owns its own hooks through register_hooks(). Plugin never needs to know what a component does, so adding a feature means adding a class to one array and nothing else changes. It also makes each piece testable in isolation.

A shared action for the author output. The meta box exposes book_library_book_meta. The single view reaches it through the the_content filter, and the archive template calls it directly. Both render through Meta_Box::get_author_markup(), so the listing and the book page can never drift apart, and a theme can restyle both by filtering book_library_author_markup in one place.

Theme override. Template_Loader checks locate_template() first, so a theme can take over the listing by dropping archive-book.php into the theme folder. The plugin only steps in when the theme has no opinion. A theme template copied from before 1.1.0 keeps working, it simply has no button until do_action( 'book_library_card_button', get_the_ID() ) is added to it. The card styling still applies, because it is delivered as custom properties on the wrapper rather than as markup.

One place to declare a setting. Card_Appearance::get_registry() is the only place a card setting is described. get_defaults() is plucked from it rather than written out again, so the two cannot drift, and the test suite fails immediately if a hand-written defaults array is ever reintroduced. Sanitisation dispatches on the handler each entry names, so there is no switch listing field names anywhere.

Escaping and capabilities. Output is escaped at the point of output. The meta box save is guarded by a nonce, an autosave and revision check, and current_user_can( 'edit_post' ). A save that carries no nonce, such as a REST or WP-CLI update, leaves the stored value alone rather than wiping it. The genre in the request is passed through sanitize_title() and confirmed to exist before it reaches the query. Settings go through register_setting() with a sanitisation callback, and the reset control checks a nonce and manage_options.

CSS values are reduced, not escaped. Every generated value is stripped down to characters that can only be a CSS value, so anything that could close a declaration, close a block, open a comment, start a string or reach the network is removed rather than encoded. That runs after the book_library_card_css_variables filter as well, so a filter cannot smuggle a value through either.

Tolerant public entry points. render_author() and append_author_to_content() take untyped parameters on purpose. Both sit on hooks that any theme or plugin can call, and under declare( strict_types = 1 ) a badly behaved caller upstream would turn a declared type into a fatal error on the whole page. The values are cast instead.

Note on themes. The archive template uses get_header() and get_footer(), so it assumes a classic theme. Under a block theme, add archive-book.html to the theme or copy the loop into a template part.


Extending it

Everything below is public API and safe to hook.

Actions

Hook Parameters Purpose
book_library_book_meta int $post_id Renders the author line. Call it from any template to place the author wherever you want.
book_library_card_button int $post_id Renders the card button. Call it from any template to place the button wherever you want.
do_action( 'book_library_book_meta', get_the_ID() );
do_action( 'book_library_card_button', get_the_ID() );

Filters

Hook Parameters Purpose
book_library_post_type_args array $args Adjust the Book post type registration.
book_library_taxonomy_args array $args Adjust the Genre taxonomy registration.
book_library_author_markup string $markup, string $author_name, int $post_id Restyle the author line in both the listing and the single view at once.
book_library_card_field_registry array $registry Add, remove or reconfigure a card setting. Reaches the defaults, the sanitiser, the admin form, the preview and the CSS.
book_library_default_card_appearance array $defaults Change what an unsaved install looks like.
book_library_card_css_variables array $variables, array $settings Adjust the custom properties before they are printed.
book_library_card_button_args array $args, int $post_id, array $settings Change the button per book. An empty url suppresses it.
book_library_card_button_url string $url, int $post_id, string $target The link resolver. Give a new button_target somewhere to go.
book_library_card_button_meta_key string $meta_key, int $post_id Change the custom field the custom_field target reads.
book_library_card_shadow_presets array $presets Change the fixed values behind the shadow presets.
add_filter(
    'book_library_author_markup',
    function ( $markup, $author_name ) {
        return '<p class="byline">by ' . esc_html( $author_name ) . '</p>';
    },
    10,
    2
);

Adding a card setting is one entry. Declare a default, a sanitisation handler, and a css_var (use null for a setting that does not map to one property), and it appears everywhere on its own:

add_filter(
    'book_library_card_field_registry',
    function ( array $registry ) {
        $registry['card_gap'] = array(
            'label'    => 'Space between card elements',
            'type'     => 'number',
            'default'  => 8,
            'sanitize' => 'pixel',
            'min'      => 0,
            'max'      => 40,
            'section'  => 'card',
            'css_var'  => '--book-card-gap',
            'css_unit' => 'px',
        );

        return $registry;
    }
);

Adding a button destination is two filters, one for the choice and one for the URL:

add_filter(
    'book_library_card_field_registry',
    function ( array $registry ) {
        $registry['button_target']['choices'][]                = 'catalogue';
        $registry['button_target']['choice_labels']['catalogue'] = 'The library catalogue';

        return $registry;
    }
);

add_filter(
    'book_library_card_button_url',
    function ( $url, $post_id, $target ) {
        return 'catalogue' === $target
            ? 'https://catalogue.example.org/?book=' . rawurlencode( get_the_title( $post_id ) )
            : $url;
    },
    10,
    3
);

Overriding the template

Copy templates/archive-book.php into the active theme as archive-book.php. Template_Loader finds it through locate_template() and stands down.


Testing it

To walk these steps on a running site rather than a local install, the archive is live at book.ojatiya.com/books/. Steps 3 to 6 can be followed there as a visitor.

  1. Books > Add New. Give it a title, some body copy and a featured image. In the Genres box on the right, add a genre such as Science Fiction. In Book Details, fill in Author Name. Publish.
  2. Repeat for a few books across two or three genres.
  3. Visit /books/. Every book should appear, each showing its cover, title, author and genres.
  4. Choose a genre in the dropdown and press Apply. The URL becomes /books/?book_genre=science-fiction and the listing narrows.
  5. Press Clear filter. The full list returns.
  6. Open a single book. The author name appears below the content.
  7. Edit a book, clear the Author Name and update. The line disappears from both views.

Card appearance

  1. Books > Card Appearance. Change the border colour, the padding and the shadow preset. The preview beside the form updates as you type, before anything is saved.
  2. Press Save Changes, then reload /books/. The listing matches the preview.
  3. Untick Enable card styling and save. The cards fall back to the theme, and the page source no longer carries the --book-card-* block.
  4. Set Button links to to None and save. The buttons disappear from the listing.
  5. Set it to A custom field, add a book_library_button_url custom field to one book, and reload. That book's button points at the URL you entered, every other book's button still points at the book page.
  6. Press Reset to defaults. The saved row is deleted, not overwritten, so the listing goes back to exactly what a fresh install renders.

The test suite

The suite runs against a live site through WP-CLI, which is what the fresh-install and option-row assertions need in order to mean anything:

wp eval-file wp-content/plugins/book-library/tests/run-tests.php

It can also run standalone, against the stubs in tests/wp-stubs.php, on a machine with no WordPress to hand:

php tests/run-tests.php

Either way it prints a line per test and exits non-zero on failure. The current option row is snapshotted before anything runs and restored afterwards, including the case where there was no row to begin with.

The structural tests are the interesting half. They hold for settings that do not exist yet, so they keep working as the registry grows: every entry declares a non-null default; every entry names a sanitisation handler that exists; every number declares a range; every select defaults to one of its own choices; every entry declares css_var explicitly, no two entries claim the same property, and a null one is a gate, a text field or a composed shadow component; the keys of get_defaults() match the keys of the registry, which fails the moment anyone reintroduces a hand-written defaults array; and the stylesheet and the registry are diffed against each other in both directions, so a property that nothing reads and a property that nothing produces are both caught.

Edge cases worth trying:

  • A genre slug that does not exist in the URL falls back to the full archive rather than an empty page.
  • A book with no author set simply omits the line instead of printing an empty label.
  • A genre with no books attached is left out of the filter dropdown, because get_terms() is called with hide_empty set to true.
  • A book with no genre appears under All genres and is absent from every filtered view.
  • A book with no featured image still renders as a card, with the cover link omitted rather than left empty.
  • A negative shadow offset stays negative. Set the shadow to Custom with a vertical offset of -8 and the shadow sits above the card, not below it.
  • A hand-edited option row with a nonsense colour in it does not break the CSS. The value is dropped and the declaration is left out rather than half written.

Seeding test content

The script below creates the content the checks above expect. It needs WP-CLI and the plugin already activated. Save it as seed-books.sh in the WordPress root and run bash seed-books.sh.

seed-books.sh
#!/usr/bin/env bash
#
# Seed test content for the Book Library plugin.
#
# Creates five genres, six books, and the Author Name meta on five of them.
# Each book is chosen to exercise a different code path. See the notes at the
# bottom for what to check once this has run.
#
# Usage, from the WordPress root:
#   bash seed-books.sh
#
# To start over:
#   wp post list --post_type=book --format=ids | xargs -r wp post delete --force
#   wp term list genre --field=term_id | xargs -r wp term delete genre

set -euo pipefail

if ! command -v wp >/dev/null 2>&1; then
  echo "WP-CLI not found on PATH. Install it or run this from a container that has it."
  exit 1
fi

if ! wp post-type get book >/dev/null 2>&1; then
  echo "The 'book' post type is not registered. Activate the Book Library plugin first."
  exit 1
fi

echo "Creating genres..."
wp term create genre "Science Fiction" --slug=science-fiction --porcelain >/dev/null 2>&1 || echo "  science-fiction already exists"
wp term create genre "Gothic" --slug=gothic --porcelain >/dev/null 2>&1 || echo "  gothic already exists"
wp term create genre "Horror" --slug=horror --porcelain >/dev/null 2>&1 || echo "  horror already exists"
wp term create genre "Romance" --slug=romance --porcelain >/dev/null 2>&1 || echo "  romance already exists"

# An unused genre. It must NOT appear in the archive filter dropdown, because
# get_terms() is called with hide_empty set to true.
wp term create genre "Poetry" --slug=poetry --porcelain >/dev/null 2>&1 || echo "  poetry already exists"

# create_book <title> <author-or-empty> <content> [genre-slugs...]
create_book() {
  local title="$1"
  local author="$2"
  local content="$3"
  shift 3

  local id
  id=$(wp post create \
    --post_type=book \
    --post_status=publish \
    --post_title="$title" \
    --post_content="$content" \
    --porcelain)

  if [ -n "$author" ]; then
    wp post meta update "$id" _book_author_name "$author" >/dev/null
  fi

  if [ "$#" -gt 0 ]; then
    wp post term set "$id" genre "$@" --by=slug >/dev/null
  fi

  printf '  #%s  %-24s author=%-22s genres=%s\n' \
    "$id" "$title" "${author:-<none>}" "${*:-<none>}"
}

echo "Creating books..."

create_book "Frankenstein" "Mary Shelley" \
  "A young scientist assembles a living creature from dead matter, then abandons it in horror at what he has made. The creature, articulate and desperately lonely, comes looking for its maker." \
  science-fiction gothic

create_book "The Time Machine" "H. G. Wells" \
  "An inventor builds a machine that carries him forward through time, arriving in a distant future where humanity has split into two species. What begins as a pastoral idyll turns out to rest on something far darker." \
  science-fiction

create_book "Dracula" "Bram Stoker" \
  "A young solicitor travels to a remote castle to close a property sale and finds his host is not what he seems. Told through letters and journals as the horror spreads to England." \
  gothic horror

create_book "Jane Eyre" "Charlotte Brontë" \
  "An orphan girl endures a brutal childhood, takes a post as a governess, and falls for her employer. She discovers what he has been keeping locked on the third floor." \
  romance

# Author Name left blank on purpose. The author line must be absent from both
# the listing and the single view, not rendered as an empty label.
create_book "The Yellow Wallpaper" "" \
  "A woman confined to an upstairs room for a rest cure becomes fixated on the pattern of the wallpaper. Her journal records, entry by entry, what she starts to see moving inside it." \
  gothic

# No genre on purpose. Appears under All genres, absent from every filtered view.
create_book "Beowulf" "Anonymous" \
  "A hero comes to the aid of a king whose hall is raided nightly by a monster. Three battles across a lifetime, ending with an old man and a dragon."

echo
echo "Done. Six books created."
echo
echo "Still to do by hand:"
echo "  1. Add a featured image to all of them EXCEPT Dracula."
echo "     Dracula having no cover is the point, it exercises has_post_thumbnail()."
echo "  2. Visit /books/ and work through the checks below."
echo
echo "What to check:"
echo "  - All six books show by default. Not just the two Science Fiction ones."
echo "  - Dropdown lists Gothic (3), Horror (1), Romance (1), Science Fiction (2)."
echo "  - Poetry is NOT in the dropdown, because nothing is attached to it."
echo "  - Filter to Science Fiction, two books. Clear filter, six books back."
echo "  - Filter to Romance, one book, and the accented name in Bronte renders correctly."
echo "  - The Yellow Wallpaper shows no author line anywhere."
echo "  - Beowulf shows no genre line, and vanishes from every filtered view."
echo "  - Dracula renders as a card with no cover, layout still holds."
echo "  - Visit /books/?book_genre=does-not-exist. You should get the full archive,"
echo "    not an empty page."

Coding standards

The plugin is written to the WordPress coding standards. phpcs.xml.dist ships the ruleset, with the class file naming rule excluded because the autoloader maps namespaced class names to class-*.php files.

composer require --dev wp-coding-standards/wpcs
vendor/bin/phpcs

Uninstalling

Deactivating unregisters the post type and flushes the rewrite rules.

Deleting the plugin runs uninstall.php, which removes the _book_author_name meta and the book_library_card_appearance option it created. Books and genres are left in place. They are the site owner's content, and deleting them on uninstall would be a surprise.