Pugpig Block API Timeline Curator
Written by Frank Lockett
Updated at August 21st, 2026
A WordPress plugin that runs inside a Pugpig Bolt install. It adds a new edition-ordering strategy: instead of Bolt working out which posts belong in a timeline, the timeline's content and running order are taken from an external Block API JSON feed and converted into Pugpig "pages" (groups, articles, webview cards, videos, carousels, iframes) that flow through the normal edition/feed pipeline.
Plugin name (from pugpig-block-api-timelinecurator/src/index.php): Pugpig - Block API Timeline Curator.
In one sentence: an editor pastes a Block API JSON URL onto a Pugpig edition and picks the "Ordered by Block API JSON feed" order type; from then on, every feed render (and a background job every 5 minutes) fetches that URL, matches each entry in it to a WordPress post by canonical URL, and rebuilds the edition's timeline to mirror the feed exactly.
Where the format comes from. That feed borrows its shape from WordPress VIP's Block Data API — introduced in this post, source at Automattic/vip-block-data-api. Worth reading for background, with one caveat about scope: VIP's API describes the contents of a single article. The Block API JSON feed this plugin curates from describes a whole front page — a list of rows, each holding several articles or videos. The two aren't interchangeable, so section 4 is the shape this plugin actually expects.
Start here — how it works, in plain English
No WordPress or Pugpig knowledge needed for this section. Everything after it is the detailed reference.
The problem it solves
An app's front page — the running order of stories a reader sees when they open it — is usually assembled inside the app's own CMS. That works until the publisher already curates that front page somewhere else: on their website, in their own homepage tool, by a team of editors who are not going to do the job twice.
This plugin removes the second job. The website's curation becomes the app's curation. Whatever order the publisher's homepage tool produces, the app mirrors it — automatically, within minutes, with nobody re-picking stories by hand.
The one-minute version
Think of it as three parties passing a list between them.
The publisher's system produces a running order. It is published as a web address that returns a machine-readable list: this group of stories first, called "Top stories", laid out three-up; then this video carousel; then this group, called "Opinion". Each story in the list is identified by its address on the website. That list is the feed.
WordPress already holds the stories themselves. Something separate — an importer — has previously copied the publisher's articles into WordPress, and each one remembers the website address it came from. This plugin does not import anything; it assumes the articles are already there.
The plugin joins the two together. It reads the feed, and for each entry looks up the article in WordPress by matching web addresses. Then it rebuilds the app's front page in exactly the feed's order — turning each group in the feed into a group in the app, each story into a card, each video into a video, each embed into an embedded panel.
That is the whole idea. The feed decides what and in what order; WordPress supplies the actual content; the plugin is the join.
The one thing to understand about matching
Everything hinges on step 3, and it is a strict, literal comparison of two web addresses: the one the feed gives for a story, and the one WordPress recorded when the story was imported.
If they match, the reader gets a proper in-app article — fast, offline-capable, styled like the rest of the app.
If they do not match, the story still appears, but as a card that opens the website instead. Nothing breaks and nothing disappears; the reader just gets a web page rather than a native article.
So "why is this story opening the website instead of the app?" nearly always has one answer: the two addresses aren't identical. A missing slash, http where the other says https, a www. on one side only — any of these is enough. There is a debug panel on the edition screen that tells you exactly which stories failed to match, and a filter a developer can add to smooth over a systematic difference.
There is also a legitimate version of the same situation: the feed can list a story before it has been imported. In that window the story shows as a website card, and it turns into a proper article by itself once the import lands. The plugin is specifically built to notice that moment and refresh the app.
What someone actually has to do
Almost nothing, once it is set up. On the edition (the app's front page) you pick the order type "Ordered by Block API JSON feed", paste the feed's web address into the box that appears, and save. From then on it maintains itself: the plugin re-reads the feed every five minutes and rebuilds the front page whenever it has changed.
Two consequences worth knowing up front:
- Choosing that order type hands over control. Whatever was in the edition before is replaced by the feed's contents on the next refresh. The single exception is pinned items, which are kept and always placed first — that is the escape hatch for "we must have this at the top regardless".
- If the publisher's feed goes down, the app does not go blank. The plugin keeps a copy of the last good version and serves that for up to ten minutes, which comfortably covers a brief outage. A longer one is logged as an error.
The words this document uses
| Word | What it means here |
|---|---|
| Edition (also collection, timeline) | One front page in the app — the thing being curated. Stored in WordPress as a pugpig_edition. |
| Feed | The web address the publisher publishes their running order at, and the JSON list it returns. |
| Block (also slab) | One row or section of the front page: a group of stories, a video carousel, an embedded panel. The feed is a list of these. |
| Page | The app's word for any single item in a front page — an article card, a video, a group, an embed. The plugin's job is turning blocks into pages. |
| Hydration | The look-up step: taking each entry in the feed and finding the WordPress article it refers to. A "hydrated" feed is one that has had those results filled in. |
| Webview card | The fallback card for a story that could not be matched. Looks like an article in the list, opens the website when tapped. |
| Transient | WordPress's name for a short-lived cached copy. This plugin keeps one per edition, for ten minutes, and it doubles as the safety net if the feed goes down. |
| Touching an edition | Marking a front page as changed, so the system that delivers content to readers' devices knows to send the new version. Nothing reaches readers without it. |
| Bolt | Pugpig's WordPress-based CMS that this plugin is a component of. |
| Distribution | The Pugpig service that pushes updated content out to apps. |
Where to go next
- Setting one up: §3, then §10 to check it worked.
- Building the feed: §4 is the contract, §5 is the matching rule.
- Something looks wrong: §17, which is organised by symptom.
- Customising behaviour in code: §13.
Contents
"Start here" is a plain-language overview for anyone new to this — read it first if the paragraphs above meant nothing to you. Sections 1–12 and 17 are the parts an editor, producer or support engineer needs. Sections 13–16 and 18 are developer/integrator reference.
1. Who this document is for
- Editors / producers — sections 3, 7–10 and 17 cover configuring an edition, using test mode, reading the debug panel and diagnosing "why isn't my article showing".
- Publisher-side developers building the feed — section 4 is the contract your JSON has to meet, and section 5 explains the matching rule.
- Integrators / theme developers — sections 6, 11 and 13 are the mapping rules and the extension points you customise behaviour with.
- Support / ops — sections 11, 12, 17 and 18.
2. Requirements
| Requirement | Notes |
|---|---|
| WordPress ≥ 5 | Below that, the plugin shows a dismissible admin notice and does nothing useful. |
pugpig-widgets plugin, active |
Hard dependency. All page construction goes through \Pugpig\Widget\* classes. If it isn't active you get an admin notice. |
| Pugpig Bolt / Pugpig WordPress connector | Supplies pugpig_get_page(), pugpig_update_page_hash(), pugpig_touch_edition(), pugpig_error_log(), the pugpig_edition post type and the pugpig_post_process_pages filter. |
| Meta Box (RWMB) | Used for the edition edit-screen fields (rwmb_meta_boxes). |
An importer that sets syndication_permalink
|
The plugin does not import content. It only orders content that already exists in WordPress, matched on the syndication_permalink post meta. See section 5. |
There are no Composer runtime dependencies — the only Composer dependency is szepeviktor/phpstan-wordpress, and it is dev-only.
3. Setting up a curated edition
- Install and activate the plugin (and
pugpig-widgets). - Edit the
pugpig_edition(collection/timeline) you want to curate. - Set the edition's order type to "Ordered by Block API JSON feed". This is added to Bolt's existing order-type dropdown via the
pugpig_custom_orders_to_order_byfilter. - A meta box appears: "Block Data API JSON feed URL to order collection". Paste the feed URL into JSON Feed URL.
This feed determines both which posts appear and the order they appear in.
- Save. Reload the edit screen and check the Feed Debug Output panel (section 10) — it tells you how many entries the feed asked for and how many were found in WordPress.
Notes on behaviour once this is switched on:
-
Normal edition ordering is bypassed entirely. When the feed render runs, every page Bolt had already assembled is discarded except pages whose post has the
pugpig_widgets_pinnedmeta set. Pinned pages survive and are prepended; everything else comes from the feed. -
Edition validation is skipped. On
save_post, if the edition uses this order type, the plugin sets the global$SKIP_EDITION_VALIDATION = trueso Bolt does not complain that the edition has no eligible posts of its own. - The order type is the on/off switch. If the order type is anything else, the plugin leaves the edition completely alone — even if a feed URL is still saved on it. (One exception: the 5-minute background refresh keys off the feed URL, not the order type, so it will keep fetching. See section 11.)
4. What the plugin expects from the feed
The feed must be JSON served over HTTP with a 200 response, decodable, and must contain a top-level blocks array. Anything else is rejected and logged (see section 17).
If you know VIP's Block Data API: the top-level
blocksarray and thename/attributesconvention are the same. The differences: VIP's endpoint returns one post's blocks and nests child content ininnerBlocks; this feed instead carriesposts[]andvideos[]on each block, andsource,primary_categoryandpost_mediahave no upstream equivalent. Stock output will not drive a timeline on its own.
{
"blocks": [
{
"name": "publisher/slab-standard",
"attributes": {
"title": "Top stories",
"description": "Optional group summary",
"template": "three-up",
"url": "https://example.com/embed/widget",
"src": "https://example.com/embed/widget",
"height": 600
},
"posts": [
{
"properties": {
"classes": ["lead", "big-image"]
},
"source": {
"id": 998877,
"link": "https://www.example.com/news/some-article/",
"title": "Headline as the publisher wants it",
"excerpt": "Standfirst / summary text",
"authors": [{ "name": "A Writer" }],
"primary_category": { "name": "News" },
"post_media": [
{
"sizes": [
{ "name": "large-16:9", "url": "https://cdn.example.com/img-16x9.jpg" }
]
}
]
}
}
],
"videos": [
{
"guid": "abc123",
"title": "Video title",
"src": "https://www.youtube.com/watch?v=abc123",
"provider": "youtube",
"image": "https://cdn.example.com/poster.jpg"
}
]
}
]
}4.1 Field-by-field
Everything is optional unless stated. Values of the wrong type are treated as absent rather than causing an error.
Block level
| Key | Type | Used for |
|---|---|---|
name |
string |
Required in practice. Sanitised into a CSS class on the group page, so the theme can style each slab type. Also the value passed to the pugpig_blockapi_block_exclude filter, and the string checked for video-carousel (see 6.3). |
attributes.title |
string | Group page title. Also the video-carousel title (defaults to Video Carousel). |
attributes.description |
string | Group page summary. |
attributes.template |
string | Sanitised into a second CSS class on the group page. |
attributes.url or attributes.src
|
string | Turns the block into an iframe embed page. url is checked first, then src. |
attributes.height |
numeric | Iframe height in px. Falls back to the widget default (800) if absent or non-numeric. |
posts |
array | Article/webview entries, in running order. |
videos |
array | Video entries. |
posts[] entries
| Key | Type | Used for |
|---|---|---|
source.link |
string |
The match key. Compared against the syndication_permalink post meta to find the WordPress post. Without it the entry is skipped during hydration. |
source.id |
int | The publisher's own GUID. Displayed in the debug panel only — not used for matching. |
source.title |
string | Overrides the WordPress post title on the card. |
source.excerpt |
string | Overrides the card summary. |
source.authors[0].name |
string | Overrides the card author. Only the first author is read. |
source.primary_category.name |
string | Sets the card's section, and is appended to categories on webview cards. |
source.post_media[0].sizes[] |
array | The image. Only the first post_media entry is read, and only the size whose name is exactly large-16:9 is used. Any other crop needs the pugpig_blockapi_block_post_image_url filter. |
properties.classes[] |
string[] | Sanitised and appended as CSS classes on the card. |
source.pugpig-post-id |
int | Injected by the plugin, not supplied by the feed. See section 5. |
videos[] entries
| Key | Type | Used for |
|---|---|---|
src |
string (URL) | Video URL. The videoid is derived from the last path segment with any query string stripped. |
provider |
string | Widget type. Allowed: youtube, vimeo, brightcove, jwplayer, video, videojs, dailymotion. Anything else is coerced to video. |
title |
string | Defaults to Video if empty. |
image |
string (URL) | Poster image. |
guid |
string | Read but currently overwritten by the generated page id before use. |
4.2 Things the feed cannot do
- It cannot create content. Every article card that should open natively in the app needs a matching WordPress post already in place. Entries with no match still render, but as external "webview" cards (section 6.2).
- It cannot reorder pinned content. Pinned pages always come first.
-
blocksmust be an array at the top level. Nested or differently-named roots are rejected outright.
5. How posts are matched to WordPress content
For every posts[] entry the plugin takes source.link and runs:
new WP_Query([
'post_type' => 'post',
'posts_per_page' => 1,
'meta_query' => [[
'key' => 'syndication_permalink',
'value' => $link,
'compare' => 'IN',
]],
'fields' => 'ids',
]);The resulting post ID is written back into the JSON as source.pugpig-post-id, and that hydrated JSON is what the page builder reads. This is what "hydration" means throughout this document and in the code.
Consequences worth knowing:
- The match is an exact string comparison. A trailing slash,
httpvshttps, awww.prefix or a stray query string will stop a post being found. The feed'slinkand the importer'ssyndication_permalinkmust agree byte-for-byte. Use thepugpig_blockapi_block_post_linkfilter to normalise if the two systems disagree. - Only the
postpost type is searched. Content stored asvideo_widget,audio_widgetor any custom type will not be found. - The first match wins. Duplicate
syndication_permalinkvalues across posts give you a non-deterministic result. - An entry can legitimately be advertised before the post exists — a feed that builds links from a date range, or mirrors an upstream on its own schedule, will do this. Until the import lands, the link resolves to nothing and the article renders as a webview card. This is expected, and the plugin is designed to notice the moment it resolves (section 11).
6. How blocks become app pages
This runs on the pugpig_post_process_pages filter (Atom::construct_timeline_atom_from_block_json), and only when the edition's order type is pugpig_blockdata_timelinecurator_ordered_by_json_feed.
Order of operations per block:
-
pugpig_blockapi_block_excludeis applied with the blockname. If it returns truthy, the block is skipped entirely. -
posts[]→ one page per entry (6.1 / 6.2), collecting a list of group-member ids. -
videos[]→ one page per video, optionally wrapped in a carousel (6.3). If any video pages were produced, processing of this block stops here. -
attributes.url/attributes.src→ one iframe page (6.4). If an embed URL was present, processing of this block stops here. - If group-member ids were collected in step 2, a Group page wraps them (6.5).
Steps 3 and 4 short-circuit. See gotcha #5 — a block that mixes posts with videos or with an embed URL will emit the article pages but never group them.
6.1 Article pages (post matched)
When source.pugpig-post-id resolved:
-
pugpig_get_page($pp_post_id, $edition_id)builds the page from the real WordPress post. - Base overrides applied:
hidden = true(the page is surfaced through its group, not directly in the timeline),classes(appended),section. - Feed values override the WordPress values where present:
title,summary(fromexcerpt),author,section, andimages(from thelarge-16:9crop, via\Pugpig\Widget\Base::getImageDetailsByUrl()). - Filter:
pugpig_blockapi_block_article_page. - Group member id:
page-<post_id>.
6.2 Webview pages (no match)
When no post was found, the entry still appears, as an external card:
- Class
open-externallyis added. - A
\Pugpig\Widget\Articlepage is built fromtitle,summary,sectionandshareurl(the feedlink). -
externalurlis set to the feedlink, plusauthor,categoriesand the image. - Filter:
pugpig_blockapi_block_webview_page. - Group member id:
page-<generated page id>.
6.3 Video pages and video carousels
- Each
videos[]entry becomes a\Pugpig\Widget\Videopage. -
YouTube Shorts (
/shorts/in thesrc) getvideoorientation: portraitinjected into the page'sbolt/widgetcustom category, so the app renders them portrait. - If the block
namecontains the stringvideo-carousel, each video page is markedhiddenand its id is collected; the collected ids are then wrapped in a\Pugpig\Widget\Carouselwithstyle: videoand classwidget-carousel__video. - Otherwise the video pages are added to the timeline individually.
6.4 Iframe pages
attributes.url (or attributes.src) produces a \Pugpig\Widget\Iframe page with src and, if numeric, height.
6.5 Group pages
A \Pugpig\Widget\Group page with:
-
title=attributes.title,summary=attributes.description(both HTML-entity-decoded) -
ids= the collected member ids, in feed order -
classes= sanitisednameand sanitisedtemplate
Duplicate members within one render get a -version-N suffix on their group id to keep the ids unique — see gotcha #11.
6.6 Final pass
pugpig_blockapi_timelinecurator_blocks receives the complete pages array plus the raw blocks, decoded JSON and feed URL, so an integration can reorder, inject or strip pages as a last step.
7. Global settings page
Settings → Pugpig Block API (options-general.php?page=pugpig-block-api-settings), requires manage_options.
| Setting | Option name | Default | Effect |
|---|---|---|---|
| Enable Test Mode | pugpig_blockdata_timelinecurator_test_mode |
off | Global switch. See section 8. |
The plugin also adds application/json to WordPress's allowed upload MIME types (upload_mimes) so test files can be uploaded through the media library.
8. Test mode
Test mode lets you drive a timeline from a JSON file uploaded to the edition instead of a live URL — useful for reproducing a publisher's payload, testing a new block type before the feed ships it, or working offline.
- Enable Settings → Pugpig Block API → Enable Test Mode.
- Reload the edition edit screen. A new field, Test JSON File, appears in the feed meta box.
- Upload a
.jsonfile whose contents match section 4 (top-levelblocks). - Save. That edition now reads from the file.
Important details:
- The toggle is global, not per-edition. Turning it on affects every edition; what varies per edition is whether a file has been uploaded.
- It is a fallback chain, not an override switch. If test mode is on but a given edition has no file uploaded (or the attachment is missing on disk), that edition silently falls back to fetching its configured URL.
- The Test JSON File field only exists while test mode is on. Turning test mode off hides the field but does not delete the uploaded attachment or the meta value.
9. Tools page
Tools → Pugpig Block API Tools (tools.php?page=pugpig-block-api-tools), requires manage_options. Both actions are nonce-protected.
9.1 Hydrated JSON transients
Lists every pugpig_edition that has a feed URL saved, whether its hydrated-JSON transient is currently set, and a Clear button per edition, plus Clear all edition transients.
Clearing forces the next request to re-fetch and re-hydrate from scratch. Use it when a post was deleted and re-created, or an import changed a syndication_permalink, and the feed is still serving the old resolved IDs.
9.2 Apply Categories from JSON Endpoint
A one-shot bulk utility, independent of the timeline rendering path. It fetches a JSON endpoint, and for each block pulls a category value and a link value out of it, finds the matching post, and applies the category as a taxonomy term.
| Field | Default | Meaning |
|---|---|---|
| JSON Endpoint URL | — | Must return {"blocks": [...]}. 30s timeout. |
| Category field path | attributes.category |
Dot-notation path into each block object for the term name. |
| Link field path | attributes.iframe |
Dot-notation path to the URL used to find the post. |
| Post meta key to match | syndication_permalink |
Meta key compared against the link value. |
| Target taxonomy | sections |
Taxonomy the term is added to. |
Behaviour: searches any post type and any status, takes the first match, creates the term if it does not already exist (slug from sanitize_title), then appends it to the post (existing terms are not removed). Reports the number of posts updated. Blocks missing either value are skipped.
10. The Feed Debug Output panel
Shown read-only in the edition's feed meta box. It renders the hydrated JSON, so it reflects exactly what the page builder will see:
-
Usable blocks in JSON — count of entries in
blocks. -
Posts in JSON — total
posts[]entries across all blocks. - Posts found in Express — how many of those resolved to a WordPress post.
Then, per block, a numbered list of its posts showing Title, Post ID (a link to the WP editor, or Not found), GUID (the feed's source.id) and Canonical URL. Blocks with no posts but an attributes.url are listed as Iframe embed.
The panel is cached for 10 minutes (Constants::TRANSIENT_EXPIRY). To see it refresh immediately, clear the edition's transients from the Tools page. If it says "No debug transient set" the edition has neither a feed URL nor test mode enabled.
11. Refresh, caching and change detection
11.1 When the feed is fetched
- On every feed render for an edition using this order type.
-
Every 5 minutes by a WP-Cron event (
pugpig_block_api_timelinecurator_update_event_hook, intervalevery_5_minutes). The job selects every non-trashed post that has apugpig_blockdata_timelinecurator_json_feed_urlmeta value and re-fetches it. The event is registered on plugin load and cleared on deactivate/uninstall. - When the debug panel is rebuilt on the edit screen.
11.2 The caching chain
- Test file (if test mode is on and a file is attached) — read from disk.
- Otherwise
wp_remote_getwith a 20s timeout; args filterable viapugpig_blockdata_timelinecurator_request_args. - The raw body is validated (decodes, has
blocks). Failures return nothing and are logged. - The body is hashed (SHA-256) and compared with the edition's seen hash. If unchanged and a hydrated transient exists, the cached transient is returned and hydration is skipped.
- Otherwise every entry is hydrated (section 5) and the result cached in the hydrated-JSON transient for 600 seconds.
- If the fetch failed at step 2–3, the last good hydrated transient is served instead. If that has also expired, the render logs "Origin Block Data JSON (…) down for more than 10 mins" and gives up.
So a total origin outage is invisible for up to 10 minutes, and — because the cron re-fetch runs every 5 minutes — a healthy feed keeps the transient permanently warm.
11.3 Why there are two hashes
BLOCKS_HASH records what the edition last saw; BLOCKS_HASH_PUBLISHED records what it last successfully hydrated and cached.
The seen-hash is written before hydration, deliberately: a second request arriving mid-hydration finds the hash already advanced, short-circuits onto the cached transient, and does not pile on a duplicate hydration of the same body. But that means a run which dies during hydration has advanced the seen-hash without publishing anything. Keeping the published hash separate is what stops such a failure stranding the edition — the touch decision below uses the published hash, so a later run still recognises the body as new.
11.4 When the edition is "touched"
Two independent things decide how an edition renders, and either changing means Pugpig Distribution has to re-download it:
- Presentation — slab titles, group descriptions, templates, classes. These live in the feed body, so the raw JSON hash detects them.
- Which articles are in it — resolved by hydration. A feed can advertise an entry long before the post backing it exists; until then the link resolves to nothing and the article silently drops out, while the feed body is byte-identical either side of the post appearing. The hash cannot tell "advertised" from "actually resolvable", so the ordered list of resolved post IDs is compared as well.
If either changed, pugpig_touch_edition() is called. That bumps the modified date the OPDS feed exposes as the edition's <updated> — the only signal that makes Distribution re-download — and fires pugpig_edition_touched, which the Distribution pinger listens for. The ID comparison is order-sensitive on purpose: those IDs are the edition's page order, so a reshuffle is a real change.
11.5 Automatic transient invalidation
When any post (other than a pugpig_edition) is trashed, untrashed or permanently deleted, the plugin finds every edition whose resolved-post-IDs meta contains that post ID and clears its transients, so the next request re-hydrates rather than serving a stale ID.
12. Storage reference
Options
| Option | Purpose |
|---|---|
pugpig_blockdata_timelinecurator_test_mode |
Global test-mode toggle. |
Post meta on pugpig_edition
| Meta key | Written by | Purpose |
|---|---|---|
edition_order_by_type |
Bolt core | Must equal pugpig_blockdata_timelinecurator_ordered_by_json_feed for the plugin to fire. |
pugpig_blockdata_timelinecurator_json_feed_url |
Editor (meta box) | The feed URL. Also the key the 5-minute cron selects on. |
pugpig_blockdata_timelinecurator_test_file |
Editor (meta box, test mode only) | Attachment ID of the uploaded test JSON. |
pugpig_blockdata_timelinecurator_blocks_hash |
Plugin | SHA-256 of the raw body last seen. Written pre-hydration. |
pugpig_blockdata_timelinecurator_blocks_hash_published |
Plugin | SHA-256 of the raw body last published. Drives the touch decision. |
pugpig_blockdata_block_post_id |
Plugin | Ordered array of resolved WordPress post IDs. Drives the touch decision and the deletion-invalidation lookup. |
Post meta on post (read only)
| Meta key | Purpose |
|---|---|
syndication_permalink |
The canonical URL used to match feed entries to posts. Set by the importer, not by this plugin. |
pugpig_widgets_pinned |
Set via pugpig-widgets. Pinned pages survive the rebuild. |
Transients
| Transient | Lifetime | Purpose |
|---|---|---|
pugpig_blockdata_timelinecurator_edition_json_<edition_id> |
600s | Hydrated JSON. Also the failover copy when the origin is down. |
pugpig_blockdata_timelinecurator_edition_json_feed_post_debug_display_<edition_id> |
600s | Cached debug-panel HTML. |
PHP constants
| Constant | Effect |
|---|---|
PUGPIG_ARC_CURATOR_FEED_DOWN_ERROR_AS_INFO |
If defined and truthy, feed-down conditions are logged at INFO instead of ERROR. Useful for feeds that are legitimately intermittent. (Note the ARC-era name — it is honoured by this plugin too.) |
13. Developer reference: filters
13.1 Fetch and hydration (Curator.php)
| Filter | Signature | Use |
|---|---|---|
pugpig_blockdata_timelinecurator_request_args |
($args, $json_feed_url, $edition_id) |
Add auth headers, change the 20s timeout, set a user agent. |
pugpig_blockapi_block_posts |
($block_posts, $block) |
Rewrite or synthesise a block's posts array before hydration. Returning entries for a block that has none is how blocks with no native posts (e.g. puzzles) are given cards. |
pugpig_blockapi_block_post_link |
($link, $post, $block) |
Normalise the canonical URL before the syndication_permalink lookup. The place to fix protocol/trailing-slash/host mismatches. |
13.2 Page construction (Atom.php)
| Filter | Signature | Use |
|---|---|---|
pugpig_blockapi_block_exclude |
($exclude = false, $block_name) |
Return true to drop a block type from the app entirely. |
pugpig_blockapi_block_base_args |
($base_args, $block, $block_post, $edition_id, $post_index) |
Adjust hidden, classes, section per card. $post_index is the zero-based position within the block, so you can treat the lead item differently. |
pugpig_blockapi_block_post_image_url |
($post_image_url, $block_post, $block, $edition_id) |
Override the image. Defaults to the large-16:9 crop, or null if the feed carries no such crop — this is the filter to use for any other crop name. |
pugpig_blockapi_block_article_page |
($article, $block_post, $block, $edition_id) |
Final mutation of a matched article page (categories, flags, metadata). |
pugpig_blockapi_block_webview_post_args |
($webview_post_args, $block, $block_post, $edition_id) |
Args passed to \Pugpig\Widget\Article::getPage() for unmatched entries. |
pugpig_blockapi_block_webview_page |
($webview_post_page, $block_post, $block, $edition_id) |
Final mutation of a webview card. |
pugpig_blockapi_block_video_page_args |
($video_args, $video, $block, $edition_id) |
Args passed to \Pugpig\Widget\Video::getPage(). |
pugpig_blockapi_block_video_page |
($video_page, $video, $block, $edition_id) |
Final mutation of a video page. |
pugpig_blockapi_block_carousel_page_args |
($carousel_args, $block, $edition_id) |
Args passed to \Pugpig\Widget\Carousel::getPage(). |
pugpig_blockapi_block_carousel_page |
($carousel, $block, $edition_id) |
Final mutation of the video carousel. |
pugpig_blockapi_block_iframe_page_args |
($frame_args, $block, $edition_id) |
Args passed to \Pugpig\Widget\Iframe::getPage() (src, height). |
pugpig_blockapi_block_iframe_page |
($iframe_page, $block, $edition_id) |
Final mutation of an iframe page. |
pugpig_blockapi_block_group_page_args |
($group_args, $block, $edition_id) |
Args passed to \Pugpig\Widget\Group::getPage() (title, summary, ids, classes). |
pugpig_blockapi_block_group_page |
($group_page, $block, $edition_id) |
Final mutation of a group page. |
pugpig_blockapi_timelinecurator_blocks |
($pages, $blocks, $json_decoded, $feed_url) |
Last word on the whole pages array — reorder, add, remove. |
If you mutate a page in one of the *_page filters, recalculate its hash (pugpig_update_page_hash() / \Pugpig\Widget\Base::updatePageHash()) so the change propagates.
13.3 Example
// Match a feed that publishes canonical URLs without a trailing slash.
add_filter('pugpig_blockapi_block_post_link', function ($link, $post, $block) {
return is_string($link) ? untrailingslashit($link) : $link;
}, 10, 3);
// Use the publisher's 4:3 crop when no 16:9 crop is present.
add_filter('pugpig_blockapi_block_post_image_url', function ($url, $block_post, $block, $edition_id) {
if ($url !== null) {
return $url;
}
foreach ($block_post['source']['post_media'][0]['sizes'] ?? [] as $size) {
if (($size['name'] ?? '') === 'large-4:3') {
return $size['url'];
}
}
return null;
}, 10, 4);
// Don't render the publisher's newsletter slab in the app.
add_filter('pugpig_blockapi_block_exclude', function ($exclude, $block_name) {
return $exclude || $block_name === 'publisher/newsletter-signup';
}, 10, 2);14. Bolt / Pugpig hooks consumed
| Hook | Direction | Purpose |
|---|---|---|
pugpig_post_process_pages |
filter, priority 10, 2 args | The main integration point. Rebuilds the pages array. |
pugpig_custom_orders_to_order_by |
filter | Registers "Ordered by Block API JSON feed" in the order-type dropdown. |
rwmb_meta_boxes |
filter | Adds the feed URL / test file / debug panel meta box. |
save_post |
action, priority 19 | Sets $SKIP_EDITION_VALIDATION for editions using this order type. |
cron_schedules |
filter | Adds the every_5_minutes interval. |
upload_mimes |
filter | Allows .json uploads. |
admin_menu, admin_init, admin_post_*
|
actions | Settings and Tools pages. |
trashed_post, untrashed_post, deleted_post
|
actions | Transient invalidation. |
Functions it relies on from Bolt / pugpig-widgets: pugpig_get_page(), pugpig_update_page_hash(), pugpig_widgets_override_bolt_setting_on_page(), pugpig_touch_edition(), pugpig_error_log(), and the \Pugpig\Widget\{Article, Group, Carousel, Video, Iframe, Base} classes.
15. Repository layout
.
├── bitbucket-pipelines.yml CI: phpstan, npm production build, S3 sync
├── docker-compose.yml Local dev container (pugpig/wpdev:v0)
├── README.md this file
└── pugpig-block-api-timelinecurator/
├── composer.json phpstan-wordpress (dev only)
├── package.json Laravel Mix + private pugpig-mix* packages
├── webpack.mix.js Stamps the version and builds the .tar.gz
├── config/phpstan/ phpstan config + WordPress/Bolt stubs
├── dist/ Build output (gitignored target)
└── src/
├── index.php Plugin header, dependency guards, bootstrap,
│ cron registration, deletion hooks
├── Constants.php All meta keys, option names, slugs, transients
├── Settings.php Settings → Pugpig Block API (test mode)
├── CollectionFields.php Order-type registration + edition meta box
├── Curator.php Fetch, validate, hydrate, cache, change detection,
│ debug HTML
├── Atom.php Blocks → Pugpig pages (the main runtime)
├── Update.php 5-minute cron refresh
└── Tools.php Tools → Pugpig Block API Tools
Load order from index.php: Constants, CollectionFields, Curator, Atom, Update, Tools, Settings. CollectionFields, Atom and Update self-initialise at the bottom of their own files; Tools and Settings are initialised explicitly.
16. Build, release and local development
Local (Docker): docker-compose up --build mounts pugpig-block-api-timelinecurator/ into pugpig/wpdev:v0 and runs composer install, composer test (phpstan), npm ci, then NO_GIT=1 npm run watch.
Local (no Docker): npm ci && npm run watch, then symlink pugpig-block-api-timelinecurator/dist/pugpig-block-api-timelinecurator into your WordPress wp-content/plugins/.
Static analysis: composer test runs phpstan with the WordPress extension and this repo's Bolt stubs.
CI (bitbucket-pipelines.yml):
-
Validate & Build PHP —
composer:2.0.12, runscomposer install && composer testfor everycomposer.lockfound. -
Build Node —
node:12.14.1,npm ci && npm run prodfor everypackage-lock.json, then collects the.tar.gzandgittag.txtintobuild/. - On
main/master— artefact synced tos3://pugpigreleases/server-artefacts/<repo-slug>/<gittag>/andlatest_dev/via an OIDC role. - On any tag — same, plus
latest_tag/, as a staging deployment.
webpack.mix.js rewrites the Version: GIT_VERSION placeholder in index.php with the real tag at build time (pugpigUtils.getVersionTag) and produces the .tar.gz via pugpigUtils.makePackage. With NO_GIT=1 or in watch mode the version becomes ?.
Deploying to a server means adding this repo's slug and a version to the relevant deploy repo's deployment.json (express-server-deploy, pugpigsite-server-deploy, or the Pro server's deployer).
17. Troubleshooting
"An article is in the feed but shows as an external/webview card in the app." No WordPress post matched its canonical URL. Check the debug panel — the entry will show Post ID: Not found. Then compare the feed's source.link with the post's syndication_permalink character for character. Trailing slash, http/https and www. differences are the usual culprits. Either fix the importer or normalise with pugpig_blockapi_block_post_link.
"I deleted and re-created a post and the timeline still points at the old ID." Clear that edition's transient from Tools → Pugpig Block API Tools. (Trashing or deleting a post should clear it automatically, but clearing manually is the direct fix.)
"The feed changed but the app hasn't picked it up." Check in order: (1) is the hydrated transient still warm — clear it from the Tools page; (2) is WP-Cron running, so the 5-minute refresh actually fires; (3) did the edition get touched — a change that alters neither the raw body nor the resolved post IDs will not bump <updated>, so Distribution will not re-download.
"Nothing from the feed appears at all." Confirm the edition's order type is Ordered by Block API JSON feed — that is the switch, and the feed URL alone does nothing. Then confirm the feed returns 200 and has a top-level blocks array.
"Posts in a block render but aren't grouped." The block probably also has videos or an attributes.url. Those branches short-circuit group construction — gotcha #5.
"An image isn't showing." Only the large-16:9 crop is read, from the first post_media entry. Anything else needs pugpig_blockapi_block_post_image_url.
Error log messages and what they mean (all via pugpig_error_log):
| Message | Cause |
|---|---|
Timeline JSON URL response is malformed for … |
wp_remote_get returned a WP_Error (DNS, TLS, timeout). |
Timeline JSON URL - Invalid response structure for … |
Response was not an array. |
Timeline JSON URL - No HTTP response for … |
Non-200 status. Downgraded to INFO if PUGPIG_ARC_CURATOR_FEED_DOWN_ERROR_AS_INFO is set. |
Timeline JSON URL - Failed to decode JSON for …, it may need hydrating |
Body did not decode to an array. |
Timeline JSON URL - No blocks found for …, it may need hydrating |
Decoded fine but no top-level blocks. |
Origin Block Data JSON (…) down for more than 10 mins |
Fetch failed and the failover transient had expired. The timeline cannot render from the feed. |
Block Api: No JSON feed found for homepage curation |
The page builder got no usable blocks; the incoming pages array is returned untouched. |
Failed to encode modified block data for edition … |
json_encode failed on the hydrated data (usually invalid UTF-8 in the source). |
18. Gotchas and known issues
Documented as found in the current code — no behaviour change implied.
-
Constants::BLOCKS_JSON_TRANSIENTis self-referential — it is defined asself::BLOCKS_JSON_TRANSIENT . '_feed', which would be a fatal error if it were ever read. Nothing reads it today. Do not start using it without fixing the definition. -
Curator::is_test_mode(int $edition_id)ignores its argument. It returns the global option. The signature implies per-edition control that does not exist; per-edition variation comes only from whether a test file is attached. -
Curator::get_block_data_json_feed()echos an HTML admin notice on total failure (fetch failed and transient expired). That code path also runs during WP-Cron, WP-CLI and feed rendering, so the markup can leak into non-HTML output. The interpolated$json_feed_urlis not escaped in that notice. -
Filter-name mismatch in the docs block. The header comment in
Atom.phplistspugpig_blockapi_article_page, but the code firespugpig_blockapi_block_article_page. Use the latter. -
The video and iframe branches
continuebefore group construction. A block that haspostsandvideos, orpostsand anattributes.url, will have its article/webview pages appended to the feed but no Group page wrapping them — so they are emittedhiddenwith nothing to surface them. Blocks are expected to be single-purpose. -
Update::update_timelines()builds a raw$wpdbquery (notprepared) andechos progress lines. The interpolated value is a class constant, so there is no injection surface, but theechos appear in cron/CLI output. -
Deletion invalidation uses a
LIKEmatch onserialize((int) $post_id). Because the stored value is a serialised array, the pattern (e.g.i:123;) can also match an array index, not just a value. The consequence is harmless over-clearing of a transient. -
query_pugpig_post()passes'compare' => 'IN'with a scalar value, which behaves as equality. The match is an exact string comparison — see section 5. -
Only the
postpost type is searched for canonical-URL matches. Video and audio widget post types will never resolve. -
Only the
large-16:9crop is honoured, and only frompost_media[0]. Everything else needs a filter. -
Duplicate articles within one render. If the same post appears twice,
pugpig_get_page()runs twice and the same page is appended to the feed twice; the second group entry gets apage-<id>-version-2id, for which no page in the feed carries a matching id. -
Only the first author is read (
source.authors[0].name). -
Constants::JSON_FEED_URLandConstants::JSON_URL_SLUGhold the same string. The cron selects on one, the meta box writes the other. Harmless today, but they must be kept in sync. -
PUGPIG_ARC_CURATOR_FEED_DOWN_ERROR_AS_INFOis ARC-named but is the constant this plugin honours. -
Settings::enqueue_scripts()is an empty placeholder, hooked but doing nothing. - The transient TTL (600s) and the cron interval (5 min) are coupled in effect — a healthy feed never lets the transient expire, so the 10-minute failover window is only really exercised when cron is also unhealthy.
-
Curator::get_edition_id()reads$_GET['post']/$_POST['post_ID']to decide which edition the meta box is for, so the debug panel only works in an admin edit-screen context.
TL;DR
An editor pastes a Block API JSON URL onto a Pugpig edition and selects the "Ordered by Block API JSON feed" order type. On every feed render — and every 5 minutes via cron — the plugin fetches that URL (or, in test mode, an uploaded file), validates it has a top-level blocks array, and hydrates it by matching each entry's canonical link against the syndication_permalink post meta to find the backing WordPress post. The hydrated JSON is cached for 10 minutes and doubles as the failover copy when the origin is down. It is then walked block by block into Pugpig widget pages — matched posts become native article cards, unmatched entries become external webview cards, videos become video pages or a video carousel, an attributes.url becomes an iframe, and the members of each block are wrapped in a Group page carrying the block's title, description and CSS classes. Change detection compares both the raw body hash and the ordered list of resolved post IDs, so both a renamed slab and a newly importable article cause pugpig_touch_edition() to bump the edition's <updated> and ping Distribution.


