This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Before completing any code changes, always run these three commands to verify code quality:
composer check-cs # Check WordPress coding standards (PHPCS)
composer lint # Check for PHP syntax errors
composer phpstan # Static analysis for type errorsAll three commands must pass without errors before changes are considered complete. Fix any issues found before proceeding.
When making changes to this codebase, always update the relevant documentation:
-
Changelogs: After implementing a feature or fix, update both:
README.md- The GitHub changelog (under## Changelog)readme.txt- The WordPress.org changelog (under== Changelog ==)
-
This file (CLAUDE.md): After making significant changes, update this file to reflect:
- New or modified settings/options
- Changes to the architecture or file structure
- New methods or classes
- Updated testing tips
- Modified filters or hooks
Keep documentation in sync with code changes to ensure accuracy for future development.
Glossary by Progress Planner (pp-glossary) is a WordPress plugin that automatically links glossary terms to accessible, semantic popovers that appear on click. Uses native WordPress custom fields for field management and includes a Gutenberg block for displaying the full glossary.
- Registers a custom post type (
pp_glossary) for glossary entries (title + custom fields only, no editor) - Uses native WordPress meta boxes for field management (short description, long description, synonyms)
- Automatically transforms first mentions of glossary terms in content into click-triggered popovers
- Provides a Gutenberg block to display the full glossary with alphabetical navigation
- Settings page to configure which page displays the glossary
pp-glossary/
├── pp-glossary.php # Main plugin file, initialization, hooks
├── includes/
│ ├── functions.php # Helper functions
│ ├── class-post-type.php # Post_Type class - CPT registration
│ ├── class-meta-boxes.php # Meta_Boxes class - Custom meta boxes
│ ├── class-content-filter.php # Content_Filter class - Term replacement
│ ├── class-settings.php # Settings class - Settings page
│ ├── class-blocks.php # Blocks class - Block registration
│ ├── class-schema.php # Schema class - Schema.org integration
│ ├── class-assets.php # Assets class - CSS/JS enqueuing
│ └── class-migrations.php # Migrations class - Data migration on upgrade
├── blocks/
│ └── glossary-list/
│ ├── block.json # Block metadata
│ └── editor.js # Block editor interface (vanilla JS)
└── assets/
├── css/glossary.css # All plugin styles
└── js/glossary.js # Click behavior and accessibility
-
Post Type Registration (
includes/class-post-type.php)- Post type slug:
pp_glossary - Supports:
titleonly (no editor, no revisions - all data is in post meta) has_archiveset tofalse(uses block instead)publicly_queryableset tofalse(no individual entry pages)exclude_from_searchset totrue(entries have no public pages)- Excluded from Yoast SEO indexables and XML sitemaps
- Post type slug:
-
Meta Boxes (
includes/class-meta-boxes.php)- Native WordPress meta boxes (no external dependencies)
- All fields stored in a single post meta key
_pp_glossary_data(array) - Fields (in display order):
short_description(textarea, required)long_description(wp_editor)synonyms(array of strings)case_sensitive(boolean) - only match terms when case matches exactlydisable_autolink(boolean) - entry appears in glossary but not auto-linked in content
- JavaScript inline for adding/removing synonyms dynamically
-
Content Filter (
includes/class-content-filter.php)- Hooks into
the_contentat priority 20 - Finds first occurrence of each term (case-insensitive by default, or case-sensitive if enabled per entry)
- Respects
disable_autolinksetting to skip entries that shouldn't be linked - Generates unique IDs for each popover instance
- Appends popovers at end of content
- Uses settings page URL for "Read more" links (not individual permalinks)
- Skips glossary page itself to prevent self-linking
- Hooks into
-
Settings Page (
includes/class-settings.php)- Submenu under Glossary CPT menu
- Stores settings in
pp_glossary_settingsoption - Settings fields:
glossary_page(int) - ID of the page containing the glossary blockexcluded_tags(array) - HTML tags where terms should not be highlighted (default:a,h1-h6)excluded_post_types(array) - Post types where terms should not be highlighted
- Methods:
get_glossary_page_id(),get_glossary_page_url(),get_excluded_tags(),get_excluded_post_types()
-
Block System (
includes/class-blocks.php,blocks/glossary-list/)- Server-side rendered block using
render_callback - No block attributes (simplified interface)
- Displays all entries grouped alphabetically with navigation
- Supports wide and full alignment
- Falls back to short description when long description is empty
- Shows edit link for logged-in users with edit capabilities per glossary item
- Server-side rendered block using
-
Schema.org Integration (
includes/class-schema.php)- Detects if Yoast SEO is active using
defined('WPSEO_VERSION') - With Yoast SEO: Hooks into
wpseo_schema_graphfilter to add JSON-LD - Without Yoast SEO: Outputs Microdata markup in HTML
- Implements DefinedTermSet (glossary) and DefinedTerm (entries) schemas
- Methods:
add_to_yoast_schema_graph(),get_microdata_attributes(),get_entry_microdata_attributes(),get_itemprop()
- Detects if Yoast SEO is active using
All custom field data is stored in a single WordPress post meta key _pp_glossary_data as an associative array:
[
'short_description' => '', // string
'long_description' => '', // string, HTML allowed
'synonyms' => [], // array of strings
'case_sensitive' => false, // boolean
'disable_autolink' => false, // boolean
]Retrieved using Meta_Boxes::get_entry_data($post_id) which handles defaults. Saved using update_post_meta().
Migration: The plugin includes an automatic migration system that converts old separate meta keys to the new consolidated format on upgrade.
The plugin generates highly semantic, accessible HTML with click triggers and CSS Anchor Positioning:
<dfn id="dfn-{term}-{counter}"
class="pp-glossary-term"
style="anchor-name: --dfn-{term}-{counter};">
<button data-glossary-popover="pop-{term}-{counter}"
type="button"
aria-expanded="false">
{matched term}
</button>
</dfn>
<aside id="pop-{term}-{counter}"
popover="auto"
role="tooltip"
aria-labelledby="dfn-{term}-{counter}"
style="position-anchor: --dfn-{term}-{counter};">
<p><a href="{glossary_page_url}#{slug}">Read more about {term}</a></p>
<p>{Short description}</p>
</aside>Key implementation details:
- Uses
popover="auto"for automatic light-dismiss and mutual exclusivity (only one popover open at a time) - Uses
<button>element for proper accessibility (not span) - Role is
tooltipfor popovers - Triggered by click (not hover) for better accessibility
- Link appears before description for better screen reader context
- Popover positioned using CSS Anchor Positioning API
- Each dfn defines an
anchor-namethat the popover references withposition-anchor
The plugin uses click-based popover control (assets/js/glossary.js):
- Click: Toggles popover visibility on button click
- Keyboard: Enter/Space toggles popover, Escape closes
- Light dismiss: Clicking outside popover closes it (handled by
popover="auto") - Mutual exclusivity: Opening one popover automatically closes others (handled by
popover="auto") - Positioning: Uses CSS Anchor Positioning API for automatic positioning
- ARIA: Updates
aria-expandedattribute on toggle buttons
The plugin uses CSS Anchor Positioning for automatic popover placement (assets/css/glossary.css):
How it works:
- Each
<dfn>element defines an anchor using inlineanchor-name: --dfn-{id}; - Each popover references its anchor using inline
position-anchor: --dfn-{id}; - CSS positions the popover using
anchor()functions:top: anchor(bottom)- Position below the termleft: anchor(left)- Align with left edge of term
- Fallback positions defined with
@position-tryrules for viewport overflow:--top-left- Above the term when it would overflow bottom--bottom-right- Below and right-aligned when it would overflow right--top-right- Above and right-aligned
Benefits:
- Browser automatically handles viewport containment
- No JavaScript calculations needed
- Better performance than JavaScript positioning
- Respects scrolling and transforms automatically
Browser Support:
- Chrome/Edge 125+ (full support)
- Safari/Firefox (not yet supported, popovers still display but may not position optimally)
The plugin provides rich structured data for glossary entries using Schema.org's DefinedTerm and DefinedTermSet types.
Dual-Mode Output:
- Yoast SEO Active: Integrates with Yoast's schema graph (JSON-LD format)
- Yoast SEO Inactive: Outputs Microdata markup in HTML
This ensures compatibility regardless of whether Yoast SEO is installed.
When WPSEO_VERSION is defined:
- Hooks into
wpseo_schema_graphfilter (priority 10) - Adds DefinedTermSet to the graph with all DefinedTerm entries nested inside
- Only runs on the glossary page (checks
get_glossary_page_id()) - Output format: JSON-LD via Yoast's graph system
Schema Structure (JSON-LD):
{
"@type": "DefinedTermSet",
"@id": "https://example.com/glossary/#glossary",
"name": "Glossary Page Title",
"description": "Page excerpt",
"hasDefinedTerm": [
{
"@type": "DefinedTerm",
"@id": "https://example.com/glossary/#term-slug",
"name": "Term Title",
"description": "Short description or Long description (stripped of HTML)",
"url": "https://example.com/glossary/#term-slug",
"alternateName": ["Synonym1", "Synonym2"]
}
]
}When Yoast SEO is NOT active:
- Adds
itemscope,itemtype,itempropattributes to HTML elements - Applied directly to the glossary block container and each entry
- All schema helper methods return empty strings when Yoast is active
- Arrays (like
alternateName) are represented using multiple elements with the sameitemprop
HTML Output (Microdata):
<div class="pp-glossary-block" itemscope itemtype="https://schema.org/DefinedTermSet" itemid="...">
<meta itemprop="name" content="Glossary Title">
<article itemscope itemtype="https://schema.org/DefinedTerm" itemprop="hasDefinedTerm">
<link itemprop="url" href="...">
<h4 itemprop="name">Term Title</h4>
<div class="glossary-synonyms">
<span>Synonym1, Synonym2</span>
<meta itemprop="alternateName" content="Synonym1">
<meta itemprop="alternateName" content="Synonym2">
</div>
<div itemprop="description">Long description</div>
</article>
</div>| Schema Property | WordPress Data | Location |
|---|---|---|
name |
Entry title | get_the_title() |
description |
Short description or Long description | _pp_glossary_short_description or _pp_glossary_long_description meta |
url |
Anchor link | {glossary_page_url}#{slug} |
alternateName |
Array of synonyms | _pp_glossary_synonyms meta |
PP_Glossary_Schema::add_to_yoast_schema_graph($graph, $context)
- Adds glossary to Yoast's schema graph
- Returns modified
$grapharray with DefinedTermSet added
PP_Glossary_Schema::get_microdata_attributes($entries, $page_id)
- Returns microdata attributes for glossary container
- Empty string if Yoast SEO is active
PP_Glossary_Schema::get_entry_microdata_attributes($entry)
- Returns microdata attributes for individual entry
- Empty string if Yoast SEO is active
PP_Glossary_Schema::get_itemprop($prop)
- Returns itemprop attribute for a property name
- Empty string if Yoast SEO is active
if ( defined( 'WPSEO_VERSION' ) ) {
// Use Yoast integration (JSON-LD)
} else {
// Use Microdata
}This check is performed:
- In
init()to determine which hooks to add - In all helper methods to determine output
- Ensures no duplicate schema markup
The alternateName property (synonyms) is output as an array in both formats, but using different methods:
JSON-LD (Yoast SEO):
- Simply assigns the PHP array directly:
$defined_term['alternateName'] = $entry['synonyms']; - JSON-LD supports arrays natively:
"alternateName": ["Synonym1", "Synonym2"]
Microdata:
- Outputs multiple
<meta>tags with the sameitemprop="alternateName" - Each synonym gets its own meta tag:
<meta itemprop="alternateName" content="Synonym1"> - Microdata parsers understand multiple properties with the same name as an array
- Visual display shows comma-separated synonyms in a
<span>, with hidden meta tags for schema
This ensures that search engines see synonyms as an array regardless of the schema format used.
No build process required - vanilla JavaScript and CSS.
Linting:
composer install
composer run phpcs # Check coding standards
composer run phpcbf # Fix coding standards- Terms sorted by length (longest first) to handle overlapping terms
- Entries with
disable_autolinkenabled are skipped entirely - Content inside excluded HTML tags is skipped (configurable via settings, default:
a,h1-h6) - Post types in the excluded list are skipped entirely (configurable via settings)
- Uses regex pattern:
/\b({term})\b(?![^<]*>)/u(or/iufor case-insensitive)\b= word boundaries(?![^<]*>)= negative lookahead to avoid matching inside HTML tagsiflag = case-insensitive (omitted whencase_sensitiveis enabled)uflag = Unicode support
- Only replaces first occurrence per entry per content piece
PP_Glossary_Content_Filter uses static properties during filtering:
$popover_counter- ensures unique IDs$popovers- stores popover HTML for end-of-content appending- These reset on each
filter_content()call
PP_Glossary_Settings::get_glossary_page_url()returns the URL of the page containing the glossary block- "Read more" links use this URL +
#{slug}anchor (slug-based, not ID-based) - If no glossary page is set, "Read more" link is omitted
PP_Glossary_Settings::get_excluded_tags()returns array of HTML tags to skip (default:['a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'])PP_Glossary_Settings::get_excluded_post_types()returns array of post types to skip (default:[])
- Block registered using
register_block_type()with JSON file render_callbackpoints toPP_Glossary_Blocks::render_glossary_list_block()- Editor script uses vanilla JavaScript (no JSX, no build)
- Block shows: entry title (H4), synonyms, description (long description with fallback to short)
- Shows edit link for logged-in users with edit capabilities
- No H2 title at top of block
- Click-to-open behavior (not hover) for better accessibility
<button>element used for triggers (proper keyboard accessibility)aria-expandedupdated via JavaScript when popovers show/hiderole="tooltip"for popoverspopover="auto"ensures only one popover open at a time (no overlapping)- Light dismiss (click outside to close)
- Escape key closes popover
- Link appears before description in popover for better screen reader context
- Dotted underline to indicate definitions without looking like regular links
- Popover positioning accounts for viewport boundaries via CSS Anchor Positioning
Uses CSS custom properties (see assets/css/glossary.css):
--glossary-underline-color # Dotted underline (default: rgba(0,0,0,0.4))
--glossary-underline-hover-color # Hover state (default: rgba(0,0,0,0.7))
--glossary-bg-color
--glossary-border-color
--glossary-accent-color
/* ...and more */Terms inherit text color from surrounding content, only underline indicates glossary term.
Requires two modern web platform features:
Popover API (required):
- Chrome/Edge 114+
- Safari 17+
- Firefox (experimental)
CSS Anchor Positioning (required for optimal positioning):
- Chrome/Edge 125+
- Safari (not yet supported)
- Firefox (not yet supported)
JavaScript checks for both features and logs warnings if unavailable. Consider:
- Popover API polyfill for older browsers
- CSS Anchor Positioning gracefully degrades (popovers still show but may not position optimally)
- Change term matching behavior: Edit
PP_Glossary_Content_Filter::replace_first_occurrence() - Modify popover HTML: Edit
PP_Glossary_Content_Filter::create_popover() - Adjust which content types are processed: Use Settings page or
pp_glossary_disabled_post_typesfilter - Adjust which HTML tags are excluded: Use Settings page or
pp_glossary_excluded_tagsfilter - Customize block output: Edit
PP_Glossary_Blocks::render_glossary_list_block() - Add more custom fields: Modify
Meta_Boxes::render_meta_box()andsave_meta_boxes() - Change popover positioning: Modify CSS anchor positioning rules in
assets/css/glossary.css(seeaside[popover]and@position-tryrules)
- All output properly escaped:
esc_attr()for HTML attributesesc_html()for text contentesc_url()for URLswp_kses_post()for WYSIWYG contentsanitize_textarea_field()for short descriptionsanitize_text_field()for synonyms
- No direct file access checks (
if (!defined('WPINC'))) - Settings sanitized via
sanitize_settings()callback - Block attributes sanitized by WordPress block API
- Nonce verification for meta box saves
- Capability checks before saving
plugins_loaded- Initialize plugin componentsinit(via post-type) - Register post type and blockadd_meta_boxes- Register custom meta boxessave_post_pp_glossary- Save custom field dataadmin_enqueue_scripts- Load admin JavaScript for synonymsthe_content(priority 20) - Filter content for term replacementwp_enqueue_scripts- Load CSS and JavaScriptadmin_menu- Add settings pageadmin_init- Register settingswpseo_schema_graph(priority 10) - Add schema to Yoast SEO graph (when Yoast is active)register_activation_hook- Flush rewrite rules on activationregister_deactivation_hook- Clean up on deactivation
Important: Users must complete this setup:
- Create a page and add the Glossary List block
- Go to Glossary > Settings and select that page
- Add glossary entries (title + custom fields)
- Terms will auto-link in content, "Read more" links point to the selected page
Without step 2, "Read more" links won't appear in popovers.
Synonyms are stored as a simple array of strings:
['CLS', 'layout shift']A local WordPress site is available for testing:
- Site URL: http://localhost:10049/
- Glossary page: http://localhost:10049/glossary/
- Admin: http://localhost:10049/wp-admin/
Use this environment to test:
- Glossary block rendering and alphabetical navigation
- Popover triggers and content display
- Nested term linking within definitions
- CSS styling and accessibility features
- Test with overlapping terms (e.g., "CLS" and "Cumulative Layout Shift")
- Check keyboard navigation (Tab, Enter, Space, Escape)
- Test with screen reader to verify ARIA attributes
- Verify terms maintain surrounding text color
- Check that only first occurrence is linked per term
- Test synonym functionality (add/remove in admin)
- Verify glossary page doesn't highlight its own terms
- Check popover positioning near viewport edges (CSS Anchor Positioning should handle this)
- Test case-sensitive matching (enable on entry, verify exact case required)
- Test disable auto-linking (enable on entry, verify it appears in glossary but not linked in content)
- Verify only one popover can be open at a time
- Verify clicking outside popover closes it (light dismiss)
- Test excluded HTML tags setting (add/remove tags, verify terms are not highlighted within those tags)
- Test excluded post types setting (enable for a post type, verify terms are not highlighted in that post type)
With Yoast SEO:
- Install and activate Yoast SEO
- View glossary page source
- Look for JSON-LD script tag with
@type: "DefinedTermSet" - Verify all entries appear in
hasDefinedTermarray - Test with Google Rich Results Test
- Verify no duplicate schema markup appears
Without Yoast SEO:
- Deactivate Yoast SEO
- View glossary page source
- Look for
itemscope itemtype="https://schema.org/DefinedTermSet"on main div - Verify each entry has
itemscope itemtype="https://schema.org/DefinedTerm" - Check all
itempropattributes are present (name, description, url) - Verify multiple
<meta itemprop="alternateName">tags exist for each synonym - Test with Google Rich Results Test
- Verify Microdata is properly nested and alternateName appears as an array
Validation:
- Use Schema.org Validator
- Use Google Rich Results Test
- Check for any warnings or errors
- Verify all required properties are present