From 08545602d07c2116aad1493974efd90c070c2910 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 15 Oct 2026 12:49:56 +0200 Subject: [PATCH 1/7] Serve the followed feeds at /.well-known/following.opml --- blockroll.php | 15 +++++- includes/class-opml.php | 95 +++++++++++++++++++++++++++++------- readme.md | 3 +- templates/opml-following.php | 42 ++++++++++++++++ tests/test-opml.php | 37 +++++++++++++- 5 files changed, 170 insertions(+), 22 deletions(-) create mode 100644 templates/opml-following.php diff --git a/blockroll.php b/blockroll.php index a19e3fd..9e5e887 100644 --- a/blockroll.php +++ b/blockroll.php @@ -74,8 +74,8 @@ function ( $vars ) { $vars[] = 'blockroll-page'; // OPML output. A query var rather than a rewrite endpoint: no rewrite // flush, and when the plugin is disabled the URL falls back to the - // page itself instead of a 404. Only the well-known URL, which has - // no page behind it, gets a rewrite rule of its own. + // page itself instead of a 404. Only the well-known URLs, which have + // no page behind them, get rewrite rules of their own. $vars[] = Opml::QUERY_VAR; // One blogroll of a page, picked by its HTML anchor. $vars[] = Opml::GROUP; @@ -83,6 +83,17 @@ function ( $vars ) { } ); +// The SocialSpec plugin serves a following.opml of its own, and it answers +// before this one does. Its rewrite rule is built from this list on `init`. +\add_filter( + 'socialspec_documents', + function ( array $documents ): array { + unset( $documents['following.opml'] ); + + return $documents; + } +); + \add_action( 'rest_api_init', function () { diff --git a/includes/class-opml.php b/includes/class-opml.php index b089b62..7ab032c 100644 --- a/includes/class-opml.php +++ b/includes/class-opml.php @@ -9,7 +9,8 @@ /** * Serve OPML for blogroll pages and a directory of them at the site root - * and at /.well-known/recommendations.opml. + * and at /.well-known/recommendations.opml, and their feeds at + * /.well-known/following.opml. */ class Opml { /** @@ -17,7 +18,14 @@ class Opml { * * See https://opml.org/ */ - const WELL_KNOWN = '.well-known/recommendations.opml'; + const RECOMMENDATIONS_PATH = '.well-known/recommendations.opml'; + + /** + * Path of the well-known following list, without the leading slash. + * + * See https://followspec.org/ + */ + const FOLLOWING_PATH = '.well-known/following.opml'; /** * Query var that asks for the OPML of a page instead of its HTML. @@ -31,6 +39,11 @@ class Opml { */ const DIRECTORY = 'directory'; + /** + * Value of the `opml` query var that always asks for the following list. + */ + const FOLLOWING = 'following'; + /** * Query var that picks one blogroll of a page by its HTML anchor. * @@ -55,8 +68,8 @@ class Opml { */ public static function register() { self::add_rewrite_rules(); - // Before redirect_canonical, which would send the well-known URL to a - // trailing-slash version of itself. + // Before redirect_canonical, which would send the well-known URLs to + // a trailing-slash version of themselves. \add_action( 'template_redirect', array( self::class, 'render' ), 9 ); \add_action( 'wp_head', array( self::class, 'discovery_link' ) ); foreach ( array( 'rss2', 'atom' ) as $feed ) { @@ -67,25 +80,32 @@ public static function register() { } /** - * Map the well-known URL to the directory OPML and `.opml` to `?opml`. + * Map the well-known URLs to the directory and the following list, + * and `.opml` to `?opml`. * * Also called on activation, before the rules are flushed. Unlike the - * `?opml` query var, the well-known path has no page to fall back to, - * so it needs a rule of its own. + * `?opml` query var, the well-known paths have no page to fall back + * to, so they need rules of their own. */ public static function add_rewrite_rules() { - \add_rewrite_rule( - // WordPress matches rewrite rules with "#" as the delimiter. - \sprintf( '^%s$', \preg_quote( self::WELL_KNOWN, '#' ) ), - \sprintf( 'index.php?%s=%s', self::QUERY_VAR, self::DIRECTORY ), - 'top' + $well_known = array( + self::RECOMMENDATIONS_PATH => self::DIRECTORY, + self::FOLLOWING_PATH => self::FOLLOWING, ); + foreach ( $well_known as $path => $value ) { + \add_rewrite_rule( + // WordPress matches rewrite rules with "#" as the delimiter. + \sprintf( '^%s$', \preg_quote( $path, '#' ) ), + \sprintf( 'index.php?%s=%s', self::QUERY_VAR, $value ), + 'top' + ); + } // A `.opml` suffix as an alias for `?opml` on pages, so the URL // looks like a file: /blogroll.opml. The query var stays the // canonical form; it also works with plain permalinks. // This rule matches the well-known path too. Rules keep the order - // they were added in, so the well-known rule above has to stay - // above; the well-known test covers that. + // they were added in, so the well-known rules above have to stay + // above; the well-known tests cover that. \add_rewrite_rule( '^(.+?)\\.opml$', \sprintf( 'index.php?pagename=$matches[1]&%s=', self::QUERY_VAR ), @@ -404,6 +424,36 @@ public static function directory( $posts = null ) { ); } + /** + * Print the following list: the feeds of every listed blogroll in + * one flat list, each feed once. + * + * See https://followspec.org/ + * + * @param \WP_Post[]|null $posts Blogroll posts, or null to look them up. + */ + public static function following( $posts = null ) { + $posts = null === $posts ? Index::get_posts() : $posts; + $links = array(); + foreach ( $posts as $post ) { + foreach ( self::page_links( $post ) as $link ) { + // Only a feed can be followed, and the same feed only once. + if ( $link['feedUrl'] && ! isset( $links[ $link['feedUrl'] ] ) ) { + $links[ $link['feedUrl'] ] = $link; + } + } + } + + \load_template( + \dirname( BLOCKROLL_PLUGIN_FILE ) . '/templates/opml-following.php', + false, + array( + 'posts' => $posts, + 'links' => \array_values( $links ), + ) + ); + } + /** * Print the XML prolog and stylesheet line of an OPML document. */ @@ -437,10 +487,12 @@ public static function render() { return; } - // The well-known URL asks for the directory, whatever page it lands on. + // The well-known URLs ask for the directory or the following list, + // whatever page they land on. $directory = self::DIRECTORY === $opml; + $following = self::FOLLOWING === $opml; $anchor = (string) \get_query_var( self::GROUP, '' ); - $post = $directory ? null : Index::queried_post(); + $post = ( $directory || $following ) ? null : Index::queried_post(); // A page whose lists are all unlisted has no file of its own, and // neither has one that is asked for a list it does not hold. It is // then a page without a blogroll: the root still answers with the @@ -448,12 +500,21 @@ public static function render() { if ( $post && ! self::requested_groups( $post, $anchor ) ) { $post = null; } - $posts = ( ! $post && ( $directory || self::is_blogroll_root() ) ) ? Index::get_posts() : array(); + $posts = ( ! $post && ( $directory || $following || self::is_blogroll_root() ) ) ? Index::get_posts() : array(); if ( ! $post && ! $posts ) { return; } + if ( $following ) { + // FollowSpec asks for the OPML media type, and for other sites + // to be allowed to read the list from the browser. + \header( 'Content-Type: text/x-opml; charset=' . \get_option( 'blog_charset' ) ); + \header( 'Access-Control-Allow-Origin: *' ); + self::following( $posts ); + exit; + } + \header( 'Content-Type: text/xml; charset=' . \get_option( 'blog_charset' ) ); if ( $post ) { self::for_post( $post, $anchor ); diff --git a/readme.md b/readme.md index a711f16..9db7546 100644 --- a/readme.md +++ b/readme.md @@ -92,7 +92,8 @@ example `https://example.com/links/?opml`. That is the address to hand to a feed You do not have to tell anyone about it. Pages with a blogroll announce it in their HTML, and so does your homepage, so a reader that looks for it will find it. Sites that collect blogrolls look at `https://example.com/.well-known/recommendations.opml`, which -lists all blogrolls on your site. +lists all blogrolls on your site. Apps that show who you follow look at +`https://example.com/.well-known/following.opml`, one list of all the feeds in them. There is also a download link under the list, for people who prefer a file. diff --git a/templates/opml-following.php b/templates/opml-following.php new file mode 100644 index 0000000..e3f6a6e --- /dev/null +++ b/templates/opml-following.php @@ -0,0 +1,42 @@ + + + + + <?php + /* translators: %s: site name */ + echo esc_xml( sprintf( __( 'Feeds followed on %s', 'blockroll' ), get_bloginfo( 'name' ) ) ); + ?> + + + + + + + + + $args['links'] ) ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> + + diff --git a/tests/test-opml.php b/tests/test-opml.php index 0119e76..3380692 100644 --- a/tests/test-opml.php +++ b/tests/test-opml.php @@ -189,14 +189,47 @@ public function test_opml_without_author_has_no_empty_owner() { $this->assertInstanceOf( SimpleXMLElement::class, new SimpleXMLElement( $xml ) ); } - public function test_well_known_url_asks_for_the_directory() { + public function test_recommendations_url_asks_for_the_directory() { $this->set_permalink_structure( '/%postname%/' ); - $this->go_to( home_url( '/' . \Blockroll\Opml::WELL_KNOWN ) ); + $this->go_to( home_url( '/' . \Blockroll\Opml::RECOMMENDATIONS_PATH ) ); $this->assertSame( \Blockroll\Opml::DIRECTORY, get_query_var( 'opml' ) ); $this->assertFalse( is_404() ); $this->set_permalink_structure( '' ); } + public function test_following_url_asks_for_the_following_list() { + $this->set_permalink_structure( '/%postname%/' ); + $this->go_to( home_url( '/' . \Blockroll\Opml::FOLLOWING_PATH ) ); + $this->assertSame( \Blockroll\Opml::FOLLOWING, get_query_var( 'opml' ) ); + $this->assertFalse( is_404() ); + $this->set_permalink_structure( '' ); + } + + public function test_following_lists_the_feeds_of_listed_blogrolls_once() { + self::factory()->post->create( array( 'post_content' => self::TWO_NAMED_BLOCKS ) ); + self::factory()->post->create( + array( + // The same feed again, a link without a feed and an unlisted list. + 'post_content' => self::BLOCK + . '' + . '', + ) + ); + ob_start(); + \Blockroll\Opml::following(); + $xml = ob_get_clean(); + $doc = new SimpleXMLElement( $xml ); + + $feeds = array(); + foreach ( $doc->body->outline as $outline ) { + $this->assertCount( 0, $outline->outline, 'The list is flat.' ); + $feeds[] = (string) $outline['xmlUrl']; + } + sort( $feeds ); + $this->assertSame( array( 'https://a.example/feed/', 'https://b.example/feed/' ), $feeds ); + $this->assertSame( get_bloginfo( 'name' ), (string) $doc->head->ownerName ); + } + public function test_opml_suffix_is_an_alias_for_the_query_var() { $id = self::factory()->post->create( array( From d9fab4ad7d76bdf009de2237564ecc11d0872cf1 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 15 Oct 2026 12:52:47 +0200 Subject: [PATCH 2/7] Mention FollowSpec in the readme --- readme.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 9db7546..c72be90 100644 --- a/readme.md +++ b/readme.md @@ -93,7 +93,8 @@ You do not have to tell anyone about it. Pages with a blogroll announce it in th HTML, and so does your homepage, so a reader that looks for it will find it. Sites that collect blogrolls look at `https://example.com/.well-known/recommendations.opml`, which lists all blogrolls on your site. Apps that show who you follow look at -`https://example.com/.well-known/following.opml`, one list of all the feeds in them. +`https://example.com/.well-known/following.opml`, one list of all the feeds in them, as +described in [FollowSpec](https://followspec.org/), part of [SocialSpec](https://socialspec.org/). There is also a download link under the list, for people who prefer a file. From 715ea13498960d5793fafcd7acda173d858201d6 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 15 Oct 2026 13:03:45 +0200 Subject: [PATCH 3/7] Add the blogroll feeds to SocialSpec instead of replacing its list --- blockroll.php | 11 ------ includes/class-opml.php | 85 ++++++++++++++++++++++++++++++++++------- tests/test-opml.php | 22 +++++++++++ 3 files changed, 94 insertions(+), 24 deletions(-) diff --git a/blockroll.php b/blockroll.php index 9e5e887..f4e3bae 100644 --- a/blockroll.php +++ b/blockroll.php @@ -83,17 +83,6 @@ function ( $vars ) { } ); -// The SocialSpec plugin serves a following.opml of its own, and it answers -// before this one does. Its rewrite rule is built from this list on `init`. -\add_filter( - 'socialspec_documents', - function ( array $documents ): array { - unset( $documents['following.opml'] ); - - return $documents; - } -); - \add_action( 'rest_api_init', function () { diff --git a/includes/class-opml.php b/includes/class-opml.php index 7ab032c..7b4e7be 100644 --- a/includes/class-opml.php +++ b/includes/class-opml.php @@ -72,6 +72,7 @@ public static function register() { // a trailing-slash version of themselves. \add_action( 'template_redirect', array( self::class, 'render' ), 9 ); \add_action( 'wp_head', array( self::class, 'discovery_link' ) ); + \add_filter( 'socialspec_following', array( self::class, 'socialspec_following' ) ); foreach ( array( 'rss2', 'atom' ) as $feed ) { \add_action( $feed . '_ns', array( self::class, 'feed_namespace_start' ), 1 ); \add_action( $feed . '_ns', array( self::class, 'feed_namespace' ), PHP_INT_MAX ); @@ -88,10 +89,11 @@ public static function register() { * to, so they need rules of their own. */ public static function add_rewrite_rules() { - $well_known = array( - self::RECOMMENDATIONS_PATH => self::DIRECTORY, - self::FOLLOWING_PATH => self::FOLLOWING, - ); + $well_known = array( self::RECOMMENDATIONS_PATH => self::DIRECTORY ); + // With SocialSpec, its following.opml lists the blogroll feeds. + if ( ! self::has_socialspec_following() ) { + $well_known[ self::FOLLOWING_PATH ] = self::FOLLOWING; + } foreach ( $well_known as $path => $value ) { \add_rewrite_rule( // WordPress matches rewrite rules with "#" as the delimiter. @@ -434,8 +436,26 @@ public static function directory( $posts = null ) { */ public static function following( $posts = null ) { $posts = null === $posts ? Index::get_posts() : $posts; + + \load_template( + \dirname( BLOCKROLL_PLUGIN_FILE ) . '/templates/opml-following.php', + false, + array( + 'posts' => $posts, + 'links' => self::following_links( $posts ), + ) + ); + } + + /** + * The links of every listed blogroll that have a feed, each feed once. + * + * @param \WP_Post[]|null $posts Blogroll posts, or null to look them up. + * @return array Normalized links. + */ + public static function following_links( $posts = null ) { $links = array(); - foreach ( $posts as $post ) { + foreach ( ( null === $posts ? Index::get_posts() : $posts ) as $post ) { foreach ( self::page_links( $post ) as $link ) { // Only a feed can be followed, and the same feed only once. if ( $link['feedUrl'] && ! isset( $links[ $link['feedUrl'] ] ) ) { @@ -443,15 +463,54 @@ public static function following( $posts = null ) { } } } + return \array_values( $links ); + } - \load_template( - \dirname( BLOCKROLL_PLUGIN_FILE ) . '/templates/opml-following.php', - false, - array( - 'posts' => $posts, - 'links' => \array_values( $links ), - ) - ); + /** + * Whether the SocialSpec plugin serves /.well-known/following.opml. + * + * The blogroll feeds then go into its list, and this plugin serves + * no file of its own there. + * + * See https://socialspec.org/ + * + * @return bool True if SocialSpec has a following.opml. + */ + private static function has_socialspec_following() { + return \class_exists( '\Socialspec\Socialspec' ) + && isset( \Socialspec\Socialspec::get_documents()['following.opml'] ); + } + + /** + * Add the blogroll feeds to the following list of SocialSpec. + * + * Feeds SocialSpec already lists, from the Link Manager or another + * plugin, are not added again. + * + * @param array $outlines OPML outline attributes, each with an `xmlUrl`. + * @return array The outlines. + */ + public static function socialspec_following( $outlines ) { + $outlines = (array) $outlines; + $feeds = \array_column( $outlines, 'xmlUrl' ); + + foreach ( self::following_links() as $link ) { + if ( \in_array( $link['feedUrl'], $feeds, true ) ) { + continue; + } + + $outlines[] = \array_filter( + array( + 'type' => 'rss', + 'text' => $link['name'] ? $link['name'] : $link['url'], + 'description' => $link['description'], + 'htmlUrl' => $link['url'], + 'xmlUrl' => $link['feedUrl'], + ) + ); + } + + return $outlines; } /** diff --git a/tests/test-opml.php b/tests/test-opml.php index 3380692..220eaf1 100644 --- a/tests/test-opml.php +++ b/tests/test-opml.php @@ -230,6 +230,28 @@ public function test_following_lists_the_feeds_of_listed_blogrolls_once() { $this->assertSame( get_bloginfo( 'name' ), (string) $doc->head->ownerName ); } + public function test_socialspec_following_adds_the_blogroll_feeds_once() { + self::factory()->post->create( array( 'post_content' => self::TWO_NAMED_BLOCKS ) ); + $bookmark = array( + 'type' => 'rss', + 'text' => 'Bookmark', + 'xmlUrl' => 'https://a.example/feed/', + ); + $outlines = \Blockroll\Opml::socialspec_following( array( $bookmark ) ); + + $this->assertCount( 2, $outlines ); + $this->assertSame( $bookmark, $outlines[0], 'A feed SocialSpec lists stays as it is.' ); + $this->assertSame( + array( + 'type' => 'rss', + 'text' => 'B', + 'htmlUrl' => 'https://b.example/', + 'xmlUrl' => 'https://b.example/feed/', + ), + $outlines[1] + ); + } + public function test_opml_suffix_is_an_alias_for_the_query_var() { $id = self::factory()->post->create( array( From 51af1764692de89474c99a83a7a72784b4eff774 Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 15 Oct 2026 13:05:05 +0200 Subject: [PATCH 4/7] Answer the well-known lists without blogrolls and flush stale rules --- includes/class-opml.php | 68 +++++++++++++++++++++++++++++++++-------- tests/test-opml.php | 24 +++++++++++++++ 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/includes/class-opml.php b/includes/class-opml.php index 7b4e7be..2b6c3a0 100644 --- a/includes/class-opml.php +++ b/includes/class-opml.php @@ -68,6 +68,8 @@ class Opml { */ public static function register() { self::add_rewrite_rules(); + // After `init`, when every plugin has added its rules. + \add_action( 'wp_loaded', array( self::class, 'flush_stale_rewrite_rules' ) ); // Before redirect_canonical, which would send the well-known URLs to // a trailing-slash version of themselves. \add_action( 'template_redirect', array( self::class, 'render' ), 9 ); @@ -89,18 +91,8 @@ public static function register() { * to, so they need rules of their own. */ public static function add_rewrite_rules() { - $well_known = array( self::RECOMMENDATIONS_PATH => self::DIRECTORY ); - // With SocialSpec, its following.opml lists the blogroll feeds. - if ( ! self::has_socialspec_following() ) { - $well_known[ self::FOLLOWING_PATH ] = self::FOLLOWING; - } - foreach ( $well_known as $path => $value ) { - \add_rewrite_rule( - // WordPress matches rewrite rules with "#" as the delimiter. - \sprintf( '^%s$', \preg_quote( $path, '#' ) ), - \sprintf( 'index.php?%s=%s', self::QUERY_VAR, $value ), - 'top' - ); + foreach ( \array_filter( self::well_known_rules() ) as $regex => $query ) { + \add_rewrite_rule( $regex, $query, 'top' ); } // A `.opml` suffix as an alias for `?opml` on pages, so the URL // looks like a file: /blogroll.opml. The query var stays the @@ -115,6 +107,54 @@ public static function add_rewrite_rules() { ); } + /** + * The rewrite rules of the well-known URLs. + * + * @return array Regex => query, or => null for a URL this plugin + * does not serve on this site. + */ + private static function well_known_rules() { + $well_known = array( + self::RECOMMENDATIONS_PATH => self::DIRECTORY, + self::FOLLOWING_PATH => self::FOLLOWING, + ); + + $rules = array(); + foreach ( $well_known as $path => $value ) { + // WordPress matches rewrite rules with "#" as the delimiter. + $regex = \sprintf( '^%s$', \preg_quote( $path, '#' ) ); + // With SocialSpec, its following.opml lists the blogroll feeds. + $rules[ $regex ] = ( self::FOLLOWING === $value && self::has_socialspec_following() ) + ? null + : \sprintf( 'index.php?%s=%s', self::QUERY_VAR, $value ); + } + return $rules; + } + + /** + * Flush the rewrite rules when the stored well-known rules are out of + * date. + * + * Activation flushes, an update does not, so a new well-known URL + * would stay a 404 on existing sites. The rules also change when + * SocialSpec is activated or deactivated. Compared with the stored + * rules, so a site flushes once and needs no version of its own. + */ + public static function flush_stale_rewrite_rules() { + $stored = \get_option( 'rewrite_rules' ); + // Plain permalinks store no rules, and missing ones are built anew. + if ( ! \is_array( $stored ) ) { + return; + } + + foreach ( self::well_known_rules() as $regex => $query ) { + if ( ( $stored[ $regex ] ?? null ) !== $query ) { + \flush_rewrite_rules( false ); + return; + } + } + } + /** * Start buffering the namespaces other plugins and themes print. * @@ -561,7 +601,9 @@ public static function render() { } $posts = ( ! $post && ( $directory || $following || self::is_blogroll_root() ) ) ? Index::get_posts() : array(); - if ( ! $post && ! $posts ) { + // The well-known URLs have no page to fall back to, so they answer + // with an empty list rather than the home page. + if ( ! $post && ! $posts && ! $directory && ! $following ) { return; } diff --git a/tests/test-opml.php b/tests/test-opml.php index 220eaf1..7f11fc2 100644 --- a/tests/test-opml.php +++ b/tests/test-opml.php @@ -230,6 +230,30 @@ public function test_following_lists_the_feeds_of_listed_blogrolls_once() { $this->assertSame( get_bloginfo( 'name' ), (string) $doc->head->ownerName ); } + public function test_missing_well_known_rules_are_flushed() { + $this->set_permalink_structure( '/%postname%/' ); + // Rules stored by a version without the following list. + $rules = get_option( 'rewrite_rules' ); + unset( $rules['^\.well-known/following\.opml$'] ); + update_option( 'rewrite_rules', $rules ); + + \Blockroll\Opml::flush_stale_rewrite_rules(); + + $rules = get_option( 'rewrite_rules' ); + $this->assertSame( 'index.php?opml=following', $rules['^\.well-known/following\.opml$'] ); + $this->assertSame( 'index.php?opml=directory', $rules['^\.well-known/recommendations\.opml$'] ); + $this->set_permalink_structure( '' ); + } + + public function test_well_known_lists_without_blogrolls_are_empty_opml() { + foreach ( array( 'directory', 'following' ) as $method ) { + ob_start(); + \Blockroll\Opml::$method( array() ); + $doc = new SimpleXMLElement( ob_get_clean() ); + $this->assertCount( 0, $doc->body->outline, $method ); + } + } + public function test_socialspec_following_adds_the_blogroll_feeds_once() { self::factory()->post->create( array( 'post_content' => self::TWO_NAMED_BLOCKS ) ); $bookmark = array( From 7c0aae2c0b6f049fb2f5205a5c83aabf7f7c769e Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 15 Oct 2026 13:10:21 +0200 Subject: [PATCH 5/7] Add ETag and preflight to following.opml and fix the rewrite rule test --- includes/class-opml.php | 92 +++++++++++++++++++++++++++++--- templates/opml-following.php | 2 +- tests/bootstrap.php | 1 + tests/stubs/class-socialspec.php | 29 ++++++++++ tests/test-opml.php | 53 ++++++++++++++++-- 5 files changed, 165 insertions(+), 12 deletions(-) create mode 100644 tests/stubs/class-socialspec.php diff --git a/includes/class-opml.php b/includes/class-opml.php index 2b6c3a0..a4ba0ac 100644 --- a/includes/class-opml.php +++ b/includes/class-opml.php @@ -481,7 +481,15 @@ public static function following( $posts = null ) { \dirname( BLOCKROLL_PLUGIN_FILE ) . '/templates/opml-following.php', false, array( - 'posts' => $posts, + // Only pages with a feed in the list make it change. + 'posts' => \array_values( + \array_filter( + $posts, + function ( $post ) { + return (bool) self::following_links( array( $post ) ); + } + ) + ), 'links' => self::following_links( $posts ), ) ); @@ -517,7 +525,7 @@ public static function following_links( $posts = null ) { * @return bool True if SocialSpec has a following.opml. */ private static function has_socialspec_following() { - return \class_exists( '\Socialspec\Socialspec' ) + return \class_exists( 'Socialspec\Socialspec' ) && isset( \Socialspec\Socialspec::get_documents()['following.opml'] ); } @@ -608,12 +616,7 @@ public static function render() { } if ( $following ) { - // FollowSpec asks for the OPML media type, and for other sites - // to be allowed to read the list from the browser. - \header( 'Content-Type: text/x-opml; charset=' . \get_option( 'blog_charset' ) ); - \header( 'Access-Control-Allow-Origin: *' ); - self::following( $posts ); - exit; + self::serve_following( $posts ); } \header( 'Content-Type: text/xml; charset=' . \get_option( 'blog_charset' ) ); @@ -625,6 +628,79 @@ public static function render() { exit; } + /** + * Serve the following list the way FollowSpec readers expect it, and + * exit. + * + * The OPML media type, open to other sites in the browser, and with + * an ETag, so a reader that asks again with If-None-Match gets a 304 + * while nothing changed. Such a request from another site asks first + * with OPTIONS. + * + * See https://followspec.org/ + * + * @param \WP_Post[] $posts Blogroll posts. + */ + private static function serve_following( $posts ) { + \header( 'Access-Control-Allow-Origin: *' ); + + $method = \strtoupper( \sanitize_text_field( \wp_unslash( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) ) ); + if ( 'OPTIONS' === $method ) { + \status_header( 204 ); + \header( 'Access-Control-Allow-Methods: GET, HEAD, OPTIONS' ); + \header( 'Access-Control-Allow-Headers: If-None-Match' ); + \header( 'Access-Control-Max-Age: 86400' ); + exit; + } + + \ob_start(); + self::following( $posts ); + $body = (string) \ob_get_clean(); + $etag = '"' . \md5( $body ) . '"'; + + \header( 'Content-Type: text/x-opml; charset=' . \get_option( 'blog_charset' ) ); + \header( 'ETag: ' . $etag ); + \header( 'Access-Control-Expose-Headers: ETag' ); + \header( 'X-Content-Type-Options: nosniff' ); + + if ( self::etag_matches( $etag, \wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ?? '' ) ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only compared. + \status_header( 304 ); + exit; + } + + echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped by the template. + exit; + } + + /** + * Whether an If-None-Match header matches an ETag. + * + * The weak comparison of RFC 9110, section 13.1.2: a `W/` in front + * of a tag is ignored. + * + * @param string $etag ETag of the response. + * @param string $header If-None-Match request header. + * @return bool True if the reader has the current version. + */ + public static function etag_matches( $etag, $header ) { + $header = \trim( (string) $header ); + if ( '*' === $header ) { + return true; + } + + foreach ( \explode( ',', $header ) as $candidate ) { + $candidate = \trim( $candidate ); + if ( 0 === \strpos( $candidate, 'W/' ) ) { + $candidate = \substr( $candidate, 2 ); + } + if ( '' !== $candidate && $candidate === $etag ) { + return true; + } + } + + return false; + } + /** * The OPML URL of a post, or of one of its blogrolls. * diff --git a/templates/opml-following.php b/templates/opml-following.php index e3f6a6e..3fc6985 100644 --- a/templates/opml-following.php +++ b/templates/opml-following.php @@ -7,7 +7,7 @@ * @var array $args { * Template arguments. * - * @type \WP_Post[] $posts Posts that contain a blogroll block. + * @type \WP_Post[] $posts Posts with a blogroll that adds a feed to the list. * @type array $links Normalized links with a feed, each feed once. * } */ diff --git a/tests/bootstrap.php b/tests/bootstrap.php index d003d69..2b7a43a 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -24,6 +24,7 @@ * Manually load the plugin being tested. */ function _manually_load_plugin() { + require __DIR__ . '/stubs/class-socialspec.php'; require __DIR__ . '/../blockroll.php'; } \tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' ); diff --git a/tests/stubs/class-socialspec.php b/tests/stubs/class-socialspec.php new file mode 100644 index 0000000..192de5f --- /dev/null +++ b/tests/stubs/class-socialspec.php @@ -0,0 +1,29 @@ +set_permalink_structure( '/%postname%/' ); // Rules stored by a version without the following list. $rules = get_option( 'rewrite_rules' ); - unset( $rules['^\.well-known/following\.opml$'] ); + unset( $rules[ $following ] ); update_option( 'rewrite_rules', $rules ); \Blockroll\Opml::flush_stale_rewrite_rules(); $rules = get_option( 'rewrite_rules' ); - $this->assertSame( 'index.php?opml=following', $rules['^\.well-known/following\.opml$'] ); - $this->assertSame( 'index.php?opml=directory', $rules['^\.well-known/recommendations\.opml$'] ); + $this->assertSame( 'index.php?opml=following', $rules[ $following ] ); + $this->assertSame( 'index.php?opml=directory', $rules[ '^' . preg_quote( \Blockroll\Opml::RECOMMENDATIONS_PATH, '#' ) . '$' ] ); $this->set_permalink_structure( '' ); } + public function test_socialspec_following_takes_over_the_rule() { + global $wp_rewrite; + $following = '^' . preg_quote( \Blockroll\Opml::FOLLOWING_PATH, '#' ) . '$'; + $this->set_permalink_structure( '/%postname%/' ); + + // SocialSpec gets activated: the next request adds no rule of its own. + \Socialspec\Socialspec::$documents = array( 'following.opml' => true ); + unset( $wp_rewrite->extra_rules_top[ $following ] ); + + \Blockroll\Opml::flush_stale_rewrite_rules(); + $this->assertArrayNotHasKey( $following, get_option( 'rewrite_rules' ) ); + + \Socialspec\Socialspec::$documents = array(); + \Blockroll\Opml::add_rewrite_rules(); + $this->set_permalink_structure( '' ); + } + + public function test_following_date_modified_ignores_pages_without_feeds() { + $with_feed = self::factory()->post->create( + array( + 'post_content' => self::BLOCK, + 'post_date' => '2026-01-01 00:00:00', + ) + ); + self::factory()->post->create( + array( + 'post_content' => '', + 'post_date' => '2026-06-01 00:00:00', + ) + ); + ob_start(); + \Blockroll\Opml::following(); + $doc = new SimpleXMLElement( ob_get_clean() ); + $this->assertSame( get_post_modified_time( 'r', true, $with_feed ), (string) $doc->head->dateModified ); + } + + public function test_etag_matches_weak_and_listed_tags() { + $etag = '"abc"'; + $this->assertTrue( \Blockroll\Opml::etag_matches( $etag, '"abc"' ) ); + $this->assertTrue( \Blockroll\Opml::etag_matches( $etag, 'W/"abc"' ) ); + $this->assertTrue( \Blockroll\Opml::etag_matches( $etag, '"x", "abc"' ) ); + $this->assertTrue( \Blockroll\Opml::etag_matches( $etag, '*' ) ); + $this->assertFalse( \Blockroll\Opml::etag_matches( $etag, '"x"' ) ); + $this->assertFalse( \Blockroll\Opml::etag_matches( $etag, '' ) ); + } + public function test_well_known_lists_without_blogrolls_are_empty_opml() { foreach ( array( 'directory', 'following' ) as $method ) { ob_start(); From 99e2921403cd92f1347cd3596468648eb83203dc Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 15 Oct 2026 13:18:12 +0200 Subject: [PATCH 6/7] Detect SocialSpec by its version constant --- includes/class-opml.php | 20 +++----------------- tests/bootstrap.php | 1 - tests/stubs/class-socialspec.php | 29 ----------------------------- tests/test-opml.php | 17 ----------------- 4 files changed, 3 insertions(+), 64 deletions(-) delete mode 100644 tests/stubs/class-socialspec.php diff --git a/includes/class-opml.php b/includes/class-opml.php index a4ba0ac..39cbe4c 100644 --- a/includes/class-opml.php +++ b/includes/class-opml.php @@ -123,8 +123,9 @@ private static function well_known_rules() { foreach ( $well_known as $path => $value ) { // WordPress matches rewrite rules with "#" as the delimiter. $regex = \sprintf( '^%s$', \preg_quote( $path, '#' ) ); - // With SocialSpec, its following.opml lists the blogroll feeds. - $rules[ $regex ] = ( self::FOLLOWING === $value && self::has_socialspec_following() ) + // With the SocialSpec plugin (https://socialspec.org/), its + // following.opml lists the blogroll feeds. + $rules[ $regex ] = ( self::FOLLOWING === $value && \defined( 'SOCIALSPEC_VERSION' ) ) ? null : \sprintf( 'index.php?%s=%s', self::QUERY_VAR, $value ); } @@ -514,21 +515,6 @@ public static function following_links( $posts = null ) { return \array_values( $links ); } - /** - * Whether the SocialSpec plugin serves /.well-known/following.opml. - * - * The blogroll feeds then go into its list, and this plugin serves - * no file of its own there. - * - * See https://socialspec.org/ - * - * @return bool True if SocialSpec has a following.opml. - */ - private static function has_socialspec_following() { - return \class_exists( 'Socialspec\Socialspec' ) - && isset( \Socialspec\Socialspec::get_documents()['following.opml'] ); - } - /** * Add the blogroll feeds to the following list of SocialSpec. * diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 2b7a43a..d003d69 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -24,7 +24,6 @@ * Manually load the plugin being tested. */ function _manually_load_plugin() { - require __DIR__ . '/stubs/class-socialspec.php'; require __DIR__ . '/../blockroll.php'; } \tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' ); diff --git a/tests/stubs/class-socialspec.php b/tests/stubs/class-socialspec.php deleted file mode 100644 index 192de5f..0000000 --- a/tests/stubs/class-socialspec.php +++ /dev/null @@ -1,29 +0,0 @@ -set_permalink_structure( '' ); } - public function test_socialspec_following_takes_over_the_rule() { - global $wp_rewrite; - $following = '^' . preg_quote( \Blockroll\Opml::FOLLOWING_PATH, '#' ) . '$'; - $this->set_permalink_structure( '/%postname%/' ); - - // SocialSpec gets activated: the next request adds no rule of its own. - \Socialspec\Socialspec::$documents = array( 'following.opml' => true ); - unset( $wp_rewrite->extra_rules_top[ $following ] ); - - \Blockroll\Opml::flush_stale_rewrite_rules(); - $this->assertArrayNotHasKey( $following, get_option( 'rewrite_rules' ) ); - - \Socialspec\Socialspec::$documents = array(); - \Blockroll\Opml::add_rewrite_rules(); - $this->set_permalink_structure( '' ); - } - public function test_following_date_modified_ignores_pages_without_feeds() { $with_feed = self::factory()->post->create( array( From f0b0743519c8dd3373c47dbddf1d5caf0e30c8fd Mon Sep 17 00:00:00 2001 From: Matthias Pfefferle Date: Thu, 15 Oct 2026 13:18:12 +0200 Subject: [PATCH 7/7] Add FollowSpec to the changelog --- readme.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/readme.md b/readme.md index c72be90..fb78204 100644 --- a/readme.md +++ b/readme.md @@ -135,6 +135,11 @@ routes are described in the Project and support maintained on github at [pfefferle/wordpress-blockroll](https://github.com/pfefferle/wordpress-blockroll). +### Unreleased + +* Apps that show who you follow can now find the feeds of your blogrolls at `/.well-known/following.opml`, as described in [FollowSpec](https://followspec.org/). If the SocialSpec plugin is active, the feeds are added to its list instead. See #47. +* Without any blogroll, `/.well-known/recommendations.opml` now answers with an empty list instead of the home page. + ### 2.1.1 * The site-wide OPML index now lists each listed blogroll separately, with its own name and subscription address, instead of combining all lists on a page into one entry. Unlisted blogrolls stay out of the index.