• Home
    • Pugpig Bolt
    • Content and Workflows

    Pugpig Block API Timeline Curator

    Written by Frank Lockett

    Updated at August 21st, 2026

    • Pugpig Bolt

      • Pugpig Site

        • Pugpig Archive

          • Working with Pugpig

            • Pugpig Consulting

              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.

              1. 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.

              2. 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.

              3. 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 — how it works, in plain English
              • 1. Who this document is for
              • 2. Requirements
              • 3. Setting up a curated edition
              • 4. What the plugin expects from the feed
              • 5. How posts are matched to WordPress content
              • 6. How blocks become app pages
              • 7. Global settings page
              • 8. Test mode
              • 9. Tools page
              • 10. The Feed Debug Output panel
              • 11. Refresh, caching and change detection
              • 12. Storage reference
              • 13. Developer reference: filters
              • 14. Bolt / Pugpig hooks consumed
              • 15. Repository layout
              • 16. Build, release and local development
              • 17. Troubleshooting
              • 18. Gotchas and known issues
              • TL;DR

              "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

              1. Install and activate the plugin (and pugpig-widgets).
              2. Edit the pugpig_edition (collection/timeline) you want to curate.
              3. 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_by filter.
              4. 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.

              5. 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_pinned meta 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 = true so 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 blocks array and the name / attributes convention are the same. The differences: VIP's endpoint returns one post's blocks and nests child content in innerBlocks; this feed instead carries posts[] and videos[] on each block, and source, primary_category and post_media have 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.
              • blocks must 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, http vs https, a www. prefix or a stray query string will stop a post being found. The feed's link and the importer's syndication_permalink must agree byte-for-byte. Use the pugpig_blockapi_block_post_link filter to normalise if the two systems disagree.
              • Only the post post type is searched. Content stored as video_widget, audio_widget or any custom type will not be found.
              • The first match wins. Duplicate syndication_permalink values 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:

              1. pugpig_blockapi_block_exclude is applied with the block name. If it returns truthy, the block is skipped entirely.
              2. posts[] → one page per entry (6.1 / 6.2), collecting a list of group-member ids.
              3. videos[] → one page per video, optionally wrapped in a carousel (6.3). If any video pages were produced, processing of this block stops here.
              4. attributes.url / attributes.src → one iframe page (6.4). If an embed URL was present, processing of this block stops here.
              5. 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 (from excerpt), author, section, and images (from the large-16:9 crop, 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-externally is added.
              • A \Pugpig\Widget\Article page is built from title, summary, section and shareurl (the feed link).
              • externalurl is set to the feed link, plus author, categories and 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\Video page.
              • YouTube Shorts (/shorts/ in the src) get videoorientation: portrait injected into the page's bolt/widget custom category, so the app renders them portrait.
              • If the block name contains the string video-carousel, each video page is marked hidden and its id is collected; the collected ids are then wrapped in a \Pugpig\Widget\Carousel with style: video and class widget-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 = sanitised name and sanitised template

              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.

              1. Enable Settings → Pugpig Block API → Enable Test Mode.
              2. Reload the edition edit screen. A new field, Test JSON File, appears in the feed meta box.
              3. Upload a .json file whose contents match section 4 (top-level blocks).
              4. 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, interval every_5_minutes). The job selects every non-trashed post that has a pugpig_blockdata_timelinecurator_json_feed_url meta 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

              1. Test file (if test mode is on and a file is attached) — read from disk.
              2. Otherwise wp_remote_get with a 20s timeout; args filterable via pugpig_blockdata_timelinecurator_request_args.
              3. The raw body is validated (decodes, has blocks). Failures return nothing and are logged.
              4. 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.
              5. Otherwise every entry is hydrated (section 5) and the result cached in the hydrated-JSON transient for 600 seconds.
              6. 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:

              1. Presentation — slab titles, group descriptions, templates, classes. These live in the feed body, so the raw JSON hash detects them.
              2. 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, runs composer install && composer test for every composer.lock found.
              • Build Node — node:12.14.1, npm ci && npm run prod for every package-lock.json, then collects the .tar.gz and gittag.txt into build/.
              • On main/master — artefact synced to s3://pugpigreleases/server-artefacts/<repo-slug>/<gittag>/ and latest_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.

              1. Constants::BLOCKS_JSON_TRANSIENT is self-referential — it is defined as self::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.
              2. 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.
              3. 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_url is not escaped in that notice.
              4. Filter-name mismatch in the docs block. The header comment in Atom.php lists pugpig_blockapi_article_page, but the code fires pugpig_blockapi_block_article_page. Use the latter.
              5. The video and iframe branches continue before group construction. A block that has posts and videos, or posts and an attributes.url, will have its article/webview pages appended to the feed but no Group page wrapping them — so they are emitted hidden with nothing to surface them. Blocks are expected to be single-purpose.
              6. Update::update_timelines() builds a raw $wpdb query (not prepared) and echos progress lines. The interpolated value is a class constant, so there is no injection surface, but the echos appear in cron/CLI output.
              7. Deletion invalidation uses a LIKE match on serialize((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.
              8. query_pugpig_post() passes 'compare' => 'IN' with a scalar value, which behaves as equality. The match is an exact string comparison — see section 5.
              9. Only the post post type is searched for canonical-URL matches. Video and audio widget post types will never resolve.
              10. Only the large-16:9 crop is honoured, and only from post_media[0]. Everything else needs a filter.
              11. 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 a page-<id>-version-2 id, for which no page in the feed carries a matching id.
              12. Only the first author is read (source.authors[0].name).
              13. Constants::JSON_FEED_URL and Constants::JSON_URL_SLUG hold the same string. The cron selects on one, the meta box writes the other. Harmless today, but they must be kept in sync.
              14. PUGPIG_ARC_CURATOR_FEED_DOWN_ERROR_AS_INFO is ARC-named but is the constant this plugin honours.
              15. Settings::enqueue_scripts() is an empty placeholder, hooked but doing nothing.
              16. 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.
              17. 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.

               

              Was this article helpful?

              Yes
              No
              Give feedback about this article

              Related Articles

              • Pugpig Content Monitoring
              • Pugpig Monitoring API
              pugpig logo white
              Navigation
              • Products
              • Customers
              • News
              • Podcast
              Contact
              • Contact us
              • LinkedIn
              • Twitter
              Technical Support
              • Status Page
              • Documentation
              • Customer Support
              Corporate
              • Company
              • Jobs
              • Privacy Policy

              © Kaldor Ltd. 2022

              Powered by Pugpig


              Knowledge Base Software powered by Helpjuice

              Expand