The display layer: zero-code frontend display, block bindings, a safe shortcode, real REST exposure - #5
Conversation
Every plugin in this category stores values and then leaves "and how do
visitors see it?" to the theme, a paid views builder, or a shortcode
with a security history. This builds the display layer in:
* Zero-code display — a per-group "Show on the front end" setting
renders the group on the post's own page, before or after the
content, with an optional heading. Theme override hierarchy via
locate_template (allterrain-fields/group-{key}.php, then group.php,
seeing $group, $post and $fields with values loaded); otherwise a
built-in renderer draws an accessible definition list — repeaters as
nested rows, galleries as images, links as anchors, choices as their
labels. Styles ship only when a section actually renders.
* Block bindings (WordPress 6.5+) — the allterrain-fields/field source
binds core paragraphs, headings, buttons and images to field values;
URL-shaped attributes resolve to URLs (attachment file, link target,
public permalink), text at the wp_kses_post ceiling.
* A shortcode that is safe by default — [atcf field="price"] renders
through the same per-type renderer, refuses password fields outright
(default text included), and reads another post only when that post
is publicly viewable.
* Real REST exposure — the group setting "Expose in the REST API" was
normalised and stored but consumed nowhere; it now adds a read-only
atcf object of formatted values to the covered post types' REST
responses.
One rule runs through all four: this is a public surface. Output is
escaped per type even when a meta row was written past the save-time
sanitisers, password fields never appear anywhere, related posts render
only when publicly viewable, and users render as display names — never
as email addresses. Every one of those refusals has a test.
Builder gains an "On the front end" settings section (toggle, placement,
heading). Eleven new PHP tests; docs and readme describe the four paths
and the new filters (atcf_display_groups, atcf_display_markup,
atcf_display_value_html, atcf_rest_field_values).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLZL6dY61BkPPmGyy6F9Us
The fourth starter template, and the display layer's worked example. It is deliberately the only template that ships with "Show on the front end" already switched on, because the fastest way to teach the display layer is the moment it teaches itself: apply the template, fill a post in, view the post — the spec sheet is simply there. What it carries maps one-to-one onto the new surfaces: a spec table (rendered as a real table, now with the header row the field declared), a photo gallery, a manual as a download link, an oembed video that renders as the player, a buy link a core Buttons block can bind to, a VAT-inclusive price that computes itself and drops into content via [atcf field="price_incl_vat"], a restock note behind a conditional, an in-the-box repeater, and related products that only link when a visitor could open them. The end-to-end test walks the exact promised path — template applied, meta written, the real content filter run — and asserts the spec sheet, its header row and the computed price all arrive. 213 PHP tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HLZL6dY61BkPPmGyy6F9Us
|
Added: the "Products" starter template — the display layer's worked example. It's deliberately the only template that ships with Show on the front end already switched on, because the fastest way to teach the display layer is the moment it teaches itself: apply the template, fill in a post, view the post — the spec sheet is simply there, no template edit, no block, no shortcode. Every field maps onto one of the new surfaces: a spec table (rendered as a real table — the renderer also gained the header row the field declares), a gallery, a manual as a download link, an oembed video that renders as the player, a buy link a core Buttons block can bind to, a VAT-inclusive computed price ( The end-to-end test walks the exact promised path — template applied, meta written, the real |
The user-visible symptom was small — the Products template's spec table saying "This table has no columns yet" over a field that declares two. The cause was not: the two halves of the codebase read table columns in different spellings, and each half was blind to the other's. The builder's column editor writes value/label (it is the choices editor wearing another hat) and the client control reads value/label. The PHP sanitiser read only `key` — so every table configured through the builder sanitised against an EMPTY column list, which blanked every cell on every save. Silent data loss on the main save path, hidden because nothing spoke both dialects until the template did: its key/label columns were visible to PHP and invisible to the client, which is the message in the screenshot. Now every layer reads every dialect that exists: the sanitiser takes key, value, or a plain string; normalizeChoices — one normaliser, every dialect, both renderers — takes key as well as value; the template writes the builder's own spelling. Regression tests on both sides prove a builder-dialect table keeps its cells through the sanitiser, and that the client sees the same two columns PHP does. Also rebalanced the template's media row: the two drop zones (Photos, Manual) now sit beside each other because they are the same height — pairing a one-line URL input with a tall drop zone left a column of dead air that read as a layout bug. Video takes the full width below. 373 JS and 214 PHP tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HLZL6dY61BkPPmGyy6F9Us
|
Follow-up from visual review: the spec table's "no columns yet" message uncovered a pre-existing data-loss bug — fixed here. The two halves of the codebase read table columns in different spellings, each blind to the other's:
Now every layer reads every dialect: the sanitizer accepts Also rebalanced the template's media row: the two tall drop zones (Photos, Manual) now pair at 50/50 and Video takes the full width — pairing a one-line URL input with a tall drop zone left a column of dead air that read as a layout bug. 373 JS / 214 PHP tests green. The sanitizer fix stands alone and is worth cherry-picking even if the rest of this PR waits. |
# Conflicts: # allterrain-fields.php # assets/js/builder.min.js # assets/js/bulk.min.js # assets/js/fields.min.js # assets/js/widget.min.js
Why
Every plugin in this category stores values, then leaves "and how do visitors see it?" to the theme, a paid views builder (MB Views), or a shortcode the market leader now disables by default because of how it handled escaping. That's the gap to win, not match: this PR makes display a built-in, free, escaped-by-default feature — four paths, from no code at all to full theme control.
What ships
1. Zero-code display — the differentiator
A per-group "Show on the front end" setting (toggle, before/after placement, optional heading — all in the builder's settings pane). Switched on, the group renders on the post's own page:
allterrain-fields/group-{key}.php, thenallterrain-fields/group.php, vialocate_template()— the template sees$group,$post, and$fieldswith values pre-loaded, so the common case is a foreach and an echo.ACF free has nothing like this; Meta Box sells it as MB Views; only Pods has a cousin of it.
2. Block bindings (WordPress 6.5+)
The source
allterrain-fields/fieldbinds core paragraphs, headings, buttons, and images to field values — value in meta, layout in blocks, edits propagate. URL-shaped attributes resolve to URLs (image field → attachment file, link field → its target, post field → permalink only when public); text resolves at thewp_kses_post()ceiling. Registration is guarded against duplicate-initwarnings.3. A shortcode that is safe by default
[atcf field="price" post="" default=""]renders through the same per-type renderer, so what ACF's shortcode got wrong is structurally impossible here:defaulttext included, since a fallback would still confirm the secret exists;4. REST exposure that actually exists
The group setting "Expose in the REST API" was normalized, stored, documented… and consumed nowhere. It now does what it always promised: covered post types gain a read-only
atcfobject of formatted values in their REST response (password fields excluded). Writes stay on this plugin's own routes, where the per-object capability checks live.The rule that runs through all four
This is a public surface, and every path treats it as one: output escaped per type even when a meta row was written past the save-time sanitizers, passwords never rendered anywhere, related posts linked only when
is_post_publicly_viewable(), users rendered as display names — never email addresses. Every refusal has a test.Surface & docs
docs/hooks-reference.md(with a bindings markup example):atcf_display_groups,atcf_display_markup,atcf_display_value_html(one filter serves both the zero-code display and the shortcode),atcf_rest_field_values.readme.txt: a "Showing values without touching a template" section and a "How do I show a field on the site?" FAQ.settings.frontendwith safe normalization.Tests
Eleven new PHP tests (
tests/phpunit/tests/display.php): renders-after-content with a raw<script>planted straight into the meta row (output escaping must hold even when input sanitizing didn't), placement/heading settings, empty-value skipping, nested repeater rows, the refusals (private posts, user emails, passwords), shortcode escaping + cross-post gate + default behavior, bindings resolution incl. URL attributes and the password refusal, REST exposure on and off, and settings normalization.tscclean · PHPCS 0 errors · bundles rebuilt reproducibly🤖 Generated with Claude Code
https://claude.ai/code/session_01HLZL6dY61BkPPmGyy6F9Us