diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 777db69..6177b4d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,7 +7,7 @@ on:
pull_request:
env:
- PHP_VERSION: 8.3
+ PHP_VERSION: 8.5
jobs:
lint:
diff --git a/Dockerfile b/Dockerfile
index a175733..f09eeb7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1
-FROM php:8.3.19-apache
+FROM php:8.5.10-apache
# Add git
RUN apt-get -y update
@@ -10,7 +10,7 @@ RUN apt-get install -y libzip-dev zip
RUN docker-php-ext-install zip && docker-php-ext-enable zip
# Add composer
-COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
+COPY --from=composer:2.10.3 /usr/bin/composer /usr/local/bin/composer
# For test coverage
RUN pecl install pcov
diff --git a/composer.json b/composer.json
index 547d0df..489ed27 100644
--- a/composer.json
+++ b/composer.json
@@ -3,7 +3,7 @@
"danielescherzer/commonmark-ext-pygments-highlighter": "0.2.0",
"danielescherzer/html-builder": "0.0.2",
"nikic/fast-route": "1.3.0",
- "league/commonmark": "2.10.1",
+ "league/commonmark": "2.10.3",
"ramsey/pygments": "3.0.0",
"symfony/yaml": "7.4.12"
},
diff --git a/resources/blog-styles.css b/resources/blog-styles.css
index f8b18c1..9cacadd 100644
--- a/resources/blog-styles.css
+++ b/resources/blog-styles.css
@@ -1,3 +1,15 @@
+.blog-index {
+ h1 {
+ display: inline-block;
+ }
+ .blog-index--clear-filter {
+ display: inline-block;
+ font-size: 20px;
+ margin-block-end: 0.67em;
+ padding-left: 20px;
+ }
+}
+
.blog-preview-date {
font-style: italic;
}
@@ -14,56 +26,89 @@
> *:last-child {
margin-bottom: 0px;
}
-}
-.blog-content h1,
-.blog-preview h2,
-.blog-preview h3 {
- margin-bottom: 0px;
+ h2, h3 {
+ margin-bottom: 0px;
+ }
}
-.blog-content img {
- width: 50%;
- display: block;
- margin: auto;
-}
+.blog-preview--has-tags {
+ columns: 2;
-.blog-page--sidebar-image p:has(img) {
- width: 40%;
- float: right;
-}
+ h2, h3 {
+ break-after: avoid-column;
+ width: max-content;
+ }
-.blog-page--sidebar-image img {
- width: fit-content;
-}
+ .blog-tags {
+ justify-self: right;
+ padding-top: 5px;
+ break-before: column;
+ }
-.blog-content .pygments-highlighter pre {
- background-color: #f8f8f8;
- overflow-x: auto;
-}
+ .blog-tags--pill:last-child {
+ margin-right: 0px;
+ }
-.blog-content .pygments-highlighter-failed {
- /* Accessibility: `red` does not provide enough contrast */
- color: #ef0000;
+ p {
+ column-span: all;
+ }
}
-.blog-content .footnotes {
- p {
- font-size: smaller;
- display: inline;
+.blog-content {
+ code {
+ white-space: nowrap;
}
- ol {
- padding-inline-start: 0px;
+ h1 {
+ margin-bottom: 0px;
}
- li {
- list-style-position: inside;
+ img {
+ width: 50%;
+ display: block;
+ margin: auto;
+ }
+
+ .blog-tags {
+ padding-top: 20px;
+ }
+
+ .footnotes {
+ li {
+ list-style-position: inside;
+ }
+
+ ol {
+ padding-inline-start: 0px;
+ }
+
+ p {
+ font-size: smaller;
+ display: inline;
+ }
+ }
+
+ .pygments-highlighter pre {
+ background-color: #f8f8f8;
+ overflow-x: auto;
+ }
+
+ .pygments-highlighter-failed {
+ /* Accessibility: `red` does not provide enough contrast */
+ color: #ef0000;
}
}
-.blog-content code {
- white-space: nowrap;
+.blog-page--sidebar-image {
+ img {
+ width: fit-content;
+ }
+
+ p:has(img) {
+ width: 40%;
+ float: right;
+ }
}
.blog-page--unhighlighted-block pre:not(.pygments-highlighter pre) {
@@ -101,6 +146,56 @@
}
}
+.blog-tags {
+ font-size: 18px;
+}
+
+.blog-tags--list {
+ display: inline;
+ padding-inline-start: 0px;
+}
+
+.blog-tags--pill {
+ height: 1.5rem;
+ border-radius: 0.75rem;
+ display: inline;
+ padding: 5px 10px 5px 10px;
+ margin: 5px;
+
+ a {
+ text-decoration: none;
+ }
+
+ &.blog-tags--pill-conferences {
+ background-color: #53fd89;
+ a {
+ color: #000;
+ }
+ }
+
+ &.blog-tags--pill-website {
+ background-color: #ffb900;
+ a {
+ color: #000;
+ }
+ }
+
+ &.blog-tags--pill-php,
+ &[class*=" blog-tags--pill-php-"] {
+ background-color: #474A8A;
+ a {
+ color: #fff;
+ }
+ }
+
+ &.blog-tags--pill-rust {
+ background-color: #D34516;
+ a {
+ color: #fff;
+ }
+ }
+}
+
@media (max-width: 1200px) {
.blog-page--has-toc.content-wrapper {
grid-template-columns: 20% 70% 10%;
@@ -117,12 +212,30 @@
top: unset;
margin-top: 20px;
}
- .blog-page--sidebar-image p:has(img) {
- float: unset;
- margin: auto;
- max-width: 100%;
+ .blog-page--sidebar-image {
+ img {
+ max-width: 100%;
+ }
+ p:has(img) {
+ float: unset;
+ margin: auto;
+ max-width: 100%;
+ }
}
- .blog-page--sidebar-image img {
- max-width: 100%;
+ .blog-preview--has-tags {
+ columns: unset;
+
+ p {
+ margin-top: 10px;
+ }
+
+ .blog-tags {
+ justify-self: unset;
+ }
+
+ .blog-tags--pill:first-child {
+ margin-left: 0px;
+ }
}
+
}
diff --git a/resources/error-styles.css b/resources/error-styles.css
index fed546d..508063e 100644
--- a/resources/error-styles.css
+++ b/resources/error-styles.css
@@ -1,6 +1,5 @@
-.error-box {
- background-color: #fcdad4;
- border: 3px solid #fc9685;
+.error-box,
+.warning-box {
padding: 10px;
margin-top: 20px;
@@ -9,6 +8,16 @@
max-width: 60%;
}
-h1 {
- margin-top: 0px;
-}
\ No newline at end of file
+.error-box {
+ background-color: #fcdad4;
+ border: 3px solid #fc9685;
+
+ h1 {
+ margin-top: 0px;
+ }
+}
+
+.warning-box {
+ background-color: #fae17a;
+ border: 3px solid #c9a202;
+}
diff --git a/sitemap.xml b/sitemap.xml
index 1919e00..f0137b8 100644
--- a/sitemap.xml
+++ b/sitemap.xml
@@ -111,4 +111,7 @@
https://scherzer.dev/Blog/20260923-no-php86-rc-1
+
+ https://scherzer.dev/Blog/20261005-blog-tags
+
diff --git a/src/Blog/BlogPost.php b/src/Blog/BlogPost.php
index 03b51f3..d126506 100644
--- a/src/Blog/BlogPost.php
+++ b/src/Blog/BlogPost.php
@@ -29,6 +29,10 @@ public function getTitle(): string {
return $this->config['title'] ?? $this->slug;
}
+ public function getTags(): array {
+ return $this->config['tags'] ?? [];
+ }
+
public function getExtraClasses(): array {
if ( $this->config !== null ) {
$extraClasses = $this->config['extra-classes'] ?? [];
diff --git a/src/Blog/BlogTags.php b/src/Blog/BlogTags.php
new file mode 100644
index 0000000..9c6dca4
--- /dev/null
+++ b/src/Blog/BlogTags.php
@@ -0,0 +1,89 @@
+ 'conferences and other events',
+ 'php' => [
+ 'description' => 'programming with PHP',
+ 'display' => 'PHP',
+ ],
+ 'php-rm' => [
+ 'description' => 'being a PHP release manager',
+ 'display' => 'PHP-RM',
+ ],
+ 'php-rm-85' => [
+ 'description' => 'being a PHP 8.5 rookie release manager',
+ 'display' => 'PHP-RM-8.5',
+ ],
+ 'php-rm-86' => [
+ 'description' => 'being the PHP 8.6 veteran release manager',
+ 'display' => 'PHP-RM-8.6',
+ ],
+ 'php-dev' => [
+ 'description' => 'development of PHP',
+ 'display' => 'PHP-dev',
+ ],
+ 'rust' => 'programming with Rust',
+ 'website' => 'my website',
+ ];
+
+ public static function isKnown( string $tag ): bool {
+ return array_key_exists( $tag, self::KNOWN_TAGS );
+ }
+
+ public static function getDescription( string $tag ): string {
+ if ( !self::isKnown( $tag ) ) {
+ throw new InvalidArgumentException( "Unknown tag: $tag" );
+ }
+ $info = self::KNOWN_TAGS[$tag];
+ if ( is_string( $info ) ) {
+ return $info;
+ }
+ return $info[ 'description' ];
+ }
+
+ private static function getPillForTag( string $tag ): FluentHTML {
+ if ( !self::isKnown( $tag ) ) {
+ throw new InvalidArgumentException( "Unknown tag: $tag" );
+ }
+ $info = self::KNOWN_TAGS[$tag];
+ if ( is_string( $info ) ) {
+ $description = $info;
+ $display = ucfirst( $tag );
+ } else {
+ $description = $info[ 'description' ];
+ $display = $info[ 'display' ];
+ }
+
+ return FluentHTML::make(
+ 'li',
+ [ 'class' => [ 'blog-tags--pill', "blog-tags--pill-$tag" ] ],
+ FluentHTML::make(
+ 'a',
+ [
+ 'href' => "/Blog?tag=$tag",
+ 'title' => "Blog posts about $description",
+ ],
+ "#$display",
+ )
+ );
+ }
+
+ public static function getListForTags( array $tags ): FluentHTML {
+ return FluentHTML::make(
+ 'ul',
+ [ 'class' => 'blog-tags--list' ],
+ array_map( self::getPillForTag( ... ), $tags ),
+ );
+ }
+
+}
diff --git a/src/Blog/feed-rss.xml b/src/Blog/feed-rss.xml
index acba096..4e42dc9 100644
--- a/src/Blog/feed-rss.xml
+++ b/src/Blog/feed-rss.xml
@@ -3,9 +3,14 @@
Daniel Scherzer's Blog
Entries from Daniel Scherzer' personal blog
- Wed, 23 Sep 2026 00:00:00 +0000
+ Mon, 05 Oct 2026 00:00:00 +0000
https://github.com/DanielEScherzer/website-content
https://scherzer.dev/Blog
+ -
+
Blog Tags
+ Mon, 05 Oct 2026 00:00:00 +0000
+ https://scherzer.dev/Blog/20261005-blog-tags
+
-
No PHP 8.6.0RC1
Wed, 23 Sep 2026 00:00:00 +0000
diff --git a/src/Blog/posts/20250409-website-launch.md b/src/Blog/posts/20250409-website-launch.md
index ad9197a..a8a7cf2 100644
--- a/src/Blog/posts/20250409-website-launch.md
+++ b/src/Blog/posts/20250409-website-launch.md
@@ -1,5 +1,6 @@
---
title: Website Launch
+tags: ["website"]
---
# Website launch
diff --git a/src/Blog/posts/20250417-php85-release-manager.md b/src/Blog/posts/20250417-php85-release-manager.md
index 18b0842..3ee7fd1 100644
--- a/src/Blog/posts/20250417-php85-release-manager.md
+++ b/src/Blog/posts/20250417-php85-release-manager.md
@@ -1,5 +1,6 @@
---
title: PHP 8.5 Release Manager
+tags: ["php", "php-rm", "php-rm-85"]
---
# PHP 8.5 Release Manager
diff --git a/src/Blog/posts/20250429-attributes-on-constants.md b/src/Blog/posts/20250429-attributes-on-constants.md
index 76394de..7524380 100644
--- a/src/Blog/posts/20250429-attributes-on-constants.md
+++ b/src/Blog/posts/20250429-attributes-on-constants.md
@@ -3,6 +3,7 @@ extensions:
pygments: true
toc: true
title: Attributes on Constants
+tags: ["php", "php-dev"]
---
# Attributes on constants
diff --git a/src/Blog/posts/20250622-two-rfcs.md b/src/Blog/posts/20250622-two-rfcs.md
index eb010d7..70f062d 100644
--- a/src/Blog/posts/20250622-two-rfcs.md
+++ b/src/Blog/posts/20250622-two-rfcs.md
@@ -3,6 +3,7 @@ extensions:
pygments: true
toc: true
title: A Tale of Two RFCs
+tags: ["php", "php-dev"]
---
# A Tale of Two RFCs
diff --git a/src/Blog/posts/20250718-pygments-highlighting.md b/src/Blog/posts/20250718-pygments-highlighting.md
index 30a4f7d..99a7580 100644
--- a/src/Blog/posts/20250718-pygments-highlighting.md
+++ b/src/Blog/posts/20250718-pygments-highlighting.md
@@ -4,6 +4,7 @@ extensions:
pygments: true
toc: true
title: Pygments Syntax Highlighting for Markdown
+tags: ["php"]
---
# Pygments Syntax Highlighting for Markdown
diff --git a/src/Blog/posts/20250801-no-alpha-3.md b/src/Blog/posts/20250801-no-alpha-3.md
index a19a6e7..2946a6b 100644
--- a/src/Blog/posts/20250801-no-alpha-3.md
+++ b/src/Blog/posts/20250801-no-alpha-3.md
@@ -2,6 +2,7 @@
title: No PHP 8.5.0alpha3
extra-classes:
- blog-page--sidebar-image
+tags: ["php", "php-rm", "php-rm-85"]
---
# No PHP 8.5.0alpha3
diff --git a/src/Blog/posts/20250820-delayed-target-validation.md b/src/Blog/posts/20250820-delayed-target-validation.md
index a891006..1d62448 100644
--- a/src/Blog/posts/20250820-delayed-target-validation.md
+++ b/src/Blog/posts/20250820-delayed-target-validation.md
@@ -2,6 +2,7 @@
title: "#[\\DelayedTargetValidation] Attribute Explained"
extensions:
pygments: true
+tags: ["php", "php-dev"]
---
# #[\DelayedTargetValidation] Attribute Explained
diff --git a/src/Blog/posts/20250829-out-of-band.md b/src/Blog/posts/20250829-out-of-band.md
index 343601b..283c304 100644
--- a/src/Blog/posts/20250829-out-of-band.md
+++ b/src/Blog/posts/20250829-out-of-band.md
@@ -1,5 +1,6 @@
---
title: "Out-of-Band Signaling"
+tags: ["php", "php-dev"]
---
# Out-of-Band Signaling
diff --git a/src/Blog/posts/20250906-mago-rust.md b/src/Blog/posts/20250906-mago-rust.md
index 4bbc187..46d0bd2 100644
--- a/src/Blog/posts/20250906-mago-rust.md
+++ b/src/Blog/posts/20250906-mago-rust.md
@@ -2,6 +2,7 @@
title: "First Rust Patch"
extensions:
pygments: true
+tags: ["rust"]
---
# First Rust Patch
diff --git a/src/Blog/posts/20250912-hello-world-rust.md b/src/Blog/posts/20250912-hello-world-rust.md
index 33f1e70..435cb03 100644
--- a/src/Blog/posts/20250912-hello-world-rust.md
+++ b/src/Blog/posts/20250912-hello-world-rust.md
@@ -2,6 +2,7 @@
title: "\"Hello, World!\" in Rust"
extensions:
pygments: true
+tags: ["rust"]
---
# "Hello, World!" in Rust
diff --git a/src/Blog/posts/20250919-rust-euler.md b/src/Blog/posts/20250919-rust-euler.md
index 8517992..a64dbd6 100644
--- a/src/Blog/posts/20250919-rust-euler.md
+++ b/src/Blog/posts/20250919-rust-euler.md
@@ -5,6 +5,7 @@ extensions:
extra-classes:
- blog-page--unhighlighted-block
title: Project Euler in Rust
+tags: ["rust"]
---
# Project Euler in Rust
diff --git a/src/Blog/posts/20251029-longhorn-php.md b/src/Blog/posts/20251029-longhorn-php.md
index 9522d43..15b1c4e 100644
--- a/src/Blog/posts/20251029-longhorn-php.md
+++ b/src/Blog/posts/20251029-longhorn-php.md
@@ -2,6 +2,7 @@
extensions:
toc: true
title: Longhorn PHP 2025
+tags: ["conferences", "php"]
---
# Longhorn PHP 2025
diff --git a/src/Blog/posts/20251113-release-candidate-5.md b/src/Blog/posts/20251113-release-candidate-5.md
index b5e59d9..26e7080 100644
--- a/src/Blog/posts/20251113-release-candidate-5.md
+++ b/src/Blog/posts/20251113-release-candidate-5.md
@@ -2,6 +2,7 @@
title: The Story of PHP 8.5.0 Release Candidate 5
extra-classes:
- blog-page--sidebar-image
+tags: ["php", "php-rm", "php-rm-85"]
---
# The Story of PHP 8.5.0 Release Candidate 5
diff --git a/src/Blog/posts/20251118-mergephp-talk.md b/src/Blog/posts/20251118-mergephp-talk.md
index 9ec07ee..72c4ef9 100644
--- a/src/Blog/posts/20251118-mergephp-talk.md
+++ b/src/Blog/posts/20251118-mergephp-talk.md
@@ -1,5 +1,6 @@
---
title: MergePHP, November 2025
+tags: ["conferences", "php"]
---
# MergePHP, November 2025
diff --git a/src/Blog/posts/20251125-unplanned-downtime.md b/src/Blog/posts/20251125-unplanned-downtime.md
index 4c99e79..a1fd3af 100644
--- a/src/Blog/posts/20251125-unplanned-downtime.md
+++ b/src/Blog/posts/20251125-unplanned-downtime.md
@@ -2,6 +2,7 @@
title: Unplanned Downtime, November 2025
extensions:
footnotes: true
+tags: ["website"]
---
# Unplanned Downtime, November 2025
diff --git a/src/Blog/posts/20251201-rust-euler-2.md b/src/Blog/posts/20251201-rust-euler-2.md
index 496fff1..5c6ac0b 100644
--- a/src/Blog/posts/20251201-rust-euler-2.md
+++ b/src/Blog/posts/20251201-rust-euler-2.md
@@ -2,6 +2,7 @@
extensions:
pygments: true
title: Project Euler in Rust, Part 2
+tags: ["rust"]
---
# Project Euler in Rust, Part 2
diff --git a/src/Blog/posts/20260120-php-2025.md b/src/Blog/posts/20260120-php-2025.md
index 4ee7962..474b72c 100644
--- a/src/Blog/posts/20260120-php-2025.md
+++ b/src/Blog/posts/20260120-php-2025.md
@@ -3,6 +3,7 @@ extensions:
pygments: true
toc: true
title: This Year in PHP (2025)
+tags: ["php", "php-dev"]
---
# This Year in PHP (2025)
diff --git a/src/Blog/posts/20260218-release-manager-notes.md b/src/Blog/posts/20260218-release-manager-notes.md
index cc2fa4c..256a558 100644
--- a/src/Blog/posts/20260218-release-manager-notes.md
+++ b/src/Blog/posts/20260218-release-manager-notes.md
@@ -1,5 +1,6 @@
---
title: Notes for PHP Release Managers
+tags: ["php", "php-rm"]
---
# Notes for PHP Release Managers
diff --git a/src/Blog/posts/20260302-confoo.md b/src/Blog/posts/20260302-confoo.md
index 14e7ff5..a5ac96f 100644
--- a/src/Blog/posts/20260302-confoo.md
+++ b/src/Blog/posts/20260302-confoo.md
@@ -2,6 +2,7 @@
extensions:
toc: true
title: ConFoo 2026
+tags: ["conferences", "php", "rust"]
---
# ConFoo 2026
diff --git a/src/Blog/posts/20260309-php-friends.md b/src/Blog/posts/20260309-php-friends.md
index 6a413b1..3253341 100644
--- a/src/Blog/posts/20260309-php-friends.md
+++ b/src/Blog/posts/20260309-php-friends.md
@@ -3,6 +3,7 @@ extensions:
footnotes: true
pygments: true
title: Friends in PHP
+tags: ["php", "php-dev"]
---
# Friends in PHP
diff --git a/src/Blog/posts/20260410-define-deprecated.md b/src/Blog/posts/20260410-define-deprecated.md
index fc9ccfd..352298c 100644
--- a/src/Blog/posts/20260410-define-deprecated.md
+++ b/src/Blog/posts/20260410-define-deprecated.md
@@ -2,6 +2,7 @@
extensions:
pygments: true
title: Introducing define_deprecated() for PHP
+tags: ["php"]
---
# Introducing define_deprecated() for PHP
diff --git a/src/Blog/posts/20260416-php86-release-manager.md b/src/Blog/posts/20260416-php86-release-manager.md
index 9db4cd5..f9c7cda 100644
--- a/src/Blog/posts/20260416-php86-release-manager.md
+++ b/src/Blog/posts/20260416-php86-release-manager.md
@@ -1,5 +1,6 @@
---
title: PHP 8.6 Release Manager
+tags: ["php", "php-rm", "php-rm-86"]
---
# PHP 8.6 Release Manager
diff --git a/src/Blog/posts/20260430-php856-rc-3.md b/src/Blog/posts/20260430-php856-rc-3.md
index 89554fb..27177e1 100644
--- a/src/Blog/posts/20260430-php856-rc-3.md
+++ b/src/Blog/posts/20260430-php856-rc-3.md
@@ -2,6 +2,7 @@
title: The Story of PHP 8.5.6 Release Candidate 3
extra-classes:
- blog-page--sidebar-image
+tags: ["php", "php-rm", "php-rm-85"]
---
# The Story of PHP 8.5.6 Release Candidate 3
diff --git a/src/Blog/posts/20260510-release-manager-notes-2.md b/src/Blog/posts/20260510-release-manager-notes-2.md
index a0576fb..0c9becb 100644
--- a/src/Blog/posts/20260510-release-manager-notes-2.md
+++ b/src/Blog/posts/20260510-release-manager-notes-2.md
@@ -1,5 +1,6 @@
---
title: Notes for PHP Release Managers, Part 2
+tags: ["php", "php-rm"]
---
# Notes for PHP Release Managers, Part 2
diff --git a/src/Blog/posts/20260517-blog-rss.md b/src/Blog/posts/20260517-blog-rss.md
index c30e6ac..f3653a9 100644
--- a/src/Blog/posts/20260517-blog-rss.md
+++ b/src/Blog/posts/20260517-blog-rss.md
@@ -2,6 +2,7 @@
title: Blog RSS Feed
extensions:
pygments: true
+tags: ["website"]
---
# Blog RSS Feed
diff --git a/src/Blog/posts/20260525-phptek-2026.md b/src/Blog/posts/20260525-phptek-2026.md
index 22317ad..8d0ae8d 100644
--- a/src/Blog/posts/20260525-phptek-2026.md
+++ b/src/Blog/posts/20260525-phptek-2026.md
@@ -1,5 +1,6 @@
---
title: PHPTek 2026
+tags: ["conferences", "php"]
---
# PHPTek 2026
diff --git a/src/Blog/posts/20260710-reflection-rust.md b/src/Blog/posts/20260710-reflection-rust.md
index 65d61a2..5a19062 100644
--- a/src/Blog/posts/20260710-reflection-rust.md
+++ b/src/Blog/posts/20260710-reflection-rust.md
@@ -3,6 +3,7 @@ extensions:
footnotes: true
pygments: true
title: Rewriting Reflection with Rust
+tags: ["php", "php-dev", "rust"]
---
# Rewriting Reflection with Rust
diff --git a/src/Blog/posts/20260810-fossy-2026.md b/src/Blog/posts/20260810-fossy-2026.md
index 1397eeb..9156731 100644
--- a/src/Blog/posts/20260810-fossy-2026.md
+++ b/src/Blog/posts/20260810-fossy-2026.md
@@ -1,5 +1,6 @@
---
title: FOSSY 2026
+tags: ["conferences"]
---
# FOSSY 2026
diff --git a/src/Blog/posts/20260909-ecosystem-security.md b/src/Blog/posts/20260909-ecosystem-security.md
index 191c7b8..00d8d30 100644
--- a/src/Blog/posts/20260909-ecosystem-security.md
+++ b/src/Blog/posts/20260909-ecosystem-security.md
@@ -1,5 +1,6 @@
---
title: Joining the PHP Ecosystem Security Team
+tags: ["php"]
---
# Joining the PHP Ecosystem Security Team
diff --git a/src/Blog/posts/20260923-no-php86-rc-1.md b/src/Blog/posts/20260923-no-php86-rc-1.md
index 81b1b6e..97a520b 100644
--- a/src/Blog/posts/20260923-no-php86-rc-1.md
+++ b/src/Blog/posts/20260923-no-php86-rc-1.md
@@ -2,6 +2,7 @@
title: No PHP 8.6.0RC1
extra-classes:
- blog-page--sidebar-image
+tags: ["php", "php-rm", "php-rm-86"]
---
# No PHP 8.6.0RC1
diff --git a/src/Blog/posts/20261005-blog-tags.md b/src/Blog/posts/20261005-blog-tags.md
new file mode 100644
index 0000000..ecd6dd1
--- /dev/null
+++ b/src/Blog/posts/20261005-blog-tags.md
@@ -0,0 +1,33 @@
+---
+title: Blog Tags
+extensions:
+ pygments: true
+tags: ["website"]
+---
+
+# Blog Tags
+
+Blog posts on my website now have tags to help filter them! You can see the
+tags for each post at the bottom of the page. On the blog index page, each
+post's tags are shown in the upper right corner of the post's excerpt.
+
+In addition to adding some color to the output and making things look more
+interesting, the tags can also be used to filter the list of blog posts. For
+example, [/Blog?tag=website][blog-tag-filtered] filters for blog posts that are
+tagged as being related to my website. The tags shown at the bottom of each post
+and in the excerpts are links that apply the corresponding filters. You can
+hover over the tags to see descriptions of each.
+
+This is my 31st blog post on this site, so having some method of organization
+was starting to feel necessary. But I did not want to go overboard - at the
+moment, the filtering is just a simple check of whether a post has a tag. At
+some point I might add support for excluding posts with a given tag, or
+filtering for posts that are tagged with one of a number of tags, or something
+else, but a simple "Only show posts with tag ---" should be enough for now.
+
+The filtering is currently implemented on the backend before the list of
+excerpts is shown, but since I don't have any public examples of a single-page
+application that I've written, I might switch to a dynamic version of the
+filtering at some point, to showcase some ~fancy~ JavaScript. We'll see.
+
+[blog-tag-filtered]: /Blog?tag=website
diff --git a/src/Pages/BlogIndexPage.php b/src/Pages/BlogIndexPage.php
index 9adbeb1..47989eb 100644
--- a/src/Pages/BlogIndexPage.php
+++ b/src/Pages/BlogIndexPage.php
@@ -7,6 +7,7 @@
use DanielEScherzer\HTMLBuilder\RawHTML;
use DanielWebsite\Blog\BlogDisplay;
use DanielWebsite\Blog\BlogPostStore;
+use DanielWebsite\Blog\BlogTags;
use DanielWebsite\SitemapEntry;
use League\CommonMark\Extension\CommonMark\Node\Block\Heading;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
@@ -18,29 +19,65 @@
#[SitemapEntry( 'Blog' )]
class BlogIndexPage extends BasePage {
- public function __construct() {
- parent::__construct();
+ private readonly ?string $tag;
+
+ public function __construct( array $matches ) {
+ $query = $matches['_url']->getQuery();
+ $tag = null;
+ if ( $query ) {
+ $params = [];
+ parse_str( $query, $params );
+ if ( isset( $params['tag'] ) ) {
+ $tag = strtolower( $params['tag'] );
+ }
+ }
+ $this->tag = $tag;
+ parent::__construct( $tag !== null );
$this->head->append(
FluentHTML::fromTag( 'title' )->addChild( 'Blog index' )
);
}
protected function build(): void {
+ $this->contentWrapper->addClass( 'blog-index' );
$this->addStyleSheet( 'blog-styles.css' );
+ $tagFilter = $this->checkTagFilter();
+
+ $heading = 'Blog index';
+ if ( $tagFilter ) {
+ $desc = BlogTags::getDescription( $this->tag );
+ $heading = "Blog posts about $desc";
+ }
$this->contentWrapper->append(
- FluentHTML::make( 'h1', [], 'Blog index' )
+ FluentHTML::make( 'h1', [], $heading )
);
+ if ( $tagFilter ) {
+ $this->contentWrapper->append(
+ FluentHTML::make(
+ 'span',
+ [ 'class' => 'blog-index--clear-filter' ],
+ [
+ '(',
+ FluentHTML::make( 'a', [ 'href' => '/Blog' ], 'clear filter' ),
+ ')',
+ ]
+ )
+ );
+ }
$store = new BlogPostStore();
$posts = $store->listBlogPosts();
- // Use `League\CommonMark` library for parsing, since I write all of
- // the blog posts no need to escape unsecure stuff
- $env = BlogDisplay::makeCommonMarkEnv( false );
+ foreach ( $posts as $post ) {
+ $env = BlogDisplay::makeCommonMarkEnv( $post );
- $parser = new MarkdownParser( $env );
- $renderer = new HtmlRenderer( $env );
+ $tags = $post->getTags();
+ if ( $tagFilter && !in_array( $this->tag, $tags, true ) ) {
+ continue;
+ }
+
+ $parser = new MarkdownParser( $env );
+ $renderer = new HtmlRenderer( $env );
- foreach ( $posts as $post ) {
$parsedResult = $parser->parse( $post->markdown );
// For any links in the first paragraph of a blog post that are
@@ -65,10 +102,20 @@ protected function build(): void {
->findOne( $parsedResult );
$firstParagraph = $renderer->renderNodes( $firstParagraph->children() );
+ $tags = $post->getTags();
+ $previewClasses = [ 'blog-preview' ];
+ if ( $tags ) {
+ $tags = FluentHTML::make(
+ 'div',
+ [ 'class' => 'blog-tags' ],
+ BlogTags::getListForTags( $tags ),
+ );
+ $previewClasses[] = 'blog-preview--has-tags';
+ }
$this->contentWrapper->append(
FluentHTML::make(
'div',
- [ 'class' => 'blog-preview' ],
+ [ 'class' => $previewClasses ],
[
FluentHTML::make( 'h2', [], new RawHTML( $firstHeading ) ),
FluentHTML::make(
@@ -76,6 +123,7 @@ protected function build(): void {
[ 'class' => 'blog-preview-date' ],
$post->date->format( 'l, d F Y' )
),
+ $tags ? $tags : '',
FluentHTML::make(
'p',
[],
@@ -95,4 +143,34 @@ protected function build(): void {
}
}
+ /**
+ * Returns whether to filter for only posts with the requested tag, and
+ * adds a warning if an unknown tag is requested.
+ */
+ private function checkTagFilter(): bool {
+ if ( !$this->tag ) {
+ return false;
+ }
+ if ( BlogTags::isKnown( $this->tag ) ) {
+ return true;
+ }
+
+ $tag = $this->tag;
+ $this->addStyleSheet( 'error-styles.css' );
+ $this->contentWrapper->append(
+ FluentHTML::make(
+ 'div',
+ [ 'class' => 'warning-box' ],
+ [
+ FluentHTML::make(
+ 'p',
+ [],
+ "The requested blog tag '$tag' is not recognized; no filtering is applied"
+ ),
+ ]
+ )
+ );
+ return false;
+ }
+
}
diff --git a/src/Pages/BlogPostPage.php b/src/Pages/BlogPostPage.php
index 88daa24..b51a2ff 100644
--- a/src/Pages/BlogPostPage.php
+++ b/src/Pages/BlogPostPage.php
@@ -7,6 +7,7 @@
use DanielEScherzer\HTMLBuilder\RawHTML;
use DanielWebsite\Blog\BlogDisplay;
use DanielWebsite\Blog\BlogPostStore;
+use DanielWebsite\Blog\BlogTags;
use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode;
use League\CommonMark\Extension\CommonMark\Node\Block\Heading;
use League\CommonMark\Extension\CommonMark\Node\Inline\Emphasis;
@@ -158,13 +159,27 @@ static function ( Node $node ): bool {
);
}
- $this->contentWrapper->append(
- FluentHTML::make(
- 'div',
- [ 'class' => 'blog-content' ],
- new RawHTML( $html )
- )
+ $content = FluentHTML::make(
+ 'div',
+ [ 'class' => 'blog-content' ],
+ new RawHTML( $html )
);
+
+ $tags = $post->getTags();
+ if ( $tags ) {
+ $content->append(
+ FluentHTML::make(
+ 'div',
+ [ 'class' => 'blog-tags' ],
+ [
+ 'Tags: ',
+ BlogTags::getListForTags( $tags ),
+ ],
+ )
+ );
+ }
+
+ $this->contentWrapper->append( $content );
$this->contentWrapper->addClass( 'blog-page' );
}
diff --git a/src/Pages/LandingPage.php b/src/Pages/LandingPage.php
index a0a983f..edee72a 100644
--- a/src/Pages/LandingPage.php
+++ b/src/Pages/LandingPage.php
@@ -7,6 +7,7 @@
use DanielEScherzer\HTMLBuilder\RawHTML;
use DanielWebsite\Blog\BlogDisplay;
use DanielWebsite\Blog\BlogPostStore;
+use DanielWebsite\Blog\BlogTags;
use DanielWebsite\SitemapEntry;
use League\CommonMark\Extension\CommonMark\Node\Block\Heading;
use League\CommonMark\Extension\CommonMark\Node\Inline\Link;
@@ -132,7 +133,7 @@ protected function build(): void {
// Use `League\CommonMark` library for parsing, since I write all of
// the blog posts no need to escape unsecure stuff
- $env = BlogDisplay::makeCommonMarkEnv( false );
+ $env = BlogDisplay::makeCommonMarkEnv( $latestPost );
$parser = new MarkdownParser( $env );
$renderer = new HtmlRenderer( $env );
@@ -175,10 +176,20 @@ protected function build(): void {
)
);
+ $tags = $latestPost->getTags();
+ $previewClasses = [ 'blog-preview' ];
+ if ( $tags ) {
+ $tags = FluentHTML::make(
+ 'div',
+ [ 'class' => 'blog-tags' ],
+ BlogTags::getListForTags( $tags ),
+ );
+ $previewClasses[] = 'blog-preview--has-tags';
+ }
$this->contentWrapper->append(
FluentHTML::make(
'div',
- [ 'class' => 'blog-preview' ],
+ [ 'class' => $previewClasses ],
[
FluentHTML::make( 'h3', [], new RawHTML( $firstHeading ) ),
FluentHTML::make(
@@ -186,6 +197,7 @@ protected function build(): void {
[ 'class' => 'blog-preview-date' ],
$latestPost->date->format( 'l, d F Y' )
),
+ $tags ? $tags : '',
FluentHTML::make(
'p',
[],
diff --git a/src/Pages/RedirectPage.php b/src/Pages/RedirectPage.php
index 5e712b7..57f39e2 100644
--- a/src/Pages/RedirectPage.php
+++ b/src/Pages/RedirectPage.php
@@ -17,7 +17,7 @@ class RedirectPage extends AbstractPage {
private string $title;
public function __construct( array $params ) {
- $title = $params['title'];
+ $title = $params['_path'];
$this->title = $title;
}
diff --git a/src/Router.php b/src/Router.php
index 57703e6..c2c7133 100644
--- a/src/Router.php
+++ b/src/Router.php
@@ -18,6 +18,7 @@
use DanielWebsite\Pages\WorkPage;
use FastRoute\Dispatcher;
use FastRoute\RouteCollector;
+use Uri\WhatWg\Url;
class Router {
@@ -28,32 +29,25 @@ public static function pageForRequest(
string $requestMethod,
string $requestURI
): AbstractPage {
- // Remove any query parameters
- if ( str_contains( $requestURI, '?' ) ) {
- $pos = strpos( $requestURI, '?' );
- $requestURI = substr( $requestURI, 0, $pos );
- }
- $requestURI = rawurldecode( $requestURI );
- $requestURI = trim( $requestURI, '/' );
- // Router is case insensitive
- $requestURI = strtolower( $requestURI );
- // Prefer uppercase first letter in the strings and error messages
- $requestURI = ucfirst( $requestURI );
+ $url = new Url( $requestURI, new Url( 'https://scherzer.dev' ) );
+ $path = $url->getPath()
+ |> ( static fn ( string $path ): string => trim( $path, '/' ) )
+ |> strtolower( ... )
+ |> ucfirst( ... );
// phpcs:ignore MediaWiki.WhiteSpace.SpaceyParenthesis
$dispatcher = \FastRoute\simpleDispatcher( self::addRoutesCb(...) );
- $routeInfo = $dispatcher->dispatch( $requestMethod, $requestURI );
+ $routeInfo = $dispatcher->dispatch( $requestMethod, $path );
switch ( $routeInfo[0] ) {
case Dispatcher::NOT_FOUND:
- return new Error404Page( $requestURI );
+ return new Error404Page( $path );
case Dispatcher::METHOD_NOT_ALLOWED:
- return new Error405Page( $requestURI, $requestMethod, $routeInfo[1] );
+ return new Error405Page( $path, $requestMethod, $routeInfo[1] );
case Dispatcher::FOUND:
$clazz = $routeInfo[1];
$matches = $routeInfo[2];
- if ( $clazz === RedirectPage::class ) {
- $matches = [ 'title' => $requestURI ];
- }
+ $matches['_path'] = $path;
+ $matches['_url'] = $url;
return new $clazz( $matches );
}
}
diff --git a/tests/Blog/BlogPostTest.php b/tests/Blog/BlogPostTest.php
index a98fe30..4bd43b0 100644
--- a/tests/Blog/BlogPostTest.php
+++ b/tests/Blog/BlogPostTest.php
@@ -22,6 +22,7 @@ public function testBlogPost() {
$this->assertSame( 'random-content', $p->markdown );
$this->assertSame( '20251201-testing', $p->getTitle() );
+ $this->assertSame( [], $p->getTags() );
$this->assertSame( [], $p->getExtraClasses() );
$p->setConfig( [
@@ -30,12 +31,17 @@ public function testBlogPost() {
'toc' => true,
],
'extra-classes' => [ 'foo' ],
+ 'tags' => [ 'baz' ],
] );
$this->assertSame( 'Example', $p->getTitle() );
$this->assertSame(
[ 'foo', 'blog-page--has-toc' ],
$p->getExtraClasses()
);
+ $this->assertSame(
+ [ 'baz' ],
+ $p->getTags()
+ );
$this->expectException( RuntimeException::class );
$this->expectExceptionMessage(
diff --git a/tests/Blog/BlogTagsTest.php b/tests/Blog/BlogTagsTest.php
new file mode 100644
index 0000000..b2fe591
--- /dev/null
+++ b/tests/Blog/BlogTagsTest.php
@@ -0,0 +1,55 @@
+assertTrue( BlogTags::isKnown( 'php' ) );
+ $this->assertFalse( BlogTags::isKnown( 'missing' ) );
+ }
+
+ public function testDescription() {
+ $this->assertSame( BlogTags::KNOWN_TAGS['conferences'], BlogTags::getDescription( 'conferences' ) );
+ $this->assertSame(
+ BlogTags::KNOWN_TAGS['php']['description'],
+ BlogTags::getDescription( 'php' )
+ );
+ $this->expectException( InvalidArgumentException::class );
+ $this->expectExceptionMessage(
+ "Unknown tag: missing"
+ );
+ BlogTags::getDescription( 'missing' );
+ }
+
+ public function testListForTag() {
+ $this->assertSame(
+ '',
+ BlogTags::getListForTags( [ 'conferences' ] )->getHTML()
+ );
+ $this->assertSame(
+ ''
+ . ''
+ . '#PHP '
+ . ' ',
+ BlogTags::getListForTags( [ 'php' ] )->getHTML()
+ );
+ $this->expectException( InvalidArgumentException::class );
+ $this->expectExceptionMessage(
+ "Unknown tag: missing"
+ );
+ BlogTags::getListForTags( [ 'missing' ] );
+ }
+
+}
diff --git a/tests/SitemapGeneratorTest.php b/tests/SitemapGeneratorTest.php
index 1b6c9e6..379846f 100644
--- a/tests/SitemapGeneratorTest.php
+++ b/tests/SitemapGeneratorTest.php
@@ -9,7 +9,7 @@
use DanielWebsite\Blog\BlogPostStore;
use DanielWebsite\Router;
use DanielWebsite\SitemapEntry;
-use FastRoute\DataGenerator\MarkBased;
+use FastRoute\DataGenerator\CharCountBased;
use FastRoute\RouteCollector;
use FastRoute\RouteParser\Std;
use PHPUnit\Framework\Attributes\CoversClass;
@@ -30,7 +30,7 @@ protected function tearDown(): void {
}
private function generateSitemap( string $location ): void {
- $collector = new RouteCollector( new Std(), new MarkBased() );
+ $collector = new RouteCollector( new Std(), new CharCountBased() );
Router::addRoutesCb( $collector );
$getPaths = $collector->getData()[0]['GET'];
$pages = array_unique( $getPaths );
diff --git a/tests/StaticOutputTest.php b/tests/StaticOutputTest.php
index 9932ec6..1934c6c 100644
--- a/tests/StaticOutputTest.php
+++ b/tests/StaticOutputTest.php
@@ -69,6 +69,8 @@ public static function providePages() {
yield 'Blog - exists' => [ 'GET', '/Blog/20250409-website-launch', 'blog-launch.html' ];
yield 'Blog - missing' => [ 'GET', '/Blog/missing', 'blog-missing.html', 404 ];
yield 'Blog - index' => [ 'GET', '/Blog', 'blog-index.html' ];
+ yield 'Blog - tag filter' => [ 'GET', '/Blog?tag=WEBSITE', 'blog-tag-filter.html' ];
+ yield 'Blog - tag unknown' => [ 'GET', '/Blog?tag=missing', 'blog-tag-unknown.html' ];
yield 'Tools - index' => [ 'GET', '/Tools', 'tools-index.html' ];
yield 'Tools - missing' => [ 'GET', '/Tools/missing', 'tools-missing.html', 404 ];
yield 'Tools - dvorak' => [ 'GET', '/Tools/dvorak', 'tools-dvorak.html' ];
diff --git a/tests/data/Home.html b/tests/data/Home.html
index 77d6ee1..251d49c 100644
--- a/tests/data/Home.html
+++ b/tests/data/Home.html
@@ -7,6 +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.
LinkedIn Email: daniel.e.scherzer@gmail.com I also have a blog. You can see a full index of my posts here . My latest blog post is:
No PHP 8.6.0RC1 Wednesday, 23 September 2026 Tomorrow, the PHP 8.6 release manager team is planning to announce the first
-release candidate (RC) of PHP 8.6, PHP 8.6.0RC2. So, what happened to
-PHP 8.6.0RC1? Continue reading...