Perimetre Core WP Registry grade A-. Low-severity findings · 1 findingWP RegistryLow-severity findings · 1 findingOpen the reportA-
WP plugin to handle code Perimetre functions and standards
Install
Upload it under Plugins → Add New Plugin → Upload Plugin, then activate.
Install straight from GitHub with WP-CLI:
wp plugin install https://github.com/perimetre/perimetre-core-wp-plugin/releases/download/v2.4.0/perimetre-core-2.4.0.zipShared agency plugin for Perimetre WordPress projects. Built for headless setups, but safe to drop into any standard WordPress site — every feature is opt-in and defaults to inert.
Perimetre Core defines the rules. Project Core follows them.
Repository: perimetre-core-wp-plugin
WordPress plugin slug: perimetre-core
The repository name reflects the platform and purpose. The plugin slug is what WordPress uses internally and stays consistent across all projects.
Requirements
- PHP 8.1+
- WordPress 6.4+
- ACF Pro (for ACF blocks)
- WPGraphQL
- WPGraphQL Content Blocks
- WPGraphQL for ACF
What This Plugin Does
- Provides abstract base classes for registering ACF blocks and custom native blocks
- Provides a central block registry that Project Core uses to register all blocks
- Provides GraphQL registration utilities that enforce naming conventions
- Hosts shared blocks that are used across multiple projects (grows over time)
- Provides configurable outgoing webhooks on post status changes (publish, draft, trash, delete)
Operational features moved out. The status / health-check endpoint and Helm portal Remote Login were split into a separate plugin, Perimetre WP Tools, so they can be deployed on any site (including standard, non-headless client sites) without the block/GraphQL framework. See the 2.0.0 changelog.
What it does not do:
- Manage third-party plugins or dependencies
- Render block markup on its behalf —
AcfBlockprovides hooks (render_preview()/render_frontend()) but the markup is the subclass's job - Contain project-specific logic of any kind
Where This Works
Perimetre Core is designed for headless Perimetre projects but is safe to install on any standard WordPress site. The plugin's surface area is opt-in and the defaults don't change anything about a host site:
- Webhooks are off by default. The dispatcher doesn't fire until the master toggle is on and a URL is configured.
- Block abstracts (
AcfBlock,NativeBlock) only register blocks the host project explicitly opts into viaRegistry::register_block(). Existing ACF blocks registered the conventional way are untouched. - GraphQL utilities are no-ops unless WPGraphQL is installed and active.
- CTA helpers are pure functions — they only run when called.
How AcfBlock differs between contexts:
- Headless site: the WordPress frontend isn't visited. The default
render_preview()shows an editor placeholder andrender_frontend()is a no-op (or emits the bare InnerBlocks token for nested-children blocks). Subclasses leave both alone — frontend rendering happens in the headless consumer. - Standard site: subclasses override
render_frontend()with real markup (typicallyget_field()calls or atemplate-parts/blocks/<slug>.phpinclude). The editor placeholder still shows in the canvas. See Frontend rendering below.
Versioning
Releases follow semantic versioning:
| Increment | When |
|---|---|
1.0.1 — patch |
Bug fixes, no breaking changes |
1.1.0 — minor |
New features, backwards compatible |
2.0.0 — major |
Breaking changes — renamed classes, changed method signatures, restructured namespaces |
Projects should never update to a new major version without testing on staging first.
To create a release, push a version tag:
git tag v1.2.0
git push origin v1.2.0
This triggers the release workflow which builds a clean plugin zip and publishes it as a GitHub release with auto-generated release notes.
Installation
Download the zip for the desired version from the GitHub releases page and install via WP-CLI:
# Local (DDEV)
ddev wp plugin install /path/to/perimetre-core-1.2.0.zip --activate
# Remote server
wp plugin install /path/to/perimetre-core-1.2.0.zip --activate
Record the installed version in the project's plugins.md.
Updates are detected automatically via GitHub Releases. When a new version is published, WordPress will show an update notice in the admin dashboard, allowing one-click updates. You can also update manually by downloading the release zip and installing it.
Structure
perimetre-core/
├── perimetre-core.php Plugin entry point. Loads autoloader, bootstraps registries.
├── composer.json Autoloading and Plugin Update Checker dependency.
├── composer.lock
├── vendor/
│ └── autoload.php Generated autoloader. Committed to the repository.
├── src/
│ ├── Plugin.php Textdomain loading and top-level bootstrap helpers.
│ ├── Acf/
│ │ └── cta-fields.php Helper functions for CTA and CTA Group ACF fields.
│ ├── Blocks/
│ │ ├── Registry.php Central block registry. Used by Project Core to register blocks.
│ │ ├── AcfBlock.php Abstract base class for ACF blocks.
│ │ ├── NativeBlock.php Abstract base class for custom native blocks.
│ │ └── Shared/ Shared blocks used across projects. Empty initially.
│ ├── GraphQL/
│ │ └── Registry.php GraphQL registration utilities. Enforces naming conventions.
│ └── Webhook/
│ ├── Settings.php Webhooks options page (ACF-backed).
│ └── Dispatcher.php Post status hooks and outgoing HTTP dispatch.
├── languages/
│ ├── perimetre-core.pot Translation template.
│ ├── perimetre-core-fr_FR.po French translations.
│ └── perimetre-core-fr_FR.mo Compiled French translations.
└── README.md
Namespace
All Perimetre Core classes live under the Perimetre\Core namespace, following PSR-4:
Perimetre\Core\Plugin → src/Plugin.php
Perimetre\Core\Blocks\Registry → src/Blocks/Registry.php
Perimetre\Core\Blocks\AcfBlock → src/Blocks/AcfBlock.php
Perimetre\Core\Blocks\NativeBlock → src/Blocks/NativeBlock.php
Perimetre\Core\GraphQL\Registry → src/GraphQL/Registry.php
Perimetre\Core\Webhook\Settings → src/Webhook/Settings.php
Perimetre\Core\Webhook\Dispatcher → src/Webhook/Dispatcher.php
How to Register Blocks from Project Core
All blocks — whether defined in Perimetre Core or Project Core — are registered through Perimetre\Core\Blocks\Registry. Call register_block() before the init hook fires.
use Perimetre\Core\Blocks\Registry;
add_action('plugins_loaded', function () {
Registry::register_block(\MicroBird\Core\Blocks\HeroBlock::class);
Registry::register_block(\MicroBird\Core\Blocks\FeaturedPostsBlock::class);
});
How to Create an ACF Block
Extend Perimetre\Core\Blocks\AcfBlock in Project Core (or in Perimetre Core for shared blocks).
<?php
declare(strict_types=1);
namespace MicroBird\Core\Blocks;
use Perimetre\Core\Blocks\AcfBlock;
class HeroBlock extends AcfBlock
{
protected function get_name(): string
{
return 'micro-bird/hero';
}
protected function get_title(): string
{
return __('Hero', 'micro-bird-core');
}
protected function get_graphql_field_name(): string
{
// This becomes the field name on the block type in GraphQL:
// ... on MicroBirdHeroBlock { hero { heading } }
return 'hero';
}
protected function get_description(): string
{
return __('Full-width hero section.', 'micro-bird-core');
}
protected function register_fields(): void
{
acf_add_local_field_group([
'key' => 'group_micro_bird_hero',
'title' => 'Hero',
'show_in_graphql' => 1,
'graphql_field_name' => $this->get_graphql_field_name(),
'location' => [[
['param' => 'block', 'operator' => '==', 'value' => $this->get_acf_name()],
]],
'fields' => [
[
'key' => 'field_micro_bird_hero_heading',
'label' => 'Heading',
'name' => 'heading',
'type' => 'text',
],
[
'key' => 'field_micro_bird_hero_subheading',
'label' => 'Subheading',
'name' => 'subheading',
'type' => 'textarea',
],
],
]);
}
}
The block is then queryable in GraphQL:
... on MicroBirdHeroBlock {
hero {
heading
subheading
}
}
InnerBlocks (optional)
To allow nested child blocks inside an ACF block, override get_inner_blocks_template(). Returning a non-null template automatically bumps the block to acf_block_version: 2 and merges 'jsx' => true into supports, so the editor renders the InnerBlocks slot.
protected function get_inner_blocks_template(): ?array
{
return [
['core/columns', [], [
['core/column', [], []],
['core/column', [], []],
]],
];
}
protected function get_allowed_blocks(): ?array
{
return ['core/columns', 'core/column', 'core/paragraph'];
}
protected function get_template_lock(): false|string
{
return 'all';
}
get_allowed_blocks() returns the post-transform names (e.g. acf/micro-bird-card, core/paragraph). get_template_lock() accepts false, 'all', 'insert', or 'contentOnly'.
Frontend rendering
AcfBlock::render() dispatches to two protected methods:
render_preview()— what the block editor canvas shows. Default: a labeled placeholder div, or the InnerBlocks slot for blocks with a template.render_frontend()— what visitors see on the public page. Default: nothing for plain blocks; the bare<InnerBlocks />JSX token for InnerBlocks-enabled blocks (so ACF v2 expands children at the right position).
On a headless site the frontend isn't rendered by WordPress, so the defaults are correct as-is — leave both alone.
On a standard (non-headless) site, override render_frontend() with real markup using get_field() calls (or include a template-parts/blocks/<slug>.php file). For InnerBlocks-enabled blocks, call $this->emit_inner_blocks_token($this->get_inner_blocks_template()) from your override so child blocks still render at the desired position. You may also want to override get_mode() to return 'preview' for live in-editor rendering.
protected function render_frontend(array $block, string $content, int $post_id): void
{
$heading = get_field('heading');
$subheading = get_field('subheading');
?>
<section class="hero">
<h1><?= esc_html($heading) ?></h1>
<p><?= esc_html($subheading) ?></p>
</section>
<?php
}
How to Create a Custom Native Block
Extend Perimetre\Core\Blocks\NativeBlock and place a block.json in the block directory.
<?php
declare(strict_types=1);
namespace MicroBird\Core\Blocks;
use Perimetre\Core\Blocks\NativeBlock;
class CalloutBlock extends NativeBlock
{
protected function get_name(): string
{
return 'micro-bird/callout';
}
protected function get_block_dir(): string
{
return MICRO_BIRD_CORE_PATH . 'blocks/callout';
}
}
The block.json in blocks/callout/ defines all attributes. These are automatically exposed in GraphQL via WPGraphQL Content Blocks under attributes:
... on MicroBirdCalloutBlock {
attributes {
message
type
}
}
How to Register Custom GraphQL Fields
Use Perimetre\Core\GraphQL\Registry instead of calling WPGraphQL functions directly. This enforces naming conventions.
use Perimetre\Core\GraphQL\Registry as GraphQLRegistry;
add_action('graphql_register_types', function () {
GraphQLRegistry::register_field('Post', 'formattedDate', [
'type' => 'String',
'description' => 'The post date formatted for display.',
'resolve' => fn ($post) => get_the_date('F j, Y', $post->databaseId),
]);
});
Field names must be camelCase. Type names must be PascalCase. Violations trigger a _doing_it_wrong() notice in debug mode.
CTA Field Helpers
Perimetre Core provides two helper functions for registering CTA (Call-to-Action) fields, so every project uses the same field structure.
perimetre_cta_fields
Returns ACF sub-fields for a single CTA: a link field, a variant select, and an optional icon (SVG or PNG).
perimetre_cta_fields(
string $prefix = 'cta',
array $variants = ['default' => 'Default', 'primary' => 'Primary', 'secondary' => 'Secondary'],
): array
The icon sub-field is registered as an ACF image field with return_format: id and mime_types: svg,png. It is optional (required: 0) and exposed through WPGraphQL.
perimetre_cta_group_fields
Returns a tab + repeater of CTAs (each with link, variant, and optional icon), ready to spread into a field group's fields array.
perimetre_cta_group_fields(
string $prefix = 'cta_group',
string $label = 'CTAs',
int $min = 0,
int $max = 3,
array $variants = ['default' => 'Default', 'primary' => 'Primary', 'secondary' => 'Secondary'],
): array
Usage
Spread the result into your ACF field group's fields array:
'fields' => [
[
'key' => 'field_micro_bird_hero_heading',
'label' => 'Heading',
'name' => 'heading',
'type' => 'text',
],
...perimetre_cta_group_fields('hero_ctas', 'Hero CTAs'),
],
To customize variants or limits:
...perimetre_cta_group_fields(
prefix: 'hero_ctas',
label: 'Hero CTAs',
min: 1,
max: 2,
variants: ['primary' => 'Primary', 'ghost' => 'Ghost'],
),
Template example
$ctas = get_field('hero_ctas');
if ($ctas) : ?>
<div class="cta-group">
<?php foreach ($ctas as $cta) :
$link = $cta['hero_ctas_item_link'];
$variant = $cta['hero_ctas_item_variant'];
if ($link) : ?>
<a href="<?= esc_url($link['url']) ?>"
class="btn btn--<?= esc_attr($variant) ?>"
target="<?= esc_attr($link['target']) ?>">
<?= esc_html($link['title']) ?>
</a>
<?php endif; endforeach; ?>
</div>
<?php endif; ?>
Webhooks
Perimetre Core can fire outgoing HTTP POST requests when posts change status. Configure it under Settings → Perimetre Core (requires ACF Pro).
Designed for headless on-demand revalidation (e.g. Next.js revalidatePath / revalidateTag), but the dispatch mechanism is generic — any HTTP-receiving consumer (sync jobs, automation tools, third-party integrations) can be the target. The master toggle is off by default; nothing fires until it's enabled and at least one URL is configured. Each watched event is dispatched to every configured URL.
Settings
| Setting | Default | Description |
|---|---|---|
| Enable Webhooks | Off | Master toggle. When off, no requests are sent. |
| Webhook URLs | — | One or more endpoints that receive the POST request. Add as many as needed; every URL receives each event. |
| Secret Token | — | Sent as a Bearer token in the Authorization header on every request. |
| Watched Post Types | All public types | Which post types trigger webhooks. Leave empty to watch all. |
| Watched Events | Publish, Trash, Delete | Which events trigger webhooks (post changes, options saves, menu updates). |
| Request Timeout (s) | 5 | HTTP timeout (1–30 seconds). Requests are non-blocking. |
Events
| Event | Trigger |
|---|---|
post.published |
Post transitions into publish from another status |
post.updated |
Already-published post is saved again |
post.drafted |
Post transitions to draft or pending |
post.privatized |
Post transitions to private |
post.scheduled |
Post transitions to future |
post.trashed |
Post transitions to trash |
post.deleted |
Post is permanently deleted |
options.saved |
ACF options page is saved (excludes the webhook settings page itself) |
menu.saved |
Navigation menu is created or updated |
menu.deleted |
Navigation menu is deleted |
Post Payload
{
"event": "post.published",
"post_id": 42,
"post_type": "page",
"post_slug": "about-us",
"post_title": "About Us",
"permalink": "/about-us/",
"language": "en",
"taxonomies": {
"category": ["news"],
"post_tag": ["launch"]
},
"timestamp": 1713000000,
"old_status": "draft",
"new_status": "publish"
}
permalink— relative URL path, useful for on-demand revalidation (e.g. Next.jsrevalidatePath)language— WPML language code when WPML is active,nullotherwisetaxonomies— public taxonomy terms keyed by taxonomy slug, so the frontend can revalidate archive pagesold_status/new_status— included on status transitions, omitted on permanent deletes
Options Payload
{
"event": "options.saved",
"options_page": "acf-options-seo",
"timestamp": 1713000000
}
Menu Payload
{
"event": "menu.saved",
"menu_id": 3,
"menu_name": "Main Navigation",
"menu_slug": "main-navigation",
"timestamp": 1713000000
}
SEO — Meta Descriptions from ACF Content
Yoast cannot see ACF. Its %%excerpt%% variable falls back to post_content when the excerpt is empty, and on an ACF-block page post_content holds only the block delimiter comments — which wp_strip_all_tags() removes, leaving nothing. Classic-editor CPTs whose text lives in ACF postmeta have the same problem from the other side. Either way, posts without a hand-written meta description ship without one.
Core registers %%perimetre_excerpt%%, which reads the prose out of wherever ACF actually put it: it walks the block tree (or the post's ACF fields), keeps the text / textarea / wysiwyg values in reading order, and trims the result to meta-description length.
Setup — one step per content type
In SEO → Settings → Content types → \<type> → Meta description, set the template to %%perimetre_excerpt%%.
Nothing happens until you do — a registered variable is inert until a template references it. Yoast is not a Core dependency; without it the whole feature no-ops.
What it guarantees
- A description an editor typed always wins. Yoast only consults the type's template when the post's own meta description is empty.
- It is a superset of
%%excerpt%%, never worse. Content ACF never touched (core blocks, Classic-editor HTML) falls back to the same text the built-in variable would have produced. This matters because a Yoast template concatenates its variables rather than picking the first non-empty one — a template cannot express a fallback, so the variable has to. Safe as a template's sole value. - Nothing is stored. The value is computed when Yoast resolves a description and discarded. It tracks the content with no save hook and no staleness, applies to existing content with no backfill, and never writes — so it cannot fire a second
transition_post_statusand double-trigger a frontend rebuild. - Cost is flat, 0.02–0.26 ms per post.
parse_blocks()duplicates a parse the same GraphQL request already performs foreditorBlocks; ACF field types resolve through a request-static map (important, because ACFML filtersacf/load_field); the postmeta path avoidsget_fields()and theacf/format_valuequeries it triggers; and collection stops once there is enough text.
It does not replace %%cf_<field>%%
Yoast's built-in custom-field variable reads flat postmeta directly, so for a post type with one field that always describes it, %%cf_hero_description%% is the better template — deterministic, and no sweep to reason about. %%perimetre_excerpt%% is for what that cannot cover: ACF block content, which lives as JSON in post_content and is unreachable from postmeta, and post types with no single obvious field. Pick per content type; they coexist.
Auditing a project
Before wiring the variable into a template on a project, see what it would produce there. Every project's field groups are its own, and this calls the resolver directly — no configuration needed, nothing written:
wp perimetre:seo-excerpt-audit --post_type=page
wp perimetre:seo-excerpt-audit --post_type=product --limit=50
wp perimetre:seo-excerpt-audit --post_type=page --empty # only posts producing nothing
wp perimetre:seo-excerpt-audit --post_type=page --timings-only # cost, without the text
Filters
| Filter | Default | Purpose |
|---|---|---|
perimetre_core_seo_excerpt_field_types |
['text', 'textarea', 'wysiwyg'] |
Which ACF field types count as prose |
perimetre_core_seo_excerpt_max_length |
156 (80 for ja) |
Character budget, mirroring Yoast's own |
perimetre_core_seo_excerpt_excluded_blocks |
[] |
Block names to skip, e.g. acf/project-bestsellers |
Naming Conventions
| Concept | Convention | Example |
|---|---|---|
| Block namespace | Plugin slug | perimetre/hero, micro-bird/callout |
| GraphQL type | PascalCase from namespace | PerimetreHeroBlock, MicroBirdCalloutBlock |
| ACF field group name | camelCase | hero, featuredPosts |
| Field names | camelCase | heading, subheading, ctaLabel |
| Shared concept names | Consistent across all blocks | heading not title |
| Link/CTA fields | Always cta with label and url |
cta { label url } |
| Image fields | Always image |
image { sourceUrl altText } |
Coding Standards
- PHP style: PSR-12
- File/namespace structure: PSR-4
- No anonymous functions on hooks — use named methods or class methods
Adding a Shared Block
When a block has proven useful across multiple projects, it can be added to Perimetre Core:
- Create the block class in
src/Blocks/Shared/ - Use the
perimetre/namespace - Register it in
src/Blocks/Registry::register_shared_blocks() - Document it in this README
The old block in Project Core can remain under its project namespace — both coexist without conflict.
Current Version
2.1.0
Update this when bumping the version in perimetre-core.php.
Changelog
2.1.0
- Added
%%perimetre_excerpt%%, a Yoast replacement variable that builds a meta description from ACF block and field content. Yoast cannot read ACF:%%excerpt%%falls back topost_content, which on an ACF-block page is only block delimiter comments thatwp_strip_all_tags()removes — so those pages shipped with no meta description at all. The new variable walks the block tree (and ACF postmeta for Classic-editor types), keepstext/textarea/wysiwygvalues in reading order, and trims to Yoast's own 156-character budget. It is a superset of%%excerpt%%— core blocks and Classic HTML fall back to the same text the built-in produces — so it is safe as a template's sole value. Nothing is stored: no save hook, no staleness, no backfill, and no extratransition_post_statusto double-trigger a frontend rebuild. A hand-written description always wins. Opt in per content type at SEO → Settings → Content types → \<type> → Meta description; inert until you do, and a no-op when Yoast is absent. See SEO — Meta Descriptions from ACF Content. - Added
wp perimetre:seo-excerpt-audit. Reports the description each post of a type would get and what generating it costs, calling the resolver directly so nothing has to be configured first. Run it when adopting the variable on a project — the block walker follows ACF's documented storage shape, but every project's field groups are its own.
2.0.7
- Fixed the block editor warning
perimetre-core-editor-css was added to the iframe incorrectly. Please use block.json or enqueue_block_assets to add styles to the iframe.assets/editor.csswas enqueued onenqueue_block_editor_assets, which targets the document around the editor canvas. Since WP 6.3 the canvas is an iframe, so the editor has to copy those styles in and logs this warning. The stylesheet now hooksenqueue_block_assets— the hook whose styles WordPress loads inside the iframe — with anis_admin()guard so nothing loads on the front end. Cosmetic-only change: the same rules apply to the same.perimetre-block-previewmarkup, and no project code needs to change. - Typographic polish on the block preview card. The card never declared a
font-family, so inside the iframe it fell back to the browser default (serif) — headless themes ship no editor styles for the canvas to inherit. It now uses the WordPress admin UI font, applied only to the card's own parts (via--perimetre-preview-font) so real author content in an InnerBlocks slot keeps looking like content. Also: a hairline under the header row, more tracking and a lighter weight on the uppercase field labels, tabular figures on values soN itemscounts don't jitter, a softer border with a 1px shadow, and a hairline frame on image thumbnails.
2.0.6
- Fixed
options.savednever firing for non-default-language Site Options saves. ACF appends the WPML language code to the options post id on secondary languages (options→options_fr), both in core (acf_get_valid_post_id) and via projectacf/validate_post_idfilters, so the strict!== 'options'guard silently dropped every localized save. The handler now accepts the bareoptionsid and anyoptions_<lang>variant.
2.0.5
- Fixed
options.savednever firing for any options page, in any language.on_options_save()gated onfunction_exists('acf_get_current_screen'), but that function does not exist in ACF (verified against ACF Pro 6.8) — the guard was always false, so the handler returned before dispatching. Replaced withcurrent_options_page_slug(), which reads the saved page from the request (ACF options pages POST to?page={menu_slug}) and validates it againstacf_get_options_pages(), also rejecting unrelated admin screens. All other guards and the payload'soptions_pageslug are unchanged.
2.0.4
- Fix the webhook silently never firing for non-default-language content under WPML. On a WPML site, saving a post in a secondary language (e.g. French on an
en/frsite) never dispatched the webhook, while the default language worked fine. Webhook config lives on an ACF options page, and ACF options — including the URL repeater and enable toggle — are served per-language at runtime, so reading them while WordPress is in a non-default language context returned an empty/disabled config even though the settings page displayed the correct values.Webhook\Settings::get_settings()now reads all options under WPML's default language (via the newwpml_default_language-basedforce_default_language()helper, restored in afinally), so a single configuration fires for every language. The payload'slanguagefield is unaffected — it is still derived from the saved post viawpml_post_language_details, so receivers continue to getlanguage: "fr"for French saves. No-op when WPML is inactive.
2.0.3
- Fixed a fatal
TypeErrorthat blocked deleting any taxonomy term (Product Categories, tags, categories, …).Webhook\Dispatcher::cache_menu_before_delete()runs on thepre_delete_termaction, which passes(int $term_id, string $taxonomy)— but the callback declared its second argument asint, so the taxonomy slug string aborted the request underdeclare(strict_types=1)before the term could be deleted. The bug also defeated its own purpose: it was meant to cache anav_menuterm before deletion, but fataled before caching. The callback now uses the correct(int $term_id, string $taxonomy)signature, is registered as an action (not a filter), and caches onlynav_menuterms.
2.0.2
- Webhooks settings are now the single Settings → Perimetre Core entry. 2.0.0–2.0.1 briefly surfaced them under a separate "Webhooks" menu item; they're now consolidated under one "Perimetre Core" entry (Core's only admin surface). No option-key or behavior changes — purely the menu label/placement.
2.0.1
- Maintenance release. Bumped the release workflow's
action-gh-releaseto v3 (Node 24 runtime). No functional or plugin-code changes.
2.0.0
- Split the operational features into a separate plugin. The status / health-check endpoint (
Status\*) and Helm portal Remote Login (RemoteLogin\*), along with the sharedAdmin\Tabsstrip, moved to Perimetre WP Tools. Core now carries only the dev framework — block registration, GraphQL conventions, and webhooks — for custom headless builds. WP Tools is ACF- and WPGraphQL-free and can be deployed on any site, including standard non-headless client sites. The two plugins are independent and can be installed side by side; option keys are unchanged, so existing Status/Remote Login settings carry over automatically. Note: the Remote Login REST namespace changed fromperimetre-core/v1toperimetre-wp-tools/v1— see the WP Tools changelog for the portal-coordination requirement. - Webhooks moved off the unified Status/Remote-Login tab strip (which was removed with those modules). The ACF option keys are unchanged, so existing webhook configuration is preserved. (See 2.0.2 for its final menu placement.)
- Fixed the long-standing mismatch between the plugin header version and the
PERIMETRE_CORE_VERSIONconstant.
1.15.0
- Purge the WPGraphQL cache when an ACF options page is saved. WPGraphQL Smart Cache invalidates by content "node" (post/term/menu), but ACF options pages have no node — so cached GraphQL responses that read option values (global nav CTAs, site-wide settings, …) stayed stale until the cache TTL lapsed, making option edits look "stuck" on the headless frontend even after the
options.savedrevalidation webhook fired.Webhook\Dispatcher::on_options_save()now firesdo_action('wpgraphql_cache_purge_all')before dispatching the webhook, so the frontend's post-webhook re-fetch reads fresh data. No-op when WPGraphQL Smart Cache isn't installed (safe on standard WP sites); opt out or customize via theperimetre_core_purge_graphql_cache_on_optionsfilter. Note: this clears the object-cache layer —?edgeCache=/ Varnish responses remain TTL-only invalidated (see 1.13.0), so keep runtime edge-cache TTLs short where option staleness is user-visible.
1.14.1
- Friendlier headless editor preview. Since v3 made the preview the only on-canvas surface, the default
AcfBlock::render_preview()now renders a content-summary card — block icon + title, then an auto-generated snapshot of the block's filled ACF fields (get_field_objects()): truncated text, image thumbnails, Yes/No toggles, link labels, and "N items" counts. Unsupported and empty fields are omitted; blocks with nothing entered show a muted "add content" hint. Requires no per-block code. Customize via the new optionalAcfBlock::get_preview_summary()hook (return a curatedlabel => valuearray), or overriderender_preview()for full control. Editor-only — frontend/GraphQL output is unaffected.
1.14.0
- Register all ACF blocks as ACF Blocks v3 / WordPress Block API v3 (
api_version => 3,acf_block_version => 3). WordPress 6.9 emits a console deprecation warning for blocks registered with API version ≤ 2 ("Block with API version 2 or lower is deprecated since version 6.9"), and WordPress 7.0 will enforce v3. This silences the warning on every ACF block and makes blocks iframe-editor compatible. - Requires ACF Pro ≥ 6.6 (the floor for ACF Blocks v3).
- Breaking: removed
AcfBlock::get_mode(). ACF Blocks v3 drops the edit/preview "mode" concept — blocks always render their preview template on the canvas while ACF fields move to the Block sidebar / slide-out modal, so themodearg is ignored. Any Project Core subclass overridingget_mode()now has a dead override (no error, but it has no effect).get_inner_blocks_template()still merges'jsx' => trueinto supports for the<InnerBlocks />slot. NativeBlocksubclassblock.jsonfiles should declare"apiVersion": 3.
1.13.1
- Fix Watched Post Types on the Webhooks settings page omitting headless CPTs. The checkbox list (and the "watch everything" default fallback) enumerated post types with
get_post_types(['public' => true]), so CPTs registered the headless way (public => false, show_in_graphql => true) never appeared and never fired webhooks. Enumeration now unions public and GraphQL-exposed post types (minusattachment) via a sharedWebhook\Settings::get_watchable_post_types()helper.
1.13.0
- Add opt-in per-request edge caching for WPGraphQL (
GraphQL\CacheControl). The frontend appends a?edgeCache=<seconds>query var to specific calls (search, build-time) to make that response cacheable; the global default stays uncacheable so nothing else is affected. Agraphql_response_headers_to_sendfilter (registered atPHP_INT_MAX, after WPGraphQL Smart Cache) setsCache-Control: max-age/s-maxagefrom the requested TTL, capped at 3600s. Logged-in requests stay no-store, and invalidation is TTL-only (Cloudways/Varnish cannot purge by tag), so keep TTLs short (30–60s) where staleness is user-visible.
1.12.0
- Webhooks now support multiple URLs: the single Webhook URL field is replaced by a repeater so any number of endpoints can be added. Each watched event is dispatched to every configured URL, all sharing the same secret token. Existing single-URL configs keep firing via a backward-compat fallback to the legacy
options_perimetre_webhook_urlvalue.
1.11.0
- Add Remote Login feature: WP REST endpoint (
/wp-json/perimetre-core/v1/remote-login) that accepts an HMAC-SHA256 signed token from the Helm portal, consumes thejtiserver-side at the portal (single-use enforced there), and logs the matching WP user in viawp_set_auth_cookie. No matching WP user means no login — never auto-creates users. Disabled and inert by default. - Unify the admin surface under a single Settings → Perimetre Core menu entry with a tab strip (
Admin\Tabs): Status, Remote Login, Webhooks. The Webhooks tab links to the existing ACF options page; its standalone menu entry is hidden viaremove_submenu_page()so both pages appear as tabs on one surface. - Remote Login auto-handshake on save: saving the Remote Login tab POSTs to
{portalUrl}/api/sites/connectwithAuthorization: Bearer <apiKey>and surfaces the result as an admin notice. No separate "Connect" button — saves and handshakes always run against the same persisted values.
1.10.0
AcfBlock::get_mode()now picks a smart default:'preview'whenget_inner_blocks_template()is non-null (so the<InnerBlocks />slot stays visible on the canvas),'edit'otherwise. Subclasses with InnerBlocks no longer need to overrideget_mode()manually.- Add
AcfBlock::get_editor_notice()hook — return a string to render an amber reminder banner above the InnerBlocks slot, useful formode='preview'blocks where ACF fields live only in the right-hand sidebar. - Ship an editor stylesheet (
assets/editor.css) enqueued viaPlugin::enqueue_editor_assets()that keeps the InnerBlocks appender always visible, outlines empty columns, and styles the editor notice.
1.9.0
- Drop-in safety on non-headless sites: the
/statusrewrite rule now only registers when the endpoint is enabled, and the hourly status cron has been removed (cron_last_runis no longer in the payload — monitorstatusdirectly). AcfBlock::render()split into protectedrender_preview()(editor canvas) andrender_frontend()(public page) so the editor placeholder no longer leaks into rendered HTML on standard sites. Headless sites are unchanged — subclasses that don't overriderender()keep their existing editor preview.- Add
AcfBlock::get_mode()override (defaults to'edit') andAcfBlock::emit_inner_blocks_token()helper for subclasses overridingrender_frontend().
Upgrade note: sites that previously ran 1.8.0 with the status endpoint enabled will have an orphaned perimetre_status_cron event scheduled. The action handler no longer exists so it's a silent no-op, but to clear it from the schedule, deactivate and reactivate the plugin after upgrading.
1.8.0
- Add opt-in InnerBlocks support to
AcfBlock. Overrideget_inner_blocks_template()to enable nested children;get_allowed_blocks()andget_template_lock()restrict insertion and lock the template shape. When a template is returned,acf_block_versionis bumped to 2 and'jsx' => trueis merged intosupportsautomatically.
1.7.0
- Add optional icon sub-field on CTA helpers (
perimetre_cta_fields,perimetre_cta_group_fields) — accepts SVG or PNG, returns the attachment ID, exposed through WPGraphQL
1.6.0
- Add
AcfBlock::get_acf_name()for use in ACF field-group block location rules
1.5.1
- Default ACF blocks to edit mode instead of preview mode
1.5.0
- Add options and menu webhook events
1.4.1
- Add permalink, language (WPML), and taxonomies to webhook payload
- Hide webhook settings fields when disabled via ACF conditional logic
- Move payload example to bottom of settings page
- Fix trashed post permalinks including
__trashedsuffix in payload
1.4.0
- Add outgoing webhooks on post status changes with ACF options configuration
1.3.1
- Fix rewrite rules not flushing when enabling the status endpoint for the first time
- Add helper text to status slug and secret token settings fields
1.3.0
- Add status / health-check endpoint with admin settings (enable toggle, slug, secret token)
- Add i18n support with English (default) and French translations
1.2.0
- Add automatic plugin updates from GitHub Releases via Plugin Update Checker
1.1.0
- Add CTA field helpers (
perimetre_cta_fields,perimetre_cta_group_fields)
1.0.0
- Initial release
- Abstract base classes for ACF blocks and native blocks
- Block registry for centralized registration
- GraphQL registry with naming convention enforcement
Development
After cloning, install the autoloader:
composer install
This also installs Plugin Update Checker for automatic updates from GitHub Releases.
To regenerate the autoloader after adding new classes:
composer dump-autoload --optimizeReleases
Newest 25 of 32 recorded releases. Each count is every asset in that release; expand a row for the breakdown.
Active-site estimate ≈10 comes from the median of recent superseded releases. Method.