diff --git a/composer.json b/composer.json index 838f3e4..a3f808a 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ }, "require-dev": { "danielescherzer/common-phpcs": "0.0.2", + "laminas/laminas-feed": "2.26.1", "phpunit/phpunit": "~12.0", "php-parallel-lint/php-parallel-lint": "^1.4", "samdark/sitemap": "2.4.1" diff --git a/sitemap.xml b/sitemap.xml index a84fd49..515c2d8 100644 --- a/sitemap.xml +++ b/sitemap.xml @@ -93,4 +93,7 @@ https://scherzer.dev/Blog/20260510-release-manager-notes-2 + + https://scherzer.dev/Blog/20260517-blog-rss + diff --git a/src/Blog/feed-rss.xml b/src/Blog/feed-rss.xml new file mode 100644 index 0000000..e291c2f --- /dev/null +++ b/src/Blog/feed-rss.xml @@ -0,0 +1,135 @@ + + + + Daniel Scherzer's Blog + Entries from Daniel Scherzer' personal blog + Sun, 17 May 2026 00:00:00 +0000 + https://github.com/DanielEScherzer/website-content + https://scherzer.dev/Blog + + Blog RSS Feed + Sun, 17 May 2026 00:00:00 +0000 + https://scherzer.dev/Blog/20260517-blog-rss + + + Notes for PHP Release Managers, Part 2 + Sun, 10 May 2026 00:00:00 +0000 + https://scherzer.dev/Blog/20260510-release-manager-notes-2 + + + The Story of PHP 8.5.6 Release Candidate 3 + Thu, 30 Apr 2026 00:00:00 +0000 + https://scherzer.dev/Blog/20260430-php856-rc-3 + + + PHP 8.6 Release Manager + Thu, 16 Apr 2026 00:00:00 +0000 + https://scherzer.dev/Blog/20260416-php86-release-manager + + + Introducing define_deprecated() for PHP + Fri, 10 Apr 2026 00:00:00 +0000 + https://scherzer.dev/Blog/20260410-define-deprecated + + + Friends in PHP + Mon, 09 Mar 2026 00:00:00 +0000 + https://scherzer.dev/Blog/20260309-php-friends + + + ConFoo 2026 + Mon, 02 Mar 2026 00:00:00 +0000 + https://scherzer.dev/Blog/20260302-confoo + + + Notes for PHP Release Managers + Wed, 18 Feb 2026 00:00:00 +0000 + https://scherzer.dev/Blog/20260218-release-manager-notes + + + This Year in PHP (2025) + Tue, 20 Jan 2026 00:00:00 +0000 + https://scherzer.dev/Blog/20260120-php-2025 + + + Project Euler in Rust, Part 2 + Mon, 01 Dec 2025 00:00:00 +0000 + https://scherzer.dev/Blog/20251201-rust-euler-2 + + + Unplanned Downtime, November 2025 + Tue, 25 Nov 2025 00:00:00 +0000 + https://scherzer.dev/Blog/20251125-unplanned-downtime + + + MergePHP, November 2025 + Tue, 18 Nov 2025 00:00:00 +0000 + https://scherzer.dev/Blog/20251118-mergephp-talk + + + The Story of PHP 8.5.0 Release Candidate 5 + Thu, 13 Nov 2025 00:00:00 +0000 + https://scherzer.dev/Blog/20251113-release-candidate-5 + + + Longhorn PHP 2025 + Wed, 29 Oct 2025 00:00:00 +0000 + https://scherzer.dev/Blog/20251029-longhorn-php + + + Project Euler in Rust + Fri, 19 Sep 2025 00:00:00 +0000 + https://scherzer.dev/Blog/20250919-rust-euler + + + "Hello, World!" in Rust + Fri, 12 Sep 2025 00:00:00 +0000 + https://scherzer.dev/Blog/20250912-hello-world-rust + + + First Rust Patch + Sat, 06 Sep 2025 00:00:00 +0000 + https://scherzer.dev/Blog/20250906-mago-rust + + + Out-of-Band Signaling + Fri, 29 Aug 2025 00:00:00 +0000 + https://scherzer.dev/Blog/20250829-out-of-band + + + #[\DelayedTargetValidation] Attribute Explained + Wed, 20 Aug 2025 00:00:00 +0000 + https://scherzer.dev/Blog/20250820-delayed-target-validation + + + No PHP 8.5.0alpha3 + Fri, 01 Aug 2025 00:00:00 +0000 + https://scherzer.dev/Blog/20250801-no-alpha-3 + + + Pygments Syntax Highlighting for Markdown + Fri, 18 Jul 2025 00:00:00 +0000 + https://scherzer.dev/Blog/20250718-pygments-highlighting + + + A Tale of Two RFCs + Sun, 22 Jun 2025 00:00:00 +0000 + https://scherzer.dev/Blog/20250622-two-rfcs + + + Attributes on Constants + Tue, 29 Apr 2025 00:00:00 +0000 + https://scherzer.dev/Blog/20250429-attributes-on-constants + + + PHP 8.5 Release Manager + Thu, 17 Apr 2025 00:00:00 +0000 + https://scherzer.dev/Blog/20250417-php85-release-manager + + + Website Launch + Wed, 09 Apr 2025 00:00:00 +0000 + https://scherzer.dev/Blog/20250409-website-launch + + + diff --git a/src/Blog/posts/20260517-blog-rss.md b/src/Blog/posts/20260517-blog-rss.md new file mode 100644 index 0000000..c30e6ac --- /dev/null +++ b/src/Blog/posts/20260517-blog-rss.md @@ -0,0 +1,95 @@ +--- +title: Blog RSS Feed +extensions: + pygments: true +--- + +# Blog RSS Feed + +Last month, I received [a request][rss-req] to add an RSS feed to my blog. I am +happy to share that I have added such a feed, available at +[/Blog/feed/rss.xml][rss-link]. + +RSS (short for "Really Simple Syndication") is a web feed specification built on +top of XML. Rather than generating the XML entirely from scratch (potentially +using the PHP dom extension), I opted to use an open source library, +[laminas/laminas-feed][pgk-laminas], to create the XML content. While the +library certainly helped, I ran into a few challenges along the way. + +## Laminas package + +The first was the fact that the library unconditionally enables a "Slash" +extension which adds some extra elements to the output. Rather than manually +removing those elements from the resulting XML, I wanted to work within the +constraints of the library API. The result? A custom extension manager that +behaves differently based on the backtrace. The Laminas package still believes +that the extension exists, but the slash extension is skipped during +registration. + +```php startinline=True +$old = Writer::getExtensionManager(); +$manager = new class( $old ) extends ExtensionManager { + public function has( $entryName ) { + if ( $entryName === 'Slash\Renderer\Entry' ) { + // Needs to return true when checking if the extension + // exists, otherwise Laminas throws an exception + $bt = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 2 ); + if ( + $bt[1]['class'] === Writer::class + && $bt[1]['function'] === 'hasExtension' + ) { + return true; + } + return false; + } + return parent::has( $entryName ); + } +}; +Writer::setExtensionManager( $manager ); +``` + +While I'm not the most pleased with this approach, it does work, and it presents +a great example for my talk at PHPTek next week about what is and is not part of +the public API of PHP libraries. + +Another issue that I encountered was the unconditional addition of `` tags +in the output - for these, I just removed them from the XML result with a +post-processing step. + +Of course, I could have used another library, or forked the Laminas package, +but the Laminas package seemed like the easiest to use. + +## Forward compatibility + +I had initially planned for the RSS feed to live at `.../Blog/rss.xml`, but +realized that I might want to support other feed formats (e.g. Atom) in the +future. To keep all feeds organized together, I put the RSS feed at +`.../Blog/feed/rss.xml`, allowing other feeds to be added more easily. If you +visit the feed page without specifying a feed format ([/Blog/feed][feeds-link]), +a list of available feeds is shown; it is also shown when an unknown feed is +requested. + +## Caching + +Rather than regenerating the RSS feed on every request, I generate it ahead of +time and store the resulting XML in my website's git repository. At runtime, the +cached file is simply read from disk. Tests exist to ensure that the cached +version remains up-to-date. + +Thinking about this further, the same approach could be taken to every other +known page on my website. Other than HTTP 404 and 405 pages that depend on +what path or method were used, the rest of my pages are static and could be +similarly cached. If performance ever becomes a concern, I may extend the same +approach to the rest of the site. + +## Field testing + +This blog post was published at the same time the RSS feed was created, so it +does not serve as a test of the feed working properly. After PHPTek next week, +I plan to write up another post about my experiences there, which will serve as +a great opportunity to confirm that the RSS feed behaves as expected. + +[feeds-link]: /Blog/feed +[pgk-laminas]: https://packagist.org/packages/laminas/laminas-feed +[rss-link]: /Blog/feed/rss.xml +[rss-req]: https://github.com/DanielEScherzer/website-content/issues/79 diff --git a/src/Pages/BlogFeedPage.php b/src/Pages/BlogFeedPage.php new file mode 100644 index 0000000..ca048ad --- /dev/null +++ b/src/Pages/BlogFeedPage.php @@ -0,0 +1,86 @@ +feedName = $params['feed'] ?? null; + $this->head->append( + FluentHTML::fromTag( 'title' )->addChild( 'Blog feeds' ) + ); + } + + public function getResponse(): WebResponse { + if ( $this->feedName === 'rss.xml' ) { + // Bypass BasePage handling and return raw xml + $content = file_get_contents( dirname( __DIR__ ) . '/Blog/feed-rss.xml' ); + return new WebResponse( + $content, + [ 'Content-Type: application/xml' ], + 200 + ); + } + return parent::getResponse(); + } + + protected function build(): void { + if ( $this->feedName === 'rss.xml' ) { + return; + } + if ( $this->feedName !== null ) { + $this->addStyleSheet( 'error-styles.css' ); + $feed = $this->feedName; + $this->contentWrapper->append( + FluentHTML::make( + 'div', + [ 'class' => 'error-box' ], + [ + FluentHTML::make( 'h1', [], 'Error' ), + FluentHTML::make( + 'p', + [], + "The requested blog feed `$feed` is not recognized" + ), + ] + ) + ); + $this->setResponseCode( 404 ); + } + $this->contentWrapper->append( + FluentHTML::make( 'h1', [], 'Known feed types' ), + FluentHTML::make( + 'ul', + [], + [ + FluentHTML::make( + 'li', + [], + FluentHTML::make( + 'a', + [ 'href' => '/Blog' ], + 'HTML index of blog posts' + ) + ), + FluentHTML::make( + 'li', + [], + FluentHTML::make( + 'a', + [ 'href' => '/Blog/feed/rss.xml' ], + 'RSS feed of blog posts' + ) + ), + ] + ) + ); + } + +} diff --git a/src/Router.php b/src/Router.php index 8ee7570..57703e6 100644 --- a/src/Router.php +++ b/src/Router.php @@ -4,6 +4,7 @@ namespace DanielWebsite; use DanielWebsite\Pages\AbstractPage; +use DanielWebsite\Pages\BlogFeedPage; use DanielWebsite\Pages\BlogIndexPage; use DanielWebsite\Pages\BlogPostPage; use DanielWebsite\Pages\Error404Page; @@ -68,6 +69,8 @@ public static function addRoutesCb( RouteCollector $r ): void { $r->addRoute( 'GET', 'Thesis', ThesisPage::class ); $r->addRoute( 'GET', 'Work', WorkPage::class ); $r->addRoute( 'GET', 'Blog', BlogIndexPage::class ); + $r->addRoute( 'GET', 'Blog/feed', BlogFeedPage::class ); + $r->addRoute( 'GET', 'Blog/feed/{feed}', BlogFeedPage::class ); $r->addRoute( 'GET', 'Blog/{slug}', BlogPostPage::class ); $r->addRoute( 'GET', 'Tools', ToolPage::class ); $r->addRoute( 'GET', 'Tools/{tool}', ToolPage::class ); diff --git a/tests/Blog/BlogFeedGeneratorTest.php b/tests/Blog/BlogFeedGeneratorTest.php new file mode 100644 index 0000000..afd08f9 --- /dev/null +++ b/tests/Blog/BlogFeedGeneratorTest.php @@ -0,0 +1,93 @@ +setTitle( 'Daniel Scherzer\'s Blog' ); + $feed->setDescription( 'Entries from Daniel Scherzer\' personal blog' ); + $feed->setLink( self::URL_BASE . '/Blog' ); + $feed->setGenerator( 'https://github.com/DanielEScherzer/website-content' ); + + $store = new BlogPostStore(); + $posts = $store->listBlogPosts(); + + $feed->setDateModified( $posts[0]->date ); + + // Only parse the YAML frontmatter + $frontMatterExt = new FrontMatterExtension(); + $parser = $frontMatterExt->getFrontMatterParser(); + + foreach ( $posts as $post ) { + $cfg = $parser->parse( $post->markdown )->getFrontMatter(); + $post->setConfig( $cfg ?? [] ); + + $entry = $feed->createEntry(); + $entry->setTitle( $post->getTitle() ); + $entry->setLink( self::URL_BASE . '/Blog/' . $post->slug ); + $entry->setDateCreated( $post->date ); + + $feed->addEntry( $entry ); + } + + return $feed; + } + + public function testRSSFeed() { + // There doesn't seem to be an easy way to disable the Slash extension + $old = Writer::getExtensionManager(); + $manager = new class( $old ) extends ExtensionManager { + public function has( $entryName ) { + if ( $entryName === 'Slash\Renderer\Entry' ) { + // Needs to return true when checking if the extension + // exists, otherwise Laminas throws an exception + $bt = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 2 ); + if ( + $bt[1]['class'] === Writer::class + && $bt[1]['function'] === 'hasExtension' + ) { + return true; + } + return false; + } + return parent::has( $entryName ); + } + }; + Writer::setExtensionManager( $manager ); + + $expected = $this->generateFeed()->export( 'rss' ); + // Easier than trying to reimplement the whole chain to remove the guid + $expected = preg_replace( "/\n\s+\S+?<\/guid>/", '', $expected ); + + $actualFeedPath = dirname( __DIR__, 2 ) . '/src/Blog/feed-rss.xml'; + if ( getenv( 'TESTS_UPDATE_EXPECTED' ) === '1' ) { + file_put_contents( $actualFeedPath, $expected ); + } + $this->assertStringEqualsFile( $actualFeedPath, $expected ); + } + +} diff --git a/tests/data/Home.html b/tests/data/Home.html index 66af69b..cc4974c 100644 --- a/tests/data/Home.html +++ b/tests/data/Home.html @@ -7,7 +7,6 @@ a release manager for PHP 8.5 (where I am one of two "rookie" release managers) and for PHP 8.6 (where I am the "veteran" release manager). I also contribute to PHP as a developer, and as the maintainer of the built-in Reflection extension.

See the links in the navigation bar above for more information about my -experience.

Contact

Blog

I also have a blog. You can see a full index of my posts here. My latest blog post is:

Notes for PHP Release Managers, Part 2

Sunday, 10 May 2026

A few months ago, I wrote up a blog post about my experience -being a PHP release manager and the kind of commitment that the role requires. -Now, I want to document some of the decision-making and process related to the -latest release that I oversaw, PHP 8.5.6. Continue reading...

\ No newline at end of file +experience.

Contact

Blog

I also have a blog. You can see a full index of my posts here. My latest blog post is:

Blog RSS Feed

Sunday, 17 May 2026

Last month, I received a request to add an RSS feed to my blog. I am +happy to share that I have added such a feed, available at +/Blog/feed/rss.xml. Continue reading...

\ No newline at end of file diff --git a/tests/data/blog-index.html b/tests/data/blog-index.html index 1b3b4c2..60a9d17 100644 --- a/tests/data/blog-index.html +++ b/tests/data/blog-index.html @@ -1,5 +1,7 @@ -Blog index

Blog index

Notes for PHP Release Managers, Part 2

Sunday, 10 May 2026

A few months ago, I wrote up a blog post about my experience +Blog index

Blog index

Blog RSS Feed

Sunday, 17 May 2026

Last month, I received a request to add an RSS feed to my blog. I am +happy to share that I have added such a feed, available at +/Blog/feed/rss.xml. Continue reading...

Notes for PHP Release Managers, Part 2

Sunday, 10 May 2026

A few months ago, I wrote up a blog post about my experience being a PHP release manager and the kind of commitment that the role requires. Now, I want to document some of the decision-making and process related to the latest release that I oversaw, PHP 8.5.6. Continue reading...

The Story of PHP 8.5.6 Release Candidate 3

Thursday, 30 April 2026

I have previously had to skip a non-stable release