Multisite Feed Aggregator
A small, purpose-built WordPress plugin for the 1A23 Studio multisite network.
by 1A23 Studio · github.com/blueset/multisite-feed-aggregator
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/blueset/multisite-feed-aggregator/archive/refs/heads/master.zipA small, purpose-built WordPress plugin for the 1A23 Studio multisite network. It replaces Network Posts Extended (closed on WordPress.org in May 2025 for a security issue) and the locally modified CBX RSS Feed for Custom Post Types feed integration.
It combines
- posts from selected sites and content types in this multisite network, and
- entries from external RSS/Atom feeds,
into one aggregated feed, which is shown by
- the Aggregated Feed block and the
[multisite_feed]shortcode (the Feed Widget), and - this site's root RSS 2.0 and Atom feeds (
/feed/,/feed/atom/).
External entries are never imported as WordPress posts.
Requirements
- WordPress 6.5+ multisite (developed and tested against 7.1.2), PHP 8.1+ (production: 8.3).
- Source sites must expose the selected content types through the core REST API
(
show_in_rest), anonymously readable. Source sites do not need this plugin. - The server must be able to reach its own sites' public URLs over HTTP(S) (see
mfa_source_rest_urlbelow if it cannot).
Installation and activation
Activate the plugin per site, only on the site(s) that should display the aggregated feed
(for 1A23 Studio: https://1a23.com/). Network activation is refused, and a network-activated
copy does nothing but show an error notice.
Then open Settings → Feed Aggregator.
Settings
| Setting | Notes |
|---|---|
| Default number of entries | Used by blocks/shortcodes without their own count. 1–100, default 5. |
| Network sites | Include a site, set its label, and select its content types. Only public, active (not archived/spam/deleted) sites are offered. Pages, attachments and internal types are never offered; types not exposed through the source site's REST API are shown as unsupported. |
| External feeds | Feed URL, label, enabled flag. URLs belonging to this network are rejected (add the site as a network source instead). |
| Source status | Per-source health: last attempt/success, errors, entries kept, next refresh, background refresh timing, and a Refresh all sources now button. |
The RSS/Atom entry count is WordPress's Settings → Reading → Syndication feeds show the most recent (capped at 100).
Included network entries are published and not password-protected, of the selected types, from eligible sites. Visibility is re-checked against the source site's database on every uncached request, so a post that becomes private, protected or deleted disappears immediately. All languages are included as separate entries.
Labels
| Entry | Label |
|---|---|
Network post of the standard post type |
The site's configured label (e.g. "Blog") |
| Network entry of a custom post type | The type's registered name on the source site (e.g. "Designs", "Open Source") |
| External feed entry | The feed's configured label (e.g. "i18n Fails") |
Each label gets a CSS class mfa-label--{slug}. ASCII labels use their sanitize_title()
slug (i18n Fails → mfa-label--i18n-fails). Labels with characters that have no readable
ASCII form get their ASCII part plus a short, stable hash (Blog ブログ → blog-1a2b3c4d,
ブログ → u1a2b3c4d); the settings screen is the source of truth for the exact class.
Feed Widget
Block: Aggregated Feed (multisite-feed-aggregator/feed), with a count control (or "use
the default"), wide/full alignment, spacing and font-size support.
<!-- wp:multisite-feed-aggregator/feed /-->
<!-- wp:multisite-feed-aggregator/feed {"count":3} /-->
Shortcode:
[multisite_feed]
[multisite_feed count="3"]
An invalid count (not a whole number from 1 to 100) falls back to the default; editors see a notice explaining why, visitors do not.
Each entry shows the linked title (to the original post), the publication date (site date format), the label, and the featured image when available (network: the featured image; external: the first image in the feed's media metadata or content). Entries without an image show no placeholder.
Markup and styling hooks
div.mfa-feed (block: also wp-block-multisite-feed-aggregator-feed)
ul.mfa-feed__list
li.mfa-feed__item
[.mfa-feed__item--first] [.mfa-feed__item--minor] [.has-image]
.mfa-label--{label-slug} .mfa-source--site-{id}|feed-{id} [.mfa-type--{post-type}]
a.mfa-feed__image-link > img.mfa-feed__image (decorative; not a separate tab stop)
div.mfa-feed__body
a.mfa-feed__title
div.mfa-feed__meta
span.mfa-label.mfa-label--{label-slug}
time.mfa-feed__date[datetime]
Layout (from the previous homepage design): the first entry is emphasised with a full-width
image above the title, unless it is a minor entry (network post meta is_minor = 1), which
keeps the row layout with a larger thumbnail. Other entries are compact rows whose thumbnails
are hidden below 600px. Colours inherit from the theme. Custom properties on .mfa-feed:
--mfa-gap, --mfa-thumb-height, --mfa-thumb-height-minor-first, --mfa-radius, --mfa-image-ratio (default 16 / 9; images are cropped to it, except for minor entries, which keep their natural ratio),
--mfa-meta-color, --mfa-focus-color.
/* Per-label styling examples */
.mfa-label.mfa-label--lyricova { color: var(--wp--preset--color--pink); }
.mfa-feed__item.mfa-label--open-source .mfa-feed__title { color: var(--wp--preset--color--green); }
Root RSS and Atom feeds
On sites where the plugin is active:
| Request | Result |
|---|---|
/feed/, /feed/rss2/, ?feed=rss2, ?feed=feed |
Aggregated RSS 2.0 |
/feed/atom/, ?feed=atom |
Aggregated Atom 1.0 |
/feed/rss/, /feed/rdf/, ?feed=rss, ?feed=rdf |
301 to the aggregated RSS 2.0 feed |
| Comment, category/tag/taxonomy, author, date, search, post-type and single-post feeds | Unchanged native WordPress feeds |
Sites without the plugin (e.g. blog.1a23.com) keep their native feeds.
- Network entries contain the full content rendered by their source site (fetched from the source site's REST API, so that site's theme/plugins/shortcodes apply) and keep their WordPress GUIDs, so existing subscribers do not see duplicates. External entries contain a plain-text excerpt and a link to the original.
ETagcovers the whole representation;If-None-Match(weak comparison, tolerant of compression suffixes) returns304.If-Modified-Sincealone is deliberately not honoured because dates cannot reflect removals or label changes.Cache-Control: no-cache.- If current full content for a selected network entry cannot be obtained (e.g. the source
site's REST API is unreachable), the feed responds
503withRetry-After: 120rather than publishing a truncated or excerpt-only feed. The widget is unaffected. - The plugin serves these requests at
parse_request, before WordPress's own conditional-GET handling. Plugins that rewrite feed queries (the CBX plugin) must be deactivated on this site; the settings screen warns while it is active.
Freshness, caching and scheduling
- Network entries are selected by direct database queries on every uncached request.
Canonical links/titles and full content are cached per post revision (links 30 days,
content 7 days) and fetched in batches from the source REST API on a miss. If a link
cannot be fetched, the entry is still shown with a
?p={id}link that WordPress redirects to the permalink, and the error is shown on the settings screen. - External feeds refresh every 15 minutes. The newest 100 entries per feed are kept, even when the publisher's rolling feed drops them or the feed is unavailable; failures never replace the last good data. Page and feed requests never fetch external feeds.
- A per-site event (
mfa_cron_tick) runs every minute: it refreshes due feeds, warms network caches, and firesmfa_aggregate_changedwhen the visible entries change. By default that purges the front page and feeds from LiteSpeed Cache (if active).
WP-Cron only runs when the site receives traffic. For reliable timing, also run due events
from a system scheduler. On the production server this is installed in blueset's crontab,
using a WP-CLI wrapper (~/mfa-rollout/wp) because the site's plugins need more than the CLI's
default 128 MB and the default php CLI lacks mysqli:
# ~/mfa-rollout/wp
#!/bin/sh
exec /usr/local/lsws/lsphp83/bin/php -d memory_limit=1024M /home/blueset/wp-cli.phar --path=/var/www/wordpress "$@"
# crontab
* * * * * /home/blueset/mfa-rollout/wp cron event run --due-now --url=https://1a23.com/ --quiet 2>&1 | grep -v HTTP_ACCEPT >> /home/blueset/mfa-rollout/cron.log
The settings screen warns when scheduled refreshes are overdue.
Pages that show the widget can also be full-page cached; other page caches can hook
mfa_aggregate_changed.
External feed safety
Feed URLs must be http(s), without credentials, on port 80/443/8080. Every request and every
redirect hop (max. 3) is resolved and rejected if it points to loopback, private, link-local
(including cloud metadata 169.254.169.254), shared or reserved addresses, or to a site in this
network; the connection is then pinned to the validated address (cURL CURLOPT_RESOLVE), so a
DNS answer that changes between check and connect cannot reach an internal address. The PHP
cURL extension is therefore required for external feeds. Responses are limited to 2 MB and 10
seconds. Content is parsed with WordPress's
bundled SimplePie and KSES sanitizer; only plain-text titles/excerpts and http(s) links/images
are stored and all output is escaped. Auto-discovery is not used: configure the feed URL
itself.
Hooks
| Hook | Type | Purpose |
|---|---|---|
mfa_source_rest_url |
filter ( string $url, int $blog_id, string $route ) |
Route source-site REST calls elsewhere (e.g. an internal address). |
mfa_aggregate_changed |
action ( FeedItem[] $items ) |
Visible entries changed; purge page caches. |
mfa_allowed_private_hosts |
filter ( string[] $hosts ) |
Development/testing only: exempt exact host names from private-address checks. |
Data and uninstall
All data is stored in the consuming site's options (non-autoloaded): mfa_settings,
mfa_feed_{id} (retained entries), mfa_feed_status_{id}, mfa_network_status, plus
mfa1_* transients for cached links, content and type lists. Deactivation stops the schedule
and keeps settings. Deleting the plugin removes only this data, on every site; source posts are
never modified.
Migrating from Network Posts Extended (production runbook)
Current state (inspected 2026-09-26): the homepage uses the shortcode below in both the
theme file wp-content/themes/t-a-2025/templates/page-index.html and the Site Editor
override stored in the database (site 1, wp_template #1658 page-index, theme t-a-2025),
which is what is actually rendered. Network Posts Extended is active on both sites; the
modified CBX feed plugin is active on site 1.
[netsposts post_type='post,design,gallery,lyrics,misc,open-source,translation' hide_excerpt='true' list='5' ... thumbnail='true' use_layout='inline' size='large']
-
Back up the database (at least site 1's options and posts tables) and the theme and plugin directories involved.
-
Install this plugin (
dev/package.phpbuildsdist/multisite-feed-aggregator-<version>.zip). -
On site 1 only: deactivate CBX RSS Feed for Custom Post Types (mod), then activate Multisite Feed Aggregator.
-
Configure sources: site 1 with
design, gallery, lyrics, misc, open-source, translation(andpostwith a site label if standard posts should appear), site 2 (blog.1a23.com) withpostand label Blog, plus the external feeds (i18n Fails, Lyricova). Check that Source status is OK and preview/feed/. -
Replace the shortcode (dry run first; it aborts if the content is not exactly the expected single shortcode block):
wp eval-file wp-content/plugins/multisite-feed-aggregator/tools/migrate-legacy-shortcode.php --url=https://1a23.com/ wp eval-file wp-content/plugins/multisite-feed-aggregator/tools/migrate-legacy-shortcode.php apply theme-files --url=https://1a23.com/ wp eval-file wp-content/plugins/multisite-feed-aggregator/tools/migrate-legacy-shortcode.php --url=https://blog.1a23.com/The database template keeps a revision; each theme file gets a timestamped
.bakcopy. -
Style the widget: the old
.netsposts-*rules intitsyul-amip-blocks/styles.cssno longer apply; move any site-specific touches (e.g. the hover arrow via--hover-arrow-size) to the new classes, then remove the old rules. -
When the dry run reports no remaining consumers on either site, deactivate Network Posts Extended on both sites and delete it.
-
Add the system cron entry above, purge LiteSpeed/Cloudflare caches for the homepage and feeds, and verify the homepage,
/feed/,/feed/atom/, and thatblog.1a23.com/feed/is unchanged.
Rollback: deactivate this plugin (native feeds return immediately), restore the template
from its revision (Site Editor → Templates → Index page → Revisions) and the theme file from its
.bak copy, and reactivate the CBX plugin if its feed behaviour is needed. Do not reactivate
Network Posts Extended; replace the section with a static list instead if needed.
Development
Requires Docker. The repository root is the plugin; it is mounted into a two-site (plus one
archived) WordPress 7.1.2 multisite with fixtures resembling production (Pods-like custom post
types on site 1 only, a blog-only content filter, multilingual posts, private/protected/draft/
scheduled posts, featured images, is_minor), and a mock feed server.
docker compose up -d
docker compose run --rm cli sh wp-content/plugins/multisite-feed-aggregator/dev/setup.sh
- Sites: http://1a23.localhost:8080/ (admin/admin) and http://blog.1a23.localhost:8080/
(Chrome resolves
*.localhostautomatically). - Demo page: create one containing ``, or use the homepage template.
Checks:
# Integration tests (real WordPress, fixtures, HTTP end-to-end); optional name filter as last arg
docker compose run --rm cli wp eval-file wp-content/plugins/multisite-feed-aggregator/tests/run.php --url=http://1a23.localhost:8080
# PHP syntax
docker compose run --rm cli sh wp-content/plugins/multisite-feed-aggregator/dev/lint.sh
# WordPress Coding Standards
docker run --rm -v "$PWD:/app" -w /app composer:2 sh -c "composer install -q && vendor/bin/phpcs"
# Inspect the current aggregate
docker compose run --rm cli wp eval-file wp-content/plugins/multisite-feed-aggregator/dev/inspect.php 12 --url=http://1a23.localhost:8080
# Build the release ZIP
docker compose run --rm --entrypoint php cli wp-content/plugins/multisite-feed-aggregator/dev/package.php
The block editor script uses WordPress globals (block/index.js + index.asset.php), so no
JavaScript build step is needed.
Known limitations
- External entries are deduplicated against network entries within the fetched window (the newest N per site); an external copy of an older network post may still appear.
- Content types that are not exposed through the source site's REST API cannot be aggregated.
- The aggregated feeds do not announce a WebSub hub.