WP Manifestindependent plugin directory
manifest / seo / schema-editor

Schema Editor

WordPress plugin: JSON-LD schema templates populated from the database, merged into Rank Math's graph

by Joeri Vanhamel · github.com/joerivanhamel/schema-editor · 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/joerivanhamel/schema-editor/archive/refs/heads/main.zip

A WordPress plugin by Joeri Vanhamel for writing JSON-LD schema templates that are populated from the database and applied by display targets. When Rank Math is active the templates are merged into Rank Math's own @graph, so a page never carries two graphs and Rank Math's Organization, WebSite, WebPage, breadcrumb and article nodes stay exactly as Rank Math builds them. Without Rank Math the plugin prints its own graph.

Nothing is hardcoded. A template says where a value comes from ({{post.title}}, {{acf.hero_image|image}}, {{option.legal_name}}, {{rankmath.description}}) and the plugin reads it at request time. A property whose source is empty is left out, so a missing field never produces an empty or wrong value, and a node missing one of its @required properties is left out whole.

Requires WordPress 6.4+ and PHP 8.1+. Works with or without Rank Math and Advanced Custom Fields.

Why not Rank Math's own schema settings

Rank Math Free sets one schema type per post type and one per post, with fixed shapes. Site-wide templates with display conditions are a Rank Math PRO feature. This plugin gives the same "one template per page type" model to any site, with three things PRO does not do: editing or removing nodes other code added (merge and remove operations), repeating a node per related post (@repeat, for example one Person per team member), and the omit- when-empty rule that keeps the output valid without anyone checking it by hand.

Install

Download schema-editor.zip from the latest release and upload it under Plugins → Add New → Upload Plugin, then activate. Or copy the schema-editor folder into wp-content/plugins/. No build step; the plugin ships its own autoloader. Composer is only needed for the unit tests.

To try the examples, import templates/examples/ (Schema Editor → Settings & Import, or wp schema-editor import wp-content/plugins/schema-editor/templates/examples/) and adapt the field names to your theme.

Writing a template

Schema Editor → Add Template. The document is JSON:

{
  "name": "Service page",
  "enabled": true,
  "priority": 10,
  "targets": [ { "type": "page_template", "value": "page-consulting.php" } ],
  "exclude": [],
  "nodes": [
    {
      "@op": "add",
      "@key": "Service",
      "@required": [ "name" ],
      "@type": "Service",
      "@id": "{{post.permalink}}#service",
      "name": "{{post.title}}",
      "description": "{{ acf.hero_subtitle || rankmath.description }}",
      "provider": { "@id": "{{site.url}}#organization" },
      "image": "{{ acf.hero_image|image:2048x2048 || post.featured_image }}"
    },
    { "@op": "merge", "@match": "#webpage", "mainEntity": { "@id": "{{post.permalink}}#service" } }
  ]
}

Targets

A template applies when any target matches and no exclude matches. Types:

type value matches
all every front-end request
singular any single post, page or custom post
post_type post type single posts of that type
page_template file name, e.g. page-about.php, or default pages using that template
post post ID that post
slug post slug that post
term taxonomy:slug single posts carrying that term
front_page the front page
posts_page the blog listing
post_type_archive post type that archive
taxonomy taxonomy its term archives
search, 404 those pages
author optional: user nicename or ID author archives (author.php), one author or all

Templates run in priority order (lower first); a later template may edit or replace what an earlier one added.

Operations

@op needs effect
add (default) @type adds the node under @key (default: the type). An existing node with the same key is replaced, which is how a template overrides a node from the theme or another template.
merge @match merges the rendered properties into every matching node (existing properties are overwritten, @type never).
remove @match, optional properties removes the listed properties from matching nodes, or the whole node when no list is given.

@match selectors: #organization (an @id ending in that fragment), @type:WebPage, @key:Service, or a full @id.

Other directives: @required (property names the node must end up with), @if (an expression; the node is only rendered when it is non-empty), @repeat (WP_Query arguments; the node is rendered once per result, that result being post and the page parent), and @each inside a value (an array-valued path; one object per entry, the entry being item).

Placeholders

{{ path | filter:arg | filter || other.path || 'literal' }}. Alternatives (||) are tried left to right and the first non-empty one wins. Strings are cleaned to plain text (tags stripped, entities decoded, whitespace collapsed) unless |raw is present. A string that is exactly one placeholder keeps the value's type, so {{terms.feature}} yields a JSON array. A string mixing text and placeholders is omitted when any placeholder is empty.

path value
post.id, post.title, post.permalink, post.slug, post.type the post
post.excerpt, post.content plain text
post.date, post.modified ISO 8601
post.featured_image, post.featured_image.large, post.featured_image_id the featured image
post.author.name, post.author.url, post.author.id, post.author.description, post.author.avatar, post.author.acf.FIELD the post's WordPress author (ACF user fields through acf.)
author.* same keys; the queried author on an author archive, otherwise the post's author
parent.* the page being rendered, inside @repeat
item.* the current @each entry
acf.FIELD, acf.FIELD.sub, acf.FIELD.url an ACF field; .url on an attachment gives its URL
acf.*_hero_image the first non-empty field whose name matches the glob
option.FIELD an ACF options field (get_field(…, 'option')), globs allowed
meta.KEY raw post meta
rankmath.title, rankmath.description the post's Rank Math title and description
rankmath.titles.KEY, rankmath.general.KEY, rankmath.sitemap.KEY Rank Math settings
site.url, site.name, site.description, site.language the site
terms.TAXONOMY, terms.TAXONOMY.slugs, terms.TAXONOMY.first, terms.all the post's terms (all: every public taxonomy of its type)
settings.NAME a WordPress option

Filters: image[:size] (attachment ID, ACF image array or URL → URL; size is a name or 1024x576), permalink, year, int, float, bool, lower, upper, ucfirst, first_word, slug, words:N, chars:N, join (comma and space) or join:'SEP' (quote an argument that carries spaces), first, count, date:FORMAT, default:'x', youtube_id, youtube_embed, youtube_watch (each accepts a bare ID or any YouTube URL), iso_duration ("1:24" → PT1M24S), raw.

acf.*_hero_image style globs are matched against the field names the field groups define (not only saved values), so a field still holding its ACF default value resolves too. The Preview box lists every ACF field on the chosen post with a glimpse of its value.

Preview

Every template has a Preview box: give it a post ID or URL and it renders the document in the editor (saved or not) plus every other saved template that applies, and prints the resulting nodes. Rank Math's own nodes only exist on the live request, so check the final graph with Google's Rich Results Test or view-source once the template is saved.

Settings & Import

Schema Editor → Settings & Import: output mode (automatic, Rank Math only, standalone, off); always print Rank Math's Organization, WebSite and WebPage nodes on single posts and pages (on by default: Rank Math leaves them out when a post's schema type is "off", and a template then has nothing to merge into); remove Rank Math's sitelinks search box; a footer HTML comment naming the templates that ran; delete templates on uninstall. Export downloads every template as one JSON file; Import reads one document or an array and creates or updates by name.

WP-CLI

wp schema-editor list
wp schema-editor import path/to/file-or-directory/
wp schema-editor export --file=templates.json
wp schema-editor render 6            # or a URL: the nodes the plugin adds for that post

Development

composer install
composer lint
composer test

Without PHP on the machine, the same through Docker:

docker run --rm -v "$PWD:/app" -w /app composer:2 install
docker run --rm -v "$PWD:/app" -w /app php:8.3-cli vendor/bin/phpunit

A throwaway WordPress with Rank Math for end-to-end checks is in docker/docker-compose.yml (instructions at the top of the file). The rendering rules (Renderer, Expression), the graph operations (GraphMerger) and target matching (Targets) are pure PHP with no WordPress dependency and are covered by the unit tests; the WordPress resolver, admin screens and output hooks are exercised in the Docker site.

Layout:

schema-editor.php        bootstrap and autoloader
src/Plugin.php           composition root
src/Template/            Template (document), Repository (storage), Targets, RequestContext
src/Render/              Expression, Renderer, Context, resolvers (WordPress and array)
src/Output/              Engine, GraphMerger, RankMathBridge, StandaloneOutput
src/Admin/               post type, editor meta boxes, preview endpoint, settings/import/export
src/Cli/Command.php      WP-CLI
templates/examples/      example templates to import and adapt
tests/                   PHPUnit

Google's guidelines

Templates can express anything Schema.org allows; whether Google accepts it is a separate question. The examples follow Google's structured-data guidelines: organization-hygiene.json, for instance, removes self-serving review markup and an unpublished price range from the Organization node. When a site wants to deviate from the guidelines, keep the deviation in its own template, disabled by default, with the reasoning in its name, so turning it on is a deliberate choice.

Licence

GPL-2.0-or-later.