WP Manifestindependent plugin directory
manifest / content / wp-runtime-content

Runtime Content Pack

WordPress plugin that publishes an app's copy as a runtime JSON content pack, so non-technical staff can change wording without a deploy. The app keeps compiled-in defaults, so an outage here cannot break the form.

by Mark Pease · github.com/ark2027/wp-runtime-content

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/ark2027/wp-runtime-content/archive/refs/heads/main.zip

Readme

Runtime Content Pack

A WordPress plugin that lets the people who own the wording change the wording, without involving the people who own the deploy pipeline.

The problem

A single-page app keeps its copy in the bundle. Changing a label means a build and a deploy. That's fine right up until the person who notices the typo isn't the person who can ship it, and then every comma becomes a ticket that sits for a week.

The obvious fix is a CMS, which introduces a new problem: now the form depends on the CMS being up. If the endpoint is down, does the application break?

It shouldn't, and here it doesn't.

How it works

WordPress stores the whole content pack in a single option and serves it from a public REST endpoint:

GET /wp-json/content/v1/content

The app fetches that at runtime and deep-merges it over its own compiled-in defaults. That ordering is the important part:

  • Endpoint unreachable? The app renders its built-in copy. A WordPress outage cannot take the form down.
  • New field added in code? It appears in the editor automatically, no migration, no risk of an older stored pack blanking it.
  • Partial or stale stored content? Still safe, because the defaults fill the gaps.

The editor is generated from the default content rather than hand-built, so adding a field in one place makes it editable everywhere.

Rolling it out

Point a non-production build at the endpoint first:

// environment.uat.ts
contentUrl: 'https://your-wordpress-site/wp-json/content/v1/content',

Edit something, press Publish, watch it appear on UAT about a minute later. Only point production at it once you've seen that work. The compiled-in fallback means the failure mode is "stale copy", not "broken form", but there's no reason to find that out in production.

Things worth pointing at

Every publish is stamped. version is set to a UTC timestamp on save. When someone asks which consent wording was live on a particular day, there's an answer rather than a shrug.

Legal copy is flagged as legal copy. Consent statements and eligibility certifications are marked in the editor with a note asking for review. Somebody changing a button label and somebody changing a consent statement are doing very different things, and the interface should say so out loud.

Saving iterates the defaults, not the submission. The save loop walks the known content paths and pulls each value from the POST. An unexpected field in the request can't introduce a key, and a missing one falls back to its default rather than blanking. Trusting the shape of your own form is how you end up with surprises in an option row.

Colors are validated, not just escaped. The editor renders a swatch by putting the stored value into a style attribute. esc_attr prevents the attribute being escaped but does nothing about CSS being injected into it, so the value has to match a literal hex color or it reverts to the default.

Submitted values have to be scalars. This one I got wrong first time. A request doesn't have to look like the form that generated it, and wprc[welcome__heading][]=x sends an array where a string belongs. That reached wp_kses_post, which is a TypeError on PHP 8 and produces the literal string "Array" on PHP 7. Depending on the version you either lose the admin page or quietly corrupt the content pack. Anything non-scalar now falls back to its default.

I found it by writing the attack rather than re-reading the code, which is also why the save logic now lives in its own function instead of buried in the page render: it wasn't testable where it was.

Security

Standard WordPress practice, applied rather than assumed:

wp_verify_nonce on save CSRF
current_user_can( 'edit_pages' ) checked on both the menu and the render
sanitize_text_field( wp_unslash( … ) ) on the nonce
wp_kses_post body copy keeps safe inline HTML, loses the rest
esc_html / esc_attr / esc_textarea / esc_url on output, per context
hex validation on anything that reaches a style attribute

The REST endpoint is deliberately public and unauthenticated. It serves display copy that any visitor to the application already sees, and no applicant data passes through it.

Install

Needs WordPress 5.8 or newer and PHP 7.4 or newer. Download the repo as a ZIP from the green Code button, or clone it:

git clone https://github.com/Ark2027/wp-runtime-content

Then Plugins → Add New → Upload Plugin, and activate. The option is seeded on activation so the endpoint is valid straight away. Or drop the folder in wp-content/plugins/.

No ACF, no page builder, no paid dependency. The plugin itself is a single file.

Tests

php tests/test-content-pack.php

No WordPress needed. The few functions the plugin calls at load time are stubbed, which is enough to exercise merging, flattening, path writing, color validation, and what the save path does with input that isn't shaped like the form.

CI runs them on PHP 7.4 and 8.2. Both versions matter here, because the array-injection bug behaved differently on each.

There's also a grep step asserting the nonce check, capability check, escaping helpers and type guards are still in the file. Behavioral tests catch behavior changing; they don't catch somebody deleting a wp_verify_nonce.

Deploying it

.github/workflows/deploy.yml pushes to a staging site over SFTP on every push to staging. Host, port, user and password all come from repository secrets. The SFTP account is chrooted to the plugin's own directory, so a full sync can only ever touch this plugin and never the wider install.

Limits

The content pack is one option row. Fine at this size; a few thousand fields would want a different storage shape.

There's no revision history in the plugin itself. The version stamp tells you when something changed, not what. WordPress revisions don't apply to options, so recovering an old pack means a database backup. If I needed that I'd write each published pack to a custom table rather than trying to bolt revisions onto an option.

Adding, removing or reordering fields is still code. This edits the wording of existing content, which is the thing that changes weekly. Structure changes are rare enough to be worth a deploy.

Access-Control-Allow-Origin: * is broad. It's display copy, so this is a considered choice rather than an oversight, but if you'd rather scope it to your app's origin that's a one-line change in wprc_rest_content().

License

GPL-2.0-or-later, matching WordPress.

Read the full README on GitHub →