WP Manifestindependent plugin directory
manifest / seo / cgit-wp-schema-map

Castlegate IT WP Schema Map

A lightweight WordPress plugin that maps existing site content to Schema.org structured data output.

by Castlegate IT · github.com/castlegateit/cgit-wp-schema-map · website

0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/castlegateit/cgit-wp-schema-map/archive/refs/heads/main.zip

Declares an update source (https://github.com/castlegateit/cgit-wp-schema-map), so updates arrive through the plugin's own updater.

Readme

CGIT WP Schema Map

A lightweight WordPress plugin that maps existing site content to Schema.org structured data output. It wraps spatie/schema-org for JSON-LD generation while abstracting away the structural boilerplate of complex schema types.

This is a companion to cgit-wp-schema, not a replacement. Where cgit-wp-schema owns and registers post types with schema baked in, cgit-wp-schema-map is designed to retrofit schema onto existing content structures — arbitrary post types, ACF fields, flex content blocks — without prescribing how that content is managed.

Requirements

  • PHP 8.2+

Admin bar

When viewing the front end as a logged-in user with the manage_options capability, a Schema menu appears in the admin bar listing every schema type output on the current page. Two links are provided at the bottom of the menu to validate the page's structured data in a new tab:

To change the required capability, use the cgit_wp_schema_map_admin_bar_capability filter:

add_filter('cgit_wp_schema_map_admin_bar_capability', fn() => 'edit_posts');

Namespaces

Schema types are grouped into namespaces that reflect the Schema.org hierarchy:

Namespace Classes
Castlegate\SchemaMap\Schema\Article Article, NewsArticle, BlogPosting
Castlegate\SchemaMap\Schema\Organization Organization, LocalBusiness, FoodEstablishment
Castlegate\SchemaMap\Schema\Product Product, Vehicle
Castlegate\SchemaMap\Schema Everything else

Nested schema types

Several properties require a fully-formed schema instance rather than a plain string. Schema.org does not allow text values for these properties, and passing a string where a schema instance is required will cause a PHP type error.

Property Valid schema types
author (Article, Book) Person or Organization
publisher (Article, Book) Organization or Person
provider (Course) Organization or Person
organizer (Event) Organization or Person
seller (Offer) Organization or Person
worksFor (Person) Organization
manufacturer (Product, Vehicle) Organization
aggregateRating (Organization, LocalBusiness, Product, Vehicle) AggregateRating
addOffer() (Product, Vehicle) Offer
addReview() (LocalBusiness) Review

The exception is location on Event, which accepts either a plain string (venue name) or a Place instance — schema.org allows text for this property.


Registering schema

Call Registry::register() anywhere that runs before wp_head — typically the template file that renders the content. The callback receives the current $post object.

use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Article\Article;

if (class_exists(Registry::class)) {
    Registry::register(function ($post) {
        if (!is_singular('post')) {
            return null;
        }

        $article = new Article();
        $article->headline($post->post_title);
        $article->datePublished($post->post_date);

        return $article;
    });
}

Returning null, or returning a schema object whose isEmpty() check fails, suppresses output for that registration. There is no separate targeting system — use conditions inside the callback or rely on which template file the registration lives in.

Multiple schemas on one page

Register separate callbacks for each schema block. All registrations run on every request and each independently decides whether to output:

use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\BreadcrumbList;
use Castlegate\SchemaMap\Schema\Organization\Organization;
use Castlegate\SchemaMap\Schema\Article\Article;

if (class_exists(Registry::class)) {
    Registry::register(function () {
        $breadcrumb = new BreadcrumbList();
        $breadcrumb->addItem('Home', home_url('/'));
        $breadcrumb->addItem(get_the_title(), get_permalink());
        return $breadcrumb;
    });

    Registry::register(function () {
        $org = new Organization();
        $org->name(get_bloginfo('name'));
        $org->url(home_url());
        return $org;
    });

    Registry::register(function ($post) {
        if (!is_singular('post')) {
            return null;
        }

        $article = new Article();
        $article->headline($post->post_title);
        $article->datePublished($post->post_date);

        return $article;
    });
}

FaqPage

use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\FaqPage;

if (class_exists(Registry::class)) {
    Registry::register(function ($post) {
        $faq = new FaqPage();

        foreach (get_field('faqs', $post->ID) ?: [] as $item) {
            $faq->addQuestion(
                $item['question'],
                wp_strip_all_tags($item['answer'])
            );
        }

        return $faq;
    });
}

Merging across templates

FaqPage implements MergeableSchema. If multiple registrations on the same page return a FaqPage, the Registry automatically merges all questions into a single output block. This means templates can add their own questions without knowing about each other.

header.php — site-wide FAQ registered on every page:

use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\FaqPage;

if (class_exists(Registry::class)) {
    Registry::register(function ($post) {
        $faq = new FaqPage();
        $faq->addQuestion('Where are you based?', 'We are based in York, UK.');
        $faq->addQuestion('Do you work remotely?', 'Yes, we work with clients across the UK.');
        return $faq;
    });
}

single-service.php — service-specific questions:

use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\FaqPage;

if (class_exists(Registry::class)) {
    Registry::register(function ($post) {
        $faq = new FaqPage();

        foreach (get_field('service_faqs', $post->ID) ?: [] as $item) {
            $faq->addQuestion($item['question'], $item['answer']);
        }

        return $faq;
    });
}

Both registrations contribute questions and the output is a single merged FAQPage block. If no questions are added to a FaqPage instance, isEmpty() returns true and it is silently dropped.


Article hierarchy

Article, NewsArticle, and BlogPosting share the same base class. All Article setters are available on the subtypes.

Article
├── NewsArticle
└── BlogPosting

Article

use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Article\Article;
use Castlegate\SchemaMap\Schema\Organization\Organization;
use Castlegate\SchemaMap\Schema\Person;

if (class_exists(Registry::class)) {
    Registry::register(function ($post) {
        if (!is_singular('post')) {
            return null;
        }

        $author = new Person();
        $author->name(get_the_author_meta('display_name', $post->post_author));
        $author->url(get_author_posts_url($post->post_author));

        $publisher = new Organization();
        $publisher->name(get_bloginfo('name'));
        $publisher->url(home_url());
        $publisher->logo(get_field('logo_url', 'option'));

        $article = new Article();
        $article->headline($post->post_title);
        $article->datePublished($post->post_date);    // MySQL datetime string accepted
        $article->dateModified($post->post_modified); // converted to ISO 8601 automatically
        $article->description(get_the_excerpt($post));
        $article->url(get_permalink($post));
        $article->image(get_the_post_thumbnail_url($post, 'full'));
        $article->keywords(implode(', ', wp_get_post_tags($post->ID, ['fields' => 'names'])));
        $article->articleBody(apply_filters('the_content', $post->post_content)); // HTML stripped automatically
        $article->author($author);
        $article->publisher($publisher);

        return $article;
    });
}

NewsArticle

Adds print publication metadata on top of all Article properties.

use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Article\NewsArticle;

if (class_exists(Registry::class)) {
    $article = new NewsArticle();
    $article->headline($post->post_title);
    $article->datePublished($post->post_date);
    $article->dateModified($post->post_modified);
    $article->author($author);
    $article->publisher($publisher);
    // NewsArticle-specific fields:
    $article->dateline('York');
    $article->printEdition('Morning Edition');
    $article->printSection('Business');
    $article->printPage('3');
    $article->printColumn('2');
}

BlogPosting

BlogPosting uses the same setters as Article — the only difference is the @type emitted in the JSON-LD output, which signals to search engines that the content is a blog post.

use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Article\BlogPosting;

if (class_exists(Registry::class)) {
    $post_schema = new BlogPosting();
    $post_schema->headline($post->post_title);
    $post_schema->datePublished($post->post_date);
    $post_schema->dateModified($post->post_modified);
    $post_schema->url(get_permalink($post));
    $post_schema->image(get_the_post_thumbnail_url($post, 'full'));
}

Organization hierarchy

Organization, LocalBusiness, and FoodEstablishment form a cascade. Every property available on Organization is also available on LocalBusiness, and every property on LocalBusiness is available on FoodEstablishment.

Organization
└── LocalBusiness
    └── FoodEstablishment

Organization

use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Organization\Organization;

if (class_exists(Registry::class)) {
    $org = new Organization();
    $org->name(get_bloginfo('name'));
    $org->url(home_url());
    $org->logo(get_field('logo_url', 'option'));
    $org->telephone(get_field('telephone', 'option'));
    $org->email(get_field('email', 'option'));
    $org->faxNumber(get_field('fax', 'option'));
    $org->description(get_bloginfo('description'));
    $org->image(get_field('og_image', 'option'));
    $org->sameAs([
        get_field('twitter_url', 'option'),
        get_field('linkedin_url', 'option'),
        get_field('facebook_url', 'option'),
    ]);
}

sameAs accepts either a single string or an array. Empty or null values in the array are ignored by spatie at render time.

LocalBusiness

Adds address, geo coordinates, opening hours, price range, and reviews.

use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Organization\LocalBusiness;

if (class_exists(Registry::class)) {
    $business = new LocalBusiness();
    // Inherits all Organization setters:
    $business->name(get_bloginfo('name'));
    $business->url(home_url());
    $business->telephone(get_field('telephone', 'option'));
    $business->logo(get_field('logo_url', 'option'));
    // LocalBusiness-specific:
    $business->streetAddress(get_field('address_street', 'option'));
    $business->addressLocality(get_field('address_city', 'option'));
    $business->postalCode(get_field('address_postcode', 'option'));
    $business->addressCountry('GB');
    $business->geo(53.9600, -1.0873);
    $business->openingHours([
        'Mo-Tu 09:00-17:30',
        'We 09:00-13:00',
        'Th-Fr 09:00-17:30',
    ]);
    $business->priceRange('££');
}

Address components are only emitted when at least one is set — an empty PostalAddress block is never output. Geo requires both latitude and longitude; providing only one suppresses the GeoCoordinates block entirely.

FoodEstablishment

Extends LocalBusiness with cuisine, menu URL, and reservation support. All Organization and LocalBusiness setters work directly.

use Castlegate\SchemaMap\Registry;
use Castlegate\SchemaMap\Schema\Organization\FoodEstablishment;

if (class_exists(Registry::class)) {
    $restaurant = new FoodEstablishment();
    // Organization properties:
    $restaurant->name('The Grill at York');
    $restaurant->url(home_url());
    $restaurant->telephone('+44 1904 000000');
    $restaurant->sameAs(['https://instagram.com/thegrillatyork']);
    // LocalBusiness properties:
    $restaurant->streetAddress('12 High Street');
    $restaurant->addressLocality('York');
    $restaurant->postalCode('YO1 8AA');
    $restaurant->addressCountry('GB');
    $restaurant->geo(53.9600, -1.0873);
    $restaurant->openingHours(['Mo-Su 12:00-22:00']);
    $restaurant->priceRange('£££');
    // FoodEstablishment properties:
    $restaurant->servesCuisine('Modern British');
    $restaurant->hasMenu(home_url('/menu'));
    $restaurant->acceptsReservations(true);
}

Product hierarchy

Product
└── Vehicle

Read the full README on GitHub →

Releases

TagPublished
v1.1.0 Jul 2, 2026
v1.0.0 Jul 1, 2026

These releases are tags only. The author does not attach a packaged zip, so there are no download counts to report.