WP Manifestindependent plugin directory
manifest / content / allterrain-custom-fields

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

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/allterraindeveloper/allterrain-custom-fields/archive/refs/heads/main.zip

Readme

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 sitepublic, publicly_queryable, exclude_from_search
  • They have a main body of textsupports: editor
  • They have a main imagesupports: thumbnail
  • They nest inside each otherhierarchical

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.

Read the full README on GitHub →