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.
Install
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.zipA 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
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
- Live demo
- Installation
- What it adds
- How the requirements are met
- Card appearance
- Structure
- Design notes
- Extending it
- Testing it
- Seeding test content
- Coding standards
- Uninstalling
Installation
- Copy the
book-libraryfolder intowp-content/plugins/. - Activate Book Library in Plugins.
- 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.
- 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. - Repeat for a few books across two or three genres.
- Visit /books/. Every book should appear, each showing its cover, title, author and genres.
- Choose a genre in the dropdown and press Apply. The URL becomes
/books/?book_genre=science-fictionand the listing narrows. - Press Clear filter. The full list returns.
- Open a single book. The author name appears below the content.
- Edit a book, clear the Author Name and update. The line disappears from both views.
Card appearance
- 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.
- Press Save Changes, then reload /books/. The listing matches the preview.
- Untick Enable card styling and save. The cards fall back to the theme, and the page source no longer carries the
--book-card-*block. - Set Button links to to None and save. The buttons disappear from the listing.
- Set it to A custom field, add a
book_library_button_urlcustom 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. - 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 withhide_emptyset 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.
#!/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.