AllTerrain Fields
Custom fields for WordPress with every premium feature free — repeaters, flexible content, a content model graph, bidirectional relationships and computed fields without eval(). Built as an OpenStation desktop app.
by AllTerrain · github.com/allterraindeveloper/allterrain-custom-fields · website
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/allterraindeveloper/allterrain-custom-fields/archive/refs/heads/main.zipReadme
AllTerrain Fields
Custom fields for WordPress with every premium feature free — built as an OpenStation desktop app, where a relationship is a line drawn between two windows and a photo arrives by being dragged.
What it is
A custom-fields plugin. You drag fields onto a canvas, say where they appear, and
fill them in on the post. Themes read them with get_field().
Three decisions shape everything else.
Nothing is behind a paywall
Repeaters, flexible content, galleries, options pages, blocks, clone fields, bidirectional relationships, conditional logic, JSON sync — the paid tier of every other plugin in this category. None of them is technically hard. A repeater is a list. They are all here, in the free plugin, and the only reason they were ever sold separately is that somebody could.
A field group is a schema, not a form
A custom-fields plugin has always been a form builder pointed at a post, and that framing is why every one of them stops at the edit screen. A field group is really a schema. A relationship field is really an edge in a graph. A field value is really a thing you should be able to drag.
None of those three are expressible in a browser tab. All three are first-class in OpenStation.
Everything is a post, and values are ordinary meta
A field group is a post. Its schema is one meta row. A value is a normal meta row
keyed by the field's name — so get_post_meta( $id, 'hero_title', true )
works with no plugin loaded at all.
That flat convention is kept deliberately and exactly, because it is the most
valuable thing a custom-fields plugin can offer that has nothing to do with its UI: every
export tool, WP-CLI command, meta_query and migration script that already
exists keeps working, and a site can move off this plugin without a data
migration.
OpenStation is optional. Every shell call sits behind a function_exists()
gate. Without the shell this is a complete custom-fields plugin under Fields
in wp-admin. There is deliberately no Requires Plugins: header.
What the desktop buys
| Shell framework | What it becomes here |
|---|---|
| Relations | Every relationship value is announced to the shell, so the post you are editing and the posts it points at are tied on the desktop and listed in the title bar's Related menu. Twenty lines of filter, and every relationship anybody has ever modelled becomes navigable. |
| Drag | Drag a photo from WP Explorer onto an Image field on the real post editor — across an iframe boundary, over the cross-frame bridge. Drag a post onto a Relationship field. Drag a field between two builder windows. Drag a field group onto a post type in the Content Model and its location rule is written. |
| Preview | The eye in the builder's title bar opens the group's fields rendered by the real edit-screen renderer, against a real post, paired beside the builder. Widen a field, watch it reflow. |
| Native windows | The builder, the Content Model graph, the Bulk Editor and Tools are windows, not admin pages — which is what gives them one shared pointer pipeline. |
| Dock constellation | One tile, four rows. Not four tiles. |
| os-controls | Every control resolves through <os-*> when the kit has upgraded, and through the platform when it has not. |
| Widgets | A Field Inspector on the wallpaper that follows whichever window has focus and edits its fields live. |
| Abilities | Five typed abilities, so an AI client can read and write field values with exactly the capability ceiling its user has. |
The Content Model
Every custom-fields plugin lets you build a content model and none of them lets you see one. You get a list of field groups, each with a location rule written in a sentence, and the shape of the thing exists only in the head of whoever built it — until they leave.
This window draws it. Post types, taxonomies and people are nodes. Every relational field is an edge, labelled with the field's own name, with one arrowhead or two depending on whether it mirrors. It is read straight out of what is already stored; there is nothing to maintain.
And it is editable, which is the part that needs a desktop:
- Drag a field group onto a node and it is assigned there.
- Drag from one node to another and a relationship field is created joining them — bidirectionally if you say so, in which case the mirror on the far side is created at the same time.
- Click an edge and the field it represents opens in the builder, in its own window, beside this one.
Relationships that hold in both directions
A relationship field points one way. Somebody sets "Related products" and the
other product knows nothing about it — so every site with a relationship field
also has a second one on the other type, maintained by hand, and the two drift
apart within a month. Everybody knows this. It gets fixed by writing a
save_post hook, in every project, again.
Here, a field with bidirectional on names a mirror field on the far side, and
the plugin keeps the two consistent: adding A→B adds B→A, removing it removes it,
and deleting either post cleans up whatever pointed at it. Writing the far side
does not come back round and rewrite this one, because the guard is a set of
edges already written this request rather than a depth counter — which would
also stop legitimate second-order updates.
// Setting one side is all it takes.
atcf_update_field( 'case_studies', array( 42, 43 ), $product_id );
// Post 42 now points back, with no save_post hook anywhere.
get_post_meta( 42, 'products', true ); // [ $product_id ]
Three worked examples, on the first screen
"Custom fields" is an abstraction with nothing in it until you have seen one. A blank canvas beside a palette of forty types tells a newcomer nothing about which of them to reach for — or that a repeater is how you do ingredients, or that a total can work itself out.
So the builder opens on three real field groups rather than on nothing:
| What it teaches | |
|---|---|
| Recipes | Repeaters — ingredients and method as rows. A total time that adds itself up. An allergen field that appears only when the switch beside it is on. |
| Property listings | A computed price per square metre over two other fields. A map, a gallery, and the agent who is selling it. |
| Events | Dates and times, a capacity that works out the places left, and a ticket price that disappears when the event is free. |
Between them they use twelve field types, three formulas, two conditionals and a repeater each. Opening one and reading it is the fastest route to knowing what this plugin does, because it is the real builder with a real group in it — everything in it can be renamed, rearranged or thrown away, and nothing about the result remembers it came from a template.
They stay reachable from Templates in the group rail, and a plugin can add
its own with
atcf_field_group_templates.
Make the post type before you describe it
Every other custom-fields plugin starts one step too late. It assumes a Recipes
post type already exists — that somebody wrote a register_post_type() call, or
installed a second plugin to write one. If you have not done that, there is
nothing to attach fields to, and nothing in the interface says so.
So there is a New post type button — in the Content Model's toolbar, and as New custom post type… in the Fields tile's dock menu, because creating one is the step before everything else in that menu. Two words — "Recipe", "Recipes" — and four questions phrased about the thing rather than about WordPress:
- Visitors can see these on the site →
public,publicly_queryable,exclude_from_search - They have a main body of text →
supports: editor - They have a main image →
supports: thumbnail - They nest inside each other →
hierarchical
The slug, the seventeen labels, the archive and the rewrite rules are worked out from the two words. It is registered inside the same request, so it is on the graph before you have finished reading the notice, and in the admin menu on the next page load.
Removing one never removes what is stored in it. The entries stay in
wp_posts; remake the type with the same name and they all come back.
The field palette
Basic — text, paragraph, number, slider, email, URL, password Content — rich text, embed, image, file, gallery, code Choice — dropdown, radios, checkboxes, button group, switch Relational — post, relationship, page link, taxonomy, user, link Layout — message, tab, accordion, group, repeater, flexible content, clone Advanced — date, date & time, time, colour, icon, location, table, JSON, computed
Every one is a single atcf_register_field_type() call using exactly the API a
third-party plugin would use. There is no privileged path: if a built-in needs
something the registry cannot express, the registry gets a feature rather than
the built-in reaching around it. The test suite proves it — a field type
registered from outside has to reach the palette, normalise its settings,
sanitise on write and format on read with no special handling anywhere.
A field type declares what it will accept off the desktop:
atcf_register_field_type( 'image', array(
'label' => __( 'Image' ),
'accepts' => array( 'media' ), // ← the whole drag bridge reads this line
// …
) );
accepts reaches the DOM as data-atcf-accepts, and the drag bridge reads that
attribute and nothing else. There is no list of field types anywhere in the drop
code. Register a type, declare what it takes, and dragging works.
Computed fields without eval()
A computed field holds an expression over its siblings — {price} * {quantity} * (1 + {vat}). Every plugin that has grown one implemented it with eval(), and
every one of those is a stored program that runs as PHP on every save of
every post, settable by an importer, a REST call or a compromised admin session.
This one is a tokeniser and a shunting-yard parser over a closed set: numbers, the sibling fields it was given, twelve operators and eighteen functions. It cannot call anything else, cannot name a variable that is not a field, cannot assign and cannot loop. There is no path from an expression to a PHP callable, because no part of the file ever builds one.
It is recalculated on every write path — a form save, atcf_update_field(),
a REST write, the bulk editor, an import, an AI agent's ability call — because a
total that is only correct after somebody opens the editor and presses Save is a
total nobody can rely on.
The vocabulary
| Arithmetic | + − * / % ^ and brackets |
| Comparison | > < >= <= == !=, joined with && and \|\| |
| Pick one | min max median clamp if |
| Combine | sum avg product |
| Round | round floor ceil int |
| Shape | abs sign sqrt pow mod pct |
| Count | count |
Three of those exist because the hand-written version is a trap.
pct(part, whole) is part / whole * 100 guarded against a zero denominator —
the unguarded form is how a price list fills with INF the first time something
has no list price. clamp(n, low, high) swaps its bounds if you give them the
wrong way round, rather than silently returning the floor forever. And int(n)
truncates toward zero, so int(−4.9) is −4 where floor(−4.9) is −5 —
which is what "drop the decimals" means to everybody not thinking about negative
numbers.
Argument counts are checked at parse time. if({a} > 1, {b}) with no else branch
used to evaluate to something — a number, stored, indistinguishable from a
right answer. It is now refused like any other malformed expression.
What a formula can read
The question everybody asks first, and the one a list of functions cannot answer:
| A number field in this group | {price} — the field's name, not its label |
| A switch | {in_stock} — on is 1, off is 0, so {price} * {in_stock} is the price or nothing |
| Another computed field | {subtotal} — worked out first, then used |
| A whole repeater column | {lines.amount} — the Amount field from every row |
| A field inside a group | {address.postcode} — same dotted form; a group is one row |
| Anything that is not a number | Counts as 0. Nothing breaks; the sum is just smaller |
That fourth row is the one that was missing, and it is the shape people reach for first — a repeater is a list of line items, and the thing you want from a list of line items is its total:
sum({lines.amount}) the column, added up
round(sum({lines.amount}) / count({lines.amount}), 2) the average line
avg({reviews.stars}) the average rating
A column used on its own is its total, so {lines.amount} and
sum({lines.amount}) give the same answer — whichever way somebody guesses, they
are right. count() gives the number of rows, which is how you get the per-row
figures avg() does not.