Skip to content

The display layer: zero-code frontend display, block bindings, a safe shortcode, real REST exposure - #5

Merged
AllTerrainDeveloper merged 4 commits into
mainfrom
feature/display-layer
Aug 17, 2026
Merged

AllTerrainDeveloper merged 4 commits into
mainfrom
feature/display-layer

Conversation

@AllTerrainDeveloper

Copy link
Copy Markdown
Owner

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:

  • Theme override hierarchy first: allterrain-fields/group-{key}.php, then allterrain-fields/group.php, via locate_template() — the template sees $group, $post, and $fields with values pre-loaded, so the common case is a foreach and an echo.
  • Otherwise a built-in renderer draws an accessible definition list: repeaters and flexible content as nested rows, galleries as images, files and links as anchors, choices as their labels, tables as tables. Empty fields take no space; an empty group renders nothing.
  • A small stylesheet ships only when a section actually renders — quiet by design, inheriting the theme's type and color.

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/field binds 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 the wp_kses_post() ceiling. Registration is guarded against duplicate-init warnings.

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:

  • escaped per type, always;
  • password fields refuse to render — the default text included, since a fallback would still confirm the secret exists;
  • reading another post requires that post to be publicly viewable — the front door is exactly as strict as the REST API.

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 atcf object 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

  • New filters, documented as Experimental in 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.
  • Builder: new "On the front end" settings section; schema gains settings.frontend with 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.

  • PHPUnit: 206 tests, 1215 assertions, green (was 195)
  • Vitest: 369 green · tsc clean · PHPCS 0 errors · bundles rebuilt reproducibly

🤖 Generated with Claude Code

https://claude.ai/code/session_01HLZL6dY61BkPPmGyy6F9Us

AllTerrainDeveloper and others added 2 commits August 16, 2026 23:54
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
@AllTerrainDeveloper

Copy link
Copy Markdown
Owner Author

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 ([atcf field="price_incl_vat"] drops it into content), a conditional restock note, an in-the-box repeater, and related products that only link when publicly viewable.

The end-to-end test walks the exact promised path — template applied, meta written, the real the_content filter run — and asserts the spec sheet, its header row, and the computed price (100 × 1.21 → 121) all arrive. 213 PHP tests green (was 206 on this branch), PHPCS clean.

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
@AllTerrainDeveloper

Copy link
Copy Markdown
Owner Author

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:

  • the builder's column editor and the client control speak value/label;
  • the PHP sanitizer read only key — so every table configured through the builder UI sanitized against an empty column list, which blanked every cell on every save. Silent data loss on the main save path, hidden until the Products template (written in the key dialect: visible to PHP, invisible to the client) exposed the split as "This table has no columns yet."

Now every layer reads every dialect: the sanitizer accepts key, value, or a plain string; normalizeChoices (one normalizer, both renderers) accepts key as well as value; the template writes the builder's own spelling. Regression tests on both sides — PHP proves a builder-dialect table keeps its cells through the sanitizer across all three spellings, and a new vitest suite pins normalizeChoices' full dialect tolerance.

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
@AllTerrainDeveloper
AllTerrainDeveloper merged commit 48c93de into main Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant