WP Manifestindependent plugin directory
manifest / multisite / multisite-feed-aggregator

Multisite Feed Aggregator

A small, purpose-built WordPress plugin for the 1A23 Studio multisite network.

by 1A23 Studio · github.com/blueset/multisite-feed-aggregator

★ 0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/blueset/multisite-feed-aggregator/archive/refs/heads/master.zip

A 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_url below 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.
  • ETag covers the whole representation; If-None-Match (weak comparison, tolerant of compression suffixes) returns 304. If-Modified-Since alone 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 503 with Retry-After: 120 rather 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 fires mfa_aggregate_changed when 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']
  1. Back up the database (at least site 1's options and posts tables) and the theme and plugin directories involved.

  2. Install this plugin (dev/package.php builds dist/multisite-feed-aggregator-<version>.zip).

  3. On site 1 only: deactivate CBX RSS Feed for Custom Post Types (mod), then activate Multisite Feed Aggregator.

  4. Configure sources: site 1 with design, gallery, lyrics, misc, open-source, translation (and post with a site label if standard posts should appear), site 2 (blog.1a23.com) with post and label Blog, plus the external feeds (i18n Fails, Lyricova). Check that Source status is OK and preview /feed/.

  5. 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 .bak copy.

  6. Style the widget: the old .netsposts-* rules in titsyul-amip-blocks/styles.css no 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.

  7. When the dry run reports no remaining consumers on either site, deactivate Network Posts Extended on both sites and delete it.

  8. Add the system cron entry above, purge LiteSpeed/Cloudflare caches for the homepage and feeds, and verify the homepage, /feed/, /feed/atom/, and that blog.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

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.