<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Russell Jones</title>
    <description>The latest articles on DEV Community by Russell Jones (@jonesrussell).</description>
    <link>https://dev.to/jonesrussell</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F136661%2Fd812786d-8ef0-4b08-9421-35be6f99b174.png</url>
      <title>DEV Community: Russell Jones</title>
      <link>https://dev.to/jonesrussell</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jonesrussell"/>
    <language>en</language>
    <item>
      <title>Agent-friendly JSON output for PHP CI tools</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Sun, 24 May 2026 19:33:07 +0000</pubDate>
      <link>https://dev.to/jonesrussell/agent-friendly-json-output-for-php-ci-tools-2720</link>
      <guid>https://dev.to/jonesrussell/agent-friendly-json-output-for-php-ci-tools-2720</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;When an AI agent runs your test suite or a CI gate during an implement-or-review loop, the verbose stdout gets piped straight back into its context window. A full &lt;a href="https://phpunit.de/" rel="noopener noreferrer"&gt;PHPUnit&lt;/a&gt; run on the &lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa framework&lt;/a&gt; monorepo is around 12,000 lines. &lt;code&gt;bin/check-package-layers&lt;/code&gt; is about 600. Per iteration, per gate. The token cost is real, and it compounds across review cycles. This post walks through &lt;code&gt;waaseyaa/agent-output&lt;/code&gt;, a Layer 0 package that shrinks that output to a single NDJSON line for agents while leaving human terminal output completely unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why agent context windows hate CI output
&lt;/h2&gt;

&lt;p&gt;The pattern shows up the moment you let an agent drive your test loop. The agent runs &lt;code&gt;composer test&lt;/code&gt;. PHPUnit emits its banner, then a dot per test, then a footer summary, then optionally a slow-test report. None of that helps the agent. It needs three things: did the run pass, what failed, where. Everything else is noise that displaces real signal.&lt;/p&gt;

&lt;p&gt;The same is true for &lt;code&gt;bin/check-package-layers&lt;/code&gt;, &lt;code&gt;bin/check-phpstan&lt;/code&gt;, &lt;code&gt;tools/drift-detector.sh&lt;/code&gt;, and friends. Each one is a CI gate that the agent already understands at the contract level. The full human-readable output exists to help a person scan and react. An agent does not need any of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the package does
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;waaseyaa/agent-output&lt;/code&gt; is a single-purpose Layer 0 package (no &lt;code&gt;waaseyaa/*&lt;/code&gt; runtime deps, installable standalone). It does three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Detects an agent runtime&lt;/strong&gt; from a list of well-known env vars (&lt;code&gt;CLAUDE_CODE&lt;/code&gt;, &lt;code&gt;CURSOR_AGENT&lt;/code&gt;, and the rest), extensible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provides a &lt;code&gt;FormatterInterface&lt;/code&gt;&lt;/strong&gt; and first-party formatters for PHPUnit, Pest, PHPStan, the &lt;code&gt;bin/check-*&lt;/code&gt; CI gates, and the drift detector.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honors three activation triggers&lt;/strong&gt; per command: an &lt;code&gt;--output=json&lt;/code&gt; flag, a &lt;code&gt;WAASEYAA_OUTPUT=json&lt;/code&gt; env var, or auto-activation when an agent env var is set.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When none of those triggers apply, the affected command emits exactly the human output it always did. No JSON fields leak, no exit codes change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three ways to flip a tool into agent mode
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/check-package-layers &lt;span class="nt"&gt;--output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json
&lt;span class="nv"&gt;WAASEYAA_OUTPUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json bin/check-package-layers
&lt;span class="nv"&gt;CLAUDE_CODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 bin/check-package-layers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first is explicit per-invocation. The second sets it for the shell. The third is what happens automatically when Claude Code (or another supported agent) drives your terminal — you do not have to wire anything up; the auto-detection kicks in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coverage
&lt;/h2&gt;

&lt;p&gt;Here is the full set of tools the package now covers, taken verbatim from the package README:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Formatter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bin/check-package-layers&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--output=json&lt;/code&gt; / env&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PackageLayersFormatter&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bin/check-dead-code&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--output=json&lt;/code&gt; / env&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DeadCodeFormatter&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bin/check-getquery-bindings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--output=json&lt;/code&gt; / env&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GetQueryBindingsFormatter&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bin/check-composer-policy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--output=json&lt;/code&gt; / env&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ComposerPolicyFormatter&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bin/check-phpstan&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--output=json&lt;/code&gt; / env&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PhpStanFormatter&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/drift-detector.sh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--output=json&lt;/code&gt; / env&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DriftDetectorFormatter&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vendor/bin/phpunit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;WAASEYAA_OUTPUT=json&lt;/code&gt; (PHPUnit does not surface custom CLI flags)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PhpUnitFormatter&lt;/code&gt; via &lt;code&gt;AgentOutputPhpUnitExtension&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Five &lt;code&gt;bin/check-*&lt;/code&gt; scripts, a drift detector, and PHPUnit. Each one emits an NDJSON envelope through a formatter dedicated to that tool's domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  PHPUnit is the awkward one
&lt;/h2&gt;

&lt;p&gt;PHPUnit's extension API does not surface custom CLI flags. There is no clean way to add &lt;code&gt;--output=json&lt;/code&gt; and have PHPUnit pass it to your extension. So the env var is the canonical trigger, and the package ships a PHPUnit 10 extension that registers six event subscribers (passed, failed, errored, marked-incomplete, skipped, execution-finished) over a shared run-state object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PhpUnitRunState&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$passed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$skipped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="cd"&gt;/** @var list&amp;lt;array{test: string, file: string, line: int, message: string}&amp;gt; */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$failures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That class lives in its own file rather than as an anonymous shape inside the extension, so PHPStan can type-check the field accesses without inferring &lt;code&gt;mixed&lt;/code&gt; through anonymous classes. A small thing, but it is the kind of detail that decides whether a package's own lint suite stays green.&lt;/p&gt;

&lt;p&gt;The extension itself is a no-op when &lt;code&gt;WAASEYAA_OUTPUT&lt;/code&gt; is not &lt;code&gt;json&lt;/code&gt; — zero overhead in human mode. When it is, the envelope is printed at &lt;code&gt;TestRunner\ExecutionFinished&lt;/code&gt; with a leading newline so it lands on its own trailing line. Agent consumers read the file line-by-line and parse the line that starts with &lt;code&gt;{"tool":"phpunit"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers say
&lt;/h2&gt;

&lt;p&gt;WP06 of the mission was an empirical token-reduction smoke test against the original NFR. The headline result, measured on &lt;code&gt;packages/foundation/tests/Unit --no-coverage&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Standard PHPUnit output:&lt;/strong&gt; 2,209 bytes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent envelope (NDJSON line only):&lt;/strong&gt; 117 bytes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reduction:&lt;/strong&gt; 94.70%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The threshold was ≥90%. The pattern delivers. And that number understates the savings on a full monorepo run, where the human output runs in the thousands of lines and the envelope stays a single line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just use Laravel PAO?
&lt;/h2&gt;

&lt;p&gt;The pattern was lifted from Laravel PAO (released around May 2026), but the package is framework-native for two reasons. First, PAO does not cover the custom CI gates the Waaseyaa monorepo runs as hard gates (&lt;code&gt;bin/check-package-layers&lt;/code&gt; and the rest). Second, the formatters need to live alongside the gate scripts so the contract between script and envelope shape can evolve in the same PR — third-party packaging would have made that coupling awkward.&lt;/p&gt;

&lt;p&gt;The package is also a Layer 0 dependency, which means anyone outside the Waaseyaa monorepo can install just &lt;code&gt;waaseyaa/agent-output&lt;/code&gt; and reuse the formatter interface for their own tools. The detection logic and envelope contract travel; the bin/check-* wrappers stay in the framework where they belong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it in your own monorepo
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require waaseyaa/agent-output
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then either pass &lt;code&gt;--output=json&lt;/code&gt; to any supported script, set &lt;code&gt;WAASEYAA_OUTPUT=json&lt;/code&gt; in your shell, or run under an agent that sets &lt;code&gt;CLAUDE_CODE=1&lt;/code&gt;. For PHPUnit specifically, register the extension in &lt;code&gt;phpunit.xml.dist&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;extensions&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;bootstrap&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"Waaseyaa\AgentOutput\Listener\AgentOutputPhpUnitExtension"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/extensions&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The extension self-disables when &lt;code&gt;WAASEYAA_OUTPUT&lt;/code&gt; is not set to &lt;code&gt;json&lt;/code&gt;, so registering it does not change human-mode output.&lt;/p&gt;

&lt;p&gt;For the full envelope schema, formatter contract, and a guide for writing third-party formatters, see &lt;code&gt;docs/specs/agent-output.md&lt;/code&gt; in the framework repo.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>php</category>
      <category>citools</category>
      <category>waaseyaa</category>
    </item>
    <item>
      <title>Spot the AI: can you tell which passage Claude wrote?</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Sat, 23 May 2026 21:11:45 +0000</pubDate>
      <link>https://dev.to/jonesrussell/spot-the-ai-can-you-tell-which-passage-claude-wrote-d25</link>
      <guid>https://dev.to/jonesrussell/spot-the-ai-can-you-tell-which-passage-claude-wrote-d25</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://spot-the-ai.oiatc.ca" rel="noopener noreferrer"&gt;Spot the AI&lt;/a&gt; is a small web game. You're shown two short passages, one written by a human author and one written by &lt;a href="https://www.anthropic.com/claude" rel="noopener noreferrer"&gt;Claude&lt;/a&gt;, and you pick which one is the AI. This post is a heads up that the game is live and an invitation to play a few rounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to play
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;a href="https://spot-the-ai.oiatc.ca" rel="noopener noreferrer"&gt;spot-the-ai.oiatc.ca&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Read both passages.&lt;/li&gt;
&lt;li&gt;Pick the one you think Claude wrote.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is the whole loop. No account, no setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it exists
&lt;/h2&gt;

&lt;p&gt;People talk a lot about AI writing without testing whether they can actually tell the difference. The game is a tiny way to test yourself before you make claims about what AI writing sounds like.&lt;/p&gt;

&lt;p&gt;It is also a small thing under the &lt;a href="https://oiatc.ca" rel="noopener noreferrer"&gt;OIATC&lt;/a&gt; umbrella, which is the broader push toward Indigenous-controlled AI tooling and infrastructure. Most of that work happens out of view. This one happens to be playable in a browser.&lt;/p&gt;

&lt;p&gt;Play a few rounds and see how you do.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>claude</category>
      <category>games</category>
    </item>
    <item>
      <title>Bumping a PHP monorepo to 8.5: the mechanics</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Mon, 11 May 2026 18:22:30 +0000</pubDate>
      <link>https://dev.to/jonesrussell/bumping-a-php-monorepo-to-85-the-mechanics-551d</link>
      <guid>https://dev.to/jonesrussell/bumping-a-php-monorepo-to-85-the-mechanics-551d</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;This is the first of three posts about taking Waaseyaa to PHP 8.5. This one is about the mechanics: how a coordinated version bump across a 67-package monorepo actually happens. The next two cover the deprecation sweep that came with it and the features we deliberately did not adopt.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Context:&lt;/strong&gt; Waaseyaa is the open-source PHP framework I have been writing about. Mission: &lt;code&gt;php-8-5-upgrade-01KR8DN2&lt;/code&gt;. Shipped as PR &lt;a href="https://github.com/waaseyaa/waaseyaa/pull/1406" rel="noopener noreferrer"&gt;#1406&lt;/a&gt;, merge commit &lt;a href="https://github.com/waaseyaa/waaseyaa/commit/e0f8cb570" rel="noopener noreferrer"&gt;&lt;code&gt;e0f8cb57&lt;/code&gt;&lt;/a&gt;. Released in alpha.176.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The starting state
&lt;/h2&gt;

&lt;p&gt;Before the bump, Waaseyaa required PHP 8.4. Sixty-six first-party &lt;code&gt;composer.json&lt;/code&gt; files, all aligned on &lt;code&gt;&amp;gt;=8.4&lt;/code&gt;. Plus a skeleton package, which is a template artifact and is kept at the lowest reasonable floor on purpose.&lt;/p&gt;

&lt;p&gt;CI ran a single PHP version. PHPStan was pinned to a matching &lt;code&gt;phpVersion&lt;/code&gt;. The floor was tight and consistent. That alignment is what makes a bump cheap. The expensive version of this story is the one where every package picks its own minimum and you have to negotiate sixty-six exceptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mission shape
&lt;/h2&gt;

&lt;p&gt;The mission split into five work packages plus a closing one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WP01.&lt;/strong&gt; Constraint bump, CI, Docker, lockfile, PHPStan pin, docs, governance charter touch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WP02.&lt;/strong&gt; 8.5 deprecation sweep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WP03.&lt;/strong&gt; Adopt &lt;code&gt;#[\NoDiscard]&lt;/code&gt; on critical surfaces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WP04.&lt;/strong&gt; Targeted &lt;code&gt;array_find()&lt;/code&gt; adoption.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WP05.&lt;/strong&gt; PHP-CS-Fixer migration rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WP06.&lt;/strong&gt; CHANGELOG and verification.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;WP01 is the only one that touches the floor. Everything after is feature work that becomes available because the floor moved. Splitting it this way matters: if WP01 lands clean, the rest can land in any order without coupling.&lt;/p&gt;

&lt;h2&gt;
  
  
  What WP01 actually changed
&lt;/h2&gt;

&lt;p&gt;The mechanical surface of a floor bump is smaller than people expect. From the merge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;66 first-party &lt;code&gt;composer.json&lt;/code&gt; files&lt;/strong&gt; updated from &lt;code&gt;&amp;gt;=8.4&lt;/code&gt; to &lt;code&gt;&amp;gt;=8.5&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3 GitHub Actions workflows&lt;/strong&gt; repinned to &lt;code&gt;php-version: '8.5'&lt;/code&gt;: &lt;code&gt;ci.yml&lt;/code&gt;, &lt;code&gt;skeleton-smoke.yml&lt;/code&gt;, &lt;code&gt;release-cut.yml&lt;/code&gt;. Ten total occurrences of the string &lt;code&gt;'8.5'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;phpstan.neon&lt;/code&gt;&lt;/strong&gt; updated: &lt;code&gt;phpVersion: 80500&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lockfile&lt;/strong&gt; regenerated against 8.5.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the bump. Everything else in the mission is downstream of those four artifacts moving in lockstep.&lt;/p&gt;

&lt;p&gt;The reason the surface is small is that Waaseyaa has hard gates that already enforce alignment. There is a &lt;code&gt;bin/check-composer-policy&lt;/code&gt; script that fails CI if any package drifts from the root constraint. There is a &lt;code&gt;bin/check-package-layers&lt;/code&gt; script that fails if the dependency direction inverts. There is a &lt;code&gt;tools/drift-detector.sh&lt;/code&gt; that fails if docs lag the code. The floor is one number defended in many places.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verification surface
&lt;/h2&gt;

&lt;p&gt;For a bump to be safe, every hard gate has to be green on the new floor. Waaseyaa's full gate list for this mission:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;composer phpstan&lt;/code&gt; (root level + package level)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vendor/bin/phpunit&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;composer cs-check&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bin/check-composer-policy&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bin/check-package-layers&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bin/audit-dead-code&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tools/drift-detector.sh&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At merge time the test suite was 7,497 unit tests, 18,118 assertions, 0 deprecations, 2 expected skips. That is the number to trust. Not because tests prove a version is fine, but because the test corpus is dense enough that deprecation warnings would surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why split it into work packages at all
&lt;/h2&gt;

&lt;p&gt;This is the part worth paying attention to if you maintain a PHP monorepo. The actual diff for a version bump is small. You could do it in one PR with one commit. People do.&lt;/p&gt;

&lt;p&gt;The cost of doing it that way is that the diff conflates four different kinds of change:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The floor moves (a policy change).&lt;/li&gt;
&lt;li&gt;Deprecations get removed (a behavior change).&lt;/li&gt;
&lt;li&gt;New features get adopted (a style change).&lt;/li&gt;
&lt;li&gt;New tooling gets wired (a configuration change).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When all four land in one squash commit, the next person to touch any of them cannot read the rationale. Six months later, someone reverts a &lt;code&gt;#[\NoDiscard]&lt;/code&gt; attribute thinking it is part of the floor bump, and now the floor bump cannot be reverted cleanly either.&lt;/p&gt;

&lt;p&gt;The work package structure makes each kind of change auditable on its own terms. WP02 is removable without affecting WP01. WP04 can be reverted without touching WP03. The mission directory is the persistent record of why each was done.&lt;/p&gt;

&lt;p&gt;That is the same point I made about the &lt;a href="https://jonesrussell.github.io/blog/spec-kitty-mission-lifecycle/" rel="noopener noreferrer"&gt;Spec Kitty mission lifecycle&lt;/a&gt; post: the output of any mission is replaceable. The trail is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the next posts cover
&lt;/h2&gt;

&lt;p&gt;Post 2 in this series digs into the deprecation sweep: what 8.5 surfaced, where it was hiding in the codebase, and how the sweep got from 34 warnings to 0.&lt;/p&gt;

&lt;p&gt;Post 3 covers the features we deliberately did not adopt. Property hooks. The pipe operator. Broader &lt;code&gt;array_find()&lt;/code&gt;. The argument is that restraint is part of the upgrade, not absent from it.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>waaseyaa</category>
      <category>monorepo</category>
      <category>speckitty</category>
    </item>
    <item>
      <title>PHP 8.5 restraint: features we did not adopt</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Mon, 11 May 2026 18:21:56 +0000</pubDate>
      <link>https://dev.to/jonesrussell/php-85-restraint-features-we-did-not-adopt-568g</link>
      <guid>https://dev.to/jonesrussell/php-85-restraint-features-we-did-not-adopt-568g</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;Third in the &lt;a href="https://jonesrussell.github.io/blog/waaseyaa-php-version-bump-monorepo/" rel="noopener noreferrer"&gt;PHP 8.5 upgrade series&lt;/a&gt;. Post one was the floor-bump mechanics. &lt;a href="https://jonesrussell.github.io/blog/php-8-5-deprecation-sweep/" rel="noopener noreferrer"&gt;Post two&lt;/a&gt; was the deprecation sweep. This one is about what we deliberately did not adopt.&lt;/p&gt;

&lt;p&gt;Most upgrade write-ups read like a feature tour. Here is what is new, here is how to use it. They are useful and they are not the whole story. The other half of an upgrade is what you choose not to add. That choice is invisible in the diff and load-bearing in the codebase.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Mission:&lt;/strong&gt; &lt;code&gt;php-8-5-upgrade-01KR8DN2&lt;/code&gt;, merge commit &lt;a href="https://github.com/waaseyaa/waaseyaa/commit/e0f8cb570" rel="noopener noreferrer"&gt;&lt;code&gt;e0f8cb57&lt;/code&gt;&lt;/a&gt;. Five work packages shipped. Property hooks were not in any of them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Property hooks: not in scope
&lt;/h2&gt;

&lt;p&gt;PHP 8.4 introduced property hooks. Define &lt;code&gt;get&lt;/code&gt; and &lt;code&gt;set&lt;/code&gt; on a property directly, eliminate the boilerplate getter and setter pair. Asymmetric visibility came in the same window. Lots of writeups called this the biggest PHP language change in years.&lt;/p&gt;

&lt;p&gt;Waaseyaa did not adopt either. The mission spec did not mention them. The plan did not list them as a non-goal. They simply were not part of the upgrade.&lt;/p&gt;

&lt;p&gt;If you grep the codebase for the patterns property hooks would replace, you will find traditional methods everywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getClientId&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;setClientId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$clientId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;clientId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$clientId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Boring. Repetitive. Could be a property hook. Was not converted.&lt;/p&gt;

&lt;p&gt;The reason this is intentional rather than accidental: the mission was scoped to "raise the PHP requirement and fix what 8.5 surfaces, plus a focused 8.5 feature-adoption pass." Property hooks are an 8.4 feature, not an 8.5 feature. The line was drawn at the version being adopted.&lt;/p&gt;

&lt;p&gt;That line is the discipline. An upgrade pass is a window where adopting new patterns is cheap because everyone is reading the diff anyway. The temptation is to use the window for everything. The cost of using it for everything is that the diff conflates "we now require 8.5" with "we changed our property style." Two reverts deep, those become impossible to separate.&lt;/p&gt;

&lt;p&gt;Property hooks are not rejected. They are deferred. They get their own mission when the conversion is the work, not a side effect of something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipe operator: not used
&lt;/h2&gt;

&lt;p&gt;PHP 8.5 introduced &lt;code&gt;|&amp;gt;&lt;/code&gt;, a pipe operator that lets you write &lt;code&gt;$x |&amp;gt; $fn1 |&amp;gt; $fn2&lt;/code&gt; instead of nested calls.&lt;/p&gt;

&lt;p&gt;Waaseyaa shipped 8.5 without using &lt;code&gt;|&amp;gt;&lt;/code&gt; anywhere. The plan considered it in WP04 alongside &lt;code&gt;array_first()&lt;/code&gt; and &lt;code&gt;array_find()&lt;/code&gt;. After the survey pass, no use sites were strong enough to take.&lt;/p&gt;

&lt;p&gt;The reason is that pipe shines when you have a multi-step transform that reads naturally as a chain. Waaseyaa's transforms are usually one-step (use a function), two-step (assign an intermediate), or many-step but heterogeneous (a builder pattern with named methods). The middle band where pipe wins is narrow.&lt;/p&gt;

&lt;p&gt;Adopting &lt;code&gt;|&amp;gt;&lt;/code&gt; at every two-step site for style would create a second idiom alongside the existing intermediate-variable style. Mixed idioms have a tax: every reader has to decide which style is in play before reading. That tax is paid every time the file is opened.&lt;/p&gt;

&lt;p&gt;So pipe stays unused until a real call site asks for it. Then it gets adopted in that one place. Not across the codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;array_find()&lt;/code&gt;: two adoptions, five rejections
&lt;/h2&gt;

&lt;p&gt;The most interesting case. PHP 8.5 added &lt;code&gt;array_find()&lt;/code&gt; for "first matching element or null." The surface use case is exactly the foreach-and-return-first pattern that shows up in every codebase.&lt;/p&gt;

&lt;p&gt;WP04 surveyed seven candidate sites. Two were adopted. Five were rejected.&lt;/p&gt;

&lt;h3&gt;
  
  
  The two adoptions
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;packages/search/src/SearchResult.php::getFacet()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;facets&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$facet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$facet&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$facet&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// After&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;array_find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;facets&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;SearchFacet&lt;/span&gt; &lt;span class="nv"&gt;$facet&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$facet&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;packages/cli/src/Testing/CliTester.php::findOption()&lt;/code&gt; follows the same pattern. Three lines of foreach become one &lt;code&gt;array_find()&lt;/code&gt; with a typed predicate.&lt;/p&gt;

&lt;p&gt;Both sites win because the return type is &lt;code&gt;?SearchFacet&lt;/code&gt; or &lt;code&gt;?OptionDefinition&lt;/code&gt;. The null case is a real outcome the caller handles. &lt;code&gt;array_find&lt;/code&gt; returns null when nothing matches, and that lines up cleanly with the existing contract.&lt;/p&gt;

&lt;h3&gt;
  
  
  The five rejections
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;SqlEntityStorage&lt;/code&gt;, &lt;code&gt;AuthController&lt;/code&gt;, &lt;code&gt;EntityResolver&lt;/code&gt;, &lt;code&gt;JsonApiController&lt;/code&gt;, &lt;code&gt;DbalTransport&lt;/code&gt;. The mission notes give one rationale that covers all five:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;all rejected because the surrounding type contracts (&lt;code&gt;load()&lt;/code&gt; accepts &lt;code&gt;int|string&lt;/code&gt;, not null) require an explicit empty guard either way&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The point is subtle. &lt;code&gt;array_find()&lt;/code&gt; returning null is only a win if the caller wants null. If the caller's contract guarantees non-null (because the input was validated upstream, or because nullness is an error condition), then the foreach version is doing two things: searching and asserting. Replacing it with &lt;code&gt;array_find()&lt;/code&gt; keeps the search but loses the assertion. You end up writing an explicit guard right after the &lt;code&gt;array_find()&lt;/code&gt; call. The line count is the same. The intent is worse.&lt;/p&gt;

&lt;p&gt;The fastest way to spot this in your own codebase: read the immediate caller of the candidate site. If it does &lt;code&gt;throw&lt;/code&gt; or &lt;code&gt;assert&lt;/code&gt; on the result, do not adopt &lt;code&gt;array_find()&lt;/code&gt; there. The foreach is encoding more than iteration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was adopted, intentionally
&lt;/h2&gt;

&lt;p&gt;To be specific about what restraint does not mean: WP03 added &lt;code&gt;#[\NoDiscard]&lt;/code&gt; to sixteen API surfaces. Four allowed/forbidden/neutral factory methods on &lt;code&gt;AccessResult&lt;/code&gt;. Five repository interface methods that return loaded entities. Ten fluent-builder methods on &lt;code&gt;DBALSelect&lt;/code&gt; that return the modified builder.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;#[\NoDiscard]&lt;/code&gt; is a semantic safety net. If a caller ignores a &lt;code&gt;find()&lt;/code&gt; return value they probably have a bug. The attribute makes the compiler say so. Adopting it on sixteen surfaces was a security-shaped decision, not a style one.&lt;/p&gt;

&lt;p&gt;WP05 also wired three mechanical PHP-CS-Fixer rules: &lt;code&gt;octal_notation&lt;/code&gt; (52 sites converted to &lt;code&gt;0o755&lt;/code&gt;), &lt;code&gt;new_expression_parentheses&lt;/code&gt; (58 chained-new conversions), and &lt;code&gt;heredoc_indentation&lt;/code&gt; (8 SQL and HTML heredocs reindented). Mechanical, fixer-driven, no judgement required per site. Easy to adopt at scale because the fixer makes the decision.&lt;/p&gt;

&lt;p&gt;The pattern across both: adoption is at its best when it is either a safety improvement on a critical surface, or a mechanical fixer rule that can be applied uniformly. Adoption is at its worst when it is a style change applied site by site by humans.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point
&lt;/h2&gt;

&lt;p&gt;An upgrade is a decision about what to add and what not to add. Both decisions live in the diff. The "did not adopt" decisions are invisible if you only read the merged code, which is why they are worth writing down somewhere.&lt;/p&gt;

&lt;p&gt;Mission directories are the place we write them down. The five-site rejection rationale for &lt;code&gt;array_find()&lt;/code&gt; is one line in WP04's notes. Six months from now, someone will look at &lt;code&gt;SqlEntityStorage::load()&lt;/code&gt; and think "why isn't this &lt;code&gt;array_find()&lt;/code&gt;?" The mission directory has the answer.&lt;/p&gt;

&lt;p&gt;If your team is doing a PHP 8.5 upgrade, the most useful thing you can write down is not the list of features you adopted. It is the list of features you considered and rejected, with one sentence each. That list is what makes the upgrade a position, not a checklist.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>waaseyaa</category>
      <category>monorepo</category>
      <category>design</category>
    </item>
    <item>
      <title>The PHP 8.5 deprecation sweep: from 34 warnings to zero</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Mon, 11 May 2026 18:21:52 +0000</pubDate>
      <link>https://dev.to/jonesrussell/the-php-85-deprecation-sweep-from-34-warnings-to-zero-1077</link>
      <guid>https://dev.to/jonesrussell/the-php-85-deprecation-sweep-from-34-warnings-to-zero-1077</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;Second in the &lt;a href="https://jonesrussell.github.io/blog/waaseyaa-php-version-bump-monorepo/" rel="noopener noreferrer"&gt;PHP 8.5 upgrade series&lt;/a&gt;. The first post covered the floor-bump mechanics. This one is about what 8.5 surfaced when the floor moved and how the sweep cleared it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Mission:&lt;/strong&gt; &lt;code&gt;php-8-5-upgrade-01KR8DN2&lt;/code&gt;, work package WP02. Merge commit &lt;a href="https://github.com/waaseyaa/waaseyaa/commit/e0f8cb570" rel="noopener noreferrer"&gt;&lt;code&gt;e0f8cb57&lt;/code&gt;&lt;/a&gt;. The CHANGELOG entry for &lt;a href="https://github.com/waaseyaa/waaseyaa/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;alpha.176&lt;/a&gt; records the verification: 34 PHPUnit deprecations to 0, across 7,497 tests.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The starting number
&lt;/h2&gt;

&lt;p&gt;Before WP02 ran, the test suite was emitting 34 deprecation warnings against 8.5. That number does not measure how broken the codebase was. It measures how dense the test corpus is. A weaker test suite would have surfaced fewer warnings because fewer code paths run during CI.&lt;/p&gt;

&lt;p&gt;PHPUnit's deprecation handling matters here. Waaseyaa runs PHPUnit in strict mode where deprecation messages are captured and counted per test, not just printed. The mission's exit criterion was zero deprecations on the matrix that already ran 18,118 assertions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three categories, twenty-nine sites
&lt;/h2&gt;

&lt;p&gt;The 34 warnings collapsed into three deprecation patterns once you grouped them. Twenty-nine distinct call sites across the monorepo.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;Reflection*::setAccessible()&lt;/code&gt; — 22 sites
&lt;/h3&gt;

&lt;p&gt;The biggest category. &lt;code&gt;ReflectionMethod::setAccessible()&lt;/code&gt; and &lt;code&gt;ReflectionProperty::setAccessible()&lt;/code&gt; were marked as no-ops in PHP 8.1 (private members became reflectively accessible by default) and deprecated outright in 8.5.&lt;/p&gt;

&lt;p&gt;Twenty-two call sites across seven test files. Packages: &lt;code&gt;entity&lt;/code&gt;, &lt;code&gt;user&lt;/code&gt;, &lt;code&gt;ssr&lt;/code&gt;, &lt;code&gt;foundation&lt;/code&gt;, &lt;code&gt;entity-storage&lt;/code&gt;, and one integration test under &lt;code&gt;tests/Integration/Phase13/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Every site looked roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$reflection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ReflectionMethod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'privateMethod'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$reflection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setAccessible&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$reflection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$arg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix was deletion. The line that called &lt;code&gt;setAccessible(true)&lt;/code&gt; was removed. The line that called &lt;code&gt;invoke()&lt;/code&gt; worked unchanged. No behavior change at runtime. The reflection access was already implicit.&lt;/p&gt;

&lt;p&gt;This is the cleanest kind of deprecation removal you can do: the warning was telling you the line was already useless.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;$http_response_header&lt;/code&gt; — 1 site
&lt;/h3&gt;

&lt;p&gt;A single site in &lt;code&gt;packages/http-client/src/StreamHttpClient.php&lt;/code&gt;. The magic global variable &lt;code&gt;$http_response_header&lt;/code&gt; was deprecated in favor of the explicit function &lt;code&gt;http_get_last_response_headers()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$http_response_header&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;http_get_last_response_headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same null-coalesce default. Explicit function call instead of a side-effect global. Easier to read, easier to mock, easier to grep for.&lt;/p&gt;

&lt;p&gt;This is the kind of language cleanup that arrives one site at a time when the language standardizes a long-running pattern. PHP's magic globals are slowly being retired across the major versions.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;code&gt;curl_close()&lt;/code&gt; — 6 sites
&lt;/h3&gt;

&lt;p&gt;Six call sites of &lt;code&gt;curl_close()&lt;/code&gt;, deprecated in 8.5 because libcurl has treated it as a no-op since version 7.20.0. PHP held onto the function for years for compatibility. 8.5 finally removes it.&lt;/p&gt;

&lt;p&gt;Files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;packages/ai-agent/src/Provider/AnthropicProvider.php&lt;/code&gt; — 3 sites&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;packages/ai-agent/src/Provider/OpenAiCompatibleProvider.php&lt;/code&gt; — 2 sites&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;packages/mercure/src/MercurePublisher.php&lt;/code&gt; — 1 site&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All six were paired with &lt;code&gt;curl_exec()&lt;/code&gt; calls in HTTP request flows. Removed without replacement. The cURL handle is collected by GC when it goes out of scope.&lt;/p&gt;

&lt;p&gt;A second tautological PHPStan warning was cleaned up in the same area while we were there. Worth noting because deprecation sweeps are a good moment to fix the things you keep walking past.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did not get removed
&lt;/h2&gt;

&lt;p&gt;WP02 was a deprecation sweep, not a code cleanup. The bar for removal was "PHP 8.5 marks it deprecated" or "PHPStan reports it tautological in the file we are already editing." Nothing else.&lt;/p&gt;

&lt;p&gt;That bar matters. It is tempting during an upgrade to fold in unrelated cleanup. The reason not to is that the diff stops being readable. A reviewer looking at WP02 should be able to read it as "deprecation removals" with no surprises. Adding "and we also renamed this variable while we were there" makes every line of the diff a question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verification
&lt;/h2&gt;

&lt;p&gt;The exit criterion was numeric and binary. Run the full suite. Count deprecations. The number must be zero.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Locked by full PHPUnit (7497 tests / 18118 assertions / 0 deprecations / 2 expected skips)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line in the CHANGELOG is the work package's signature. Two expected skips are environment-dependent tests that always skip in CI (Redis, Mercure broker variants). Zero deprecations across 7,497 tests is dense enough coverage that a future deprecation drift would surface immediately.&lt;/p&gt;

&lt;p&gt;If you are running a similar sweep on your own codebase, the methodology is straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Move the PHP floor.&lt;/li&gt;
&lt;li&gt;Run the full test suite. Capture deprecation output.&lt;/li&gt;
&lt;li&gt;Group warnings by the deprecation key (&lt;code&gt;E_USER_DEPRECATED&lt;/code&gt; message, function name, or class).&lt;/li&gt;
&lt;li&gt;For each group, write a one-line removal pattern and apply it across all sites in one commit per group.&lt;/li&gt;
&lt;li&gt;Rerun. The number is zero or it is not done.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reason to group by deprecation pattern (not by file) is that each group has the same fix. Mixing them in one commit makes the diff hard to read and impossible to revert selectively.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next post
&lt;/h2&gt;

&lt;p&gt;Post 3 in the series covers the features 8.5 introduced that we deliberately did not adopt. Property hooks. The pipe operator. Broader &lt;code&gt;array_find()&lt;/code&gt; adoption beyond the two confirmed sites. The reasoning is that restraint is part of the upgrade, not the absence of one.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>waaseyaa</category>
      <category>monorepo</category>
      <category>testing</category>
    </item>
    <item>
      <title>Spec Kitty mission lifecycle: a domain modeling pass through Giiken</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Mon, 11 May 2026 16:24:24 +0000</pubDate>
      <link>https://dev.to/jonesrussell/spec-kitty-mission-lifecycle-a-domain-modeling-pass-through-giiken-3j4k</link>
      <guid>https://dev.to/jonesrussell/spec-kitty-mission-lifecycle-a-domain-modeling-pass-through-giiken-3j4k</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;A lot of agent frameworks promise "end to end" workflows. Most of them stop at "generate a plan and hope." Spec Kitty is different. It runs a real mission through a state machine, with artifacts on disk and gates between phases. This post walks one of those missions, &lt;code&gt;giiken-domain-modeling-01KR2HKT&lt;/code&gt;, from spec to merge.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Context:&lt;/strong&gt; Giiken is the community knowledge service built on Waaseyaa. The mission did discovery and docs for its domain model. Real commit: &lt;a href="https://github.com/waaseyaa/giiken/commit/5b2328bf330b73bc1d999999bcc7cae02e2b1b6f" rel="noopener noreferrer"&gt;&lt;code&gt;waaseyaa/giiken@5b2328b&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What "a mission" actually is
&lt;/h2&gt;

&lt;p&gt;A Spec Kitty mission is a directory under &lt;code&gt;kitty-specs/&lt;/code&gt;, named with a slug and an ULID. After this mission landed, that directory looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kitty-specs/giiken-domain-modeling-01KR2HKT/
  spec.md
  plan.md
  research.md
  data-model.md
  meta.json
  status.json
  status.events.jsonl
  mission-events.jsonl
  checklists/
    requirements.md
  research/
    evidence-log.csv
    source-register.csv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not a generated artifact dump. Each file has a role in the state machine. &lt;code&gt;spec.md&lt;/code&gt; is the contract. &lt;code&gt;plan.md&lt;/code&gt; is the chosen approach. &lt;code&gt;research.md&lt;/code&gt; plus the CSVs are the evidence trail. &lt;code&gt;status.json&lt;/code&gt; and the two &lt;code&gt;.jsonl&lt;/code&gt; files are the lane state and the audit log. The checklist is a hard gate, not a suggestion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The phases
&lt;/h2&gt;

&lt;p&gt;The mission moved through these phases. Each one writes an artifact and emits a status event.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Specify.&lt;/strong&gt; Compile a &lt;code&gt;spec.md&lt;/code&gt; from the mission brief. Requirements get checklisted. Ambiguity gets surfaced before code touches the repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan.&lt;/strong&gt; Choose an approach in &lt;code&gt;plan.md&lt;/code&gt;. Inputs from spec. Output is the shape of the work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tasks.&lt;/strong&gt; Break the plan into work packages (WPs). Each WP is independent enough to assign and review on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implement.&lt;/strong&gt; Each WP runs through implement and review until approved. State transitions go through the orchestrator, not by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review.&lt;/strong&gt; Per WP, against the spec. Reviewers can reject with structured feedback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge.&lt;/strong&gt; Once every WP is approved, the mission squash-merges and the events log records the terminal state.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The thing that makes this different from a long prompt is that every transition is gated. You can't move a WP to &lt;code&gt;approved&lt;/code&gt; without a passing review. You can't merge with WPs still in flight. The agent is constrained to the shape of the state machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this mission actually produced
&lt;/h2&gt;

&lt;p&gt;Beyond the kitty-specs directory, the merge commit added two architecture documents to the Giiken repo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;docs/architecture/domain-model.md&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;docs/architecture/lifecycle.md&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the implementation work in WP01 and WP02 left the data model migration-aligned, with PHPUnit and Vitest both green at merge time. Thirteen files in one squash commit, all traceable back to the spec.&lt;/p&gt;

&lt;p&gt;The point is the trail. A reader six months from now can open &lt;code&gt;kitty-specs/giiken-domain-modeling-01KR2HKT/&lt;/code&gt; and see: what was asked for, what was chosen, what evidence informed it, what got built, and which checks passed. That is a working memory you can hand to the next agent or the next human.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters more than the output
&lt;/h2&gt;

&lt;p&gt;The output of this mission is fine. Useful, even. But the output is replaceable. The trail is not.&lt;/p&gt;

&lt;p&gt;If you have been around agent workflows for any length of time, you know the failure mode: an AI session ends, the context evaporates, and the next session has to reconstruct everything from the code. Spec Kitty inverts that. The mission directory &lt;strong&gt;is&lt;/strong&gt; the persistent context. The next agent picks up the spec and the checklist, not a chat log.&lt;/p&gt;

&lt;p&gt;That is the lifecycle proof: not "an agent shipped code," but "an agent moved through a structured workflow that another agent or human can audit, resume, or extend."&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you want to see one of these missions in your own repo, the easiest path is to install Spec Kitty and run &lt;code&gt;spec-kitty next --agent &amp;lt;name&amp;gt;&lt;/code&gt; on a small scope. Pick something with a clear question, not a vague refactor. Discovery missions like this one are a good first try.&lt;/p&gt;

&lt;p&gt;The commit for the mission described here is &lt;a href="https://github.com/waaseyaa/giiken/commit/5b2328b" rel="noopener noreferrer"&gt;&lt;code&gt;waaseyaa/giiken@5b2328b&lt;/code&gt;&lt;/a&gt;. The full mission directory is in that repo at &lt;code&gt;kitty-specs/giiken-domain-modeling-01KR2HKT/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>speckitty</category>
      <category>giiken</category>
      <category>waaseyaa</category>
      <category>ai</category>
    </item>
    <item>
      <title>Hugo blog shortcodes: adding a visual component system to PaperMod</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Wed, 08 Apr 2026 20:41:09 +0000</pubDate>
      <link>https://dev.to/jonesrussell/hugo-blog-shortcodes-adding-a-visual-component-system-to-papermod-3i3l</link>
      <guid>https://dev.to/jonesrussell/hugo-blog-shortcodes-adding-a-visual-component-system-to-papermod-3i3l</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/adityatelange/hugo-PaperMod" rel="noopener noreferrer"&gt;PaperMod&lt;/a&gt; is a clean, fast &lt;a href="https://gohugo.io/" rel="noopener noreferrer"&gt;Hugo&lt;/a&gt; theme. What it doesn't give you out of the box is a component library: no callouts, no numbered steps, no before/after comparisons. If you write tutorials or technical posts, you end up compensating with blockquotes and bold text where purpose-built components would serve the reader better.&lt;/p&gt;

&lt;p&gt;This post covers all six shortcodes, the CSS behind them, and how to add the same components to your own PaperMod blog. All of it came together in a single &lt;a href="https://claude.com/claude-code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; session.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;p&gt;Six shortcodes, one CSS file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;callout&lt;/strong&gt;: highlighted aside with five severity types&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;steps / step&lt;/strong&gt;: auto-numbered procedure blocks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pullquote&lt;/strong&gt;: large-format quote for emphasis&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;stats / stat&lt;/strong&gt;: side-by-side metric tiles&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;compare / before / after&lt;/strong&gt;: side-by-side comparison panels&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cta&lt;/strong&gt;: call-to-action box with a button&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All styles hook into PaperMod's CSS variables (&lt;code&gt;--primary&lt;/code&gt;, &lt;code&gt;--entry&lt;/code&gt;, &lt;code&gt;--border&lt;/code&gt;, etc.), so they adapt to dark and light mode automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  File locations
&lt;/h2&gt;

&lt;p&gt;Hugo resolves shortcodes from &lt;code&gt;layouts/shortcodes/&lt;/code&gt;. Create one &lt;code&gt;.html&lt;/code&gt; file per shortcode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;layouts/shortcodes/
  callout.html
  steps.html
  step.html
  pullquote.html
  stats.html
  stat.html
  compare.html
  before.html
  after.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CSS goes in &lt;code&gt;assets/css/extended/&lt;/code&gt;. PaperMod loads everything in that directory automatically; no import statements needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shortcodes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Callout
&lt;/h3&gt;

&lt;p&gt;A callout is a highlighted aside that draws the reader's attention. It accepts a &lt;code&gt;type&lt;/code&gt; parameter: &lt;code&gt;info&lt;/code&gt;, &lt;code&gt;warning&lt;/code&gt;, &lt;code&gt;tip&lt;/code&gt;, &lt;code&gt;note&lt;/code&gt;, or &lt;code&gt;success&lt;/code&gt;. Defaults to &lt;code&gt;note&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Template&lt;/strong&gt; (&lt;code&gt;layouts/shortcodes/callout.html&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;{{- $type := .Get "type" | default "note" -}}
{{- $emoji := dict "info" "💡" "warning" "⚠️" "tip" "✨" "note" "📝" "success" "✅" -}}
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"callout callout-{{ $type }}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"callout-marker"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ index $emoji $type }}&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"callout-body"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ .Inner | markdownify }}&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Usage:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{&amp;lt;/* callout type="warning" */&amp;gt;}}
Run `git stash` before switching branches or you will lose your changes.
{{&amp;lt;/* /callout */&amp;gt;}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rendered:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;git stash&lt;/code&gt; before switching branches or you will lose your changes.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;markdownify&lt;/code&gt; call means you can use inline markdown inside the body: backtick code, bold, links. All render correctly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Steps and step
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;steps&lt;/code&gt; shortcode wraps a sequence of &lt;code&gt;step&lt;/code&gt; shortcodes. Each &lt;code&gt;step&lt;/code&gt; takes a title as its first positional argument and auto-numbers itself via CSS counters. No JavaScript, no manual numbering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Templates&lt;/strong&gt; (&lt;code&gt;layouts/shortcodes/steps.html&lt;/code&gt; and &lt;code&gt;step.html&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- steps.html --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"steps"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ .Inner }}&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- step.html --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"step"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"step-badge"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"step-body"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"step-title"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ .Get 0 }}&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"step-content"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ .Inner | markdownify }}&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Usage:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{&amp;lt;/* steps */&amp;gt;}}
{{&amp;lt;/* step "Create the shortcode file" */&amp;gt;}}
Add `layouts/shortcodes/callout.html` to your project.
{{&amp;lt;/* /step */&amp;gt;}}
{{&amp;lt;/* step "Add the CSS" */&amp;gt;}}
Create `assets/css/extended/components.css` with the component styles.
{{&amp;lt;/* /step */&amp;gt;}}
{{&amp;lt;/* /steps */&amp;gt;}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rendered:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Add &lt;code&gt;layouts/shortcodes/callout.html&lt;/code&gt; to your project.&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;assets/css/extended/components.css&lt;/code&gt; with the component styles.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stats and stat
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;stats&lt;/code&gt; shortcode is a flex container for &lt;code&gt;stat&lt;/code&gt; tiles. Each &lt;code&gt;stat&lt;/code&gt; takes two positional arguments: the value and the label.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Templates&lt;/strong&gt; (&lt;code&gt;layouts/shortcodes/stats.html&lt;/code&gt; and &lt;code&gt;stat.html&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- stats.html --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"stats"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ .Inner }}&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- stat.html --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"stat"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"stat-number"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ .Get 0 }}&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"stat-label"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ .Get 1 }}&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Usage:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{&amp;lt;/* stats */&amp;gt;}}
{{&amp;lt;/* stat "6" "shortcodes" */&amp;gt;}}
{{&amp;lt;/* stat "1" "CSS file" */&amp;gt;}}
{{&amp;lt;/* stat "0" "JS required" */&amp;gt;}}
{{&amp;lt;/* /stats */&amp;gt;}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rendered:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The tiles flex-wrap on small screens, so they stack gracefully on mobile without extra media query work.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compare, before, and after
&lt;/h3&gt;

&lt;p&gt;Three files work together: &lt;code&gt;compare.html&lt;/code&gt; wraps the pair, &lt;code&gt;before.html&lt;/code&gt; and &lt;code&gt;after.html&lt;/code&gt; render each panel. The before panel uses PaperMod's warning colour; after uses the success colour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Templates&lt;/strong&gt; (&lt;code&gt;compare.html&lt;/code&gt;, &lt;code&gt;before.html&lt;/code&gt;, &lt;code&gt;after.html&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- compare.html --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"compare"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ .Inner }}&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- before.html --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"compare-before"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"compare-marker"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;✕&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"compare-body"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ .Inner | markdownify }}&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- after.html --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"compare-after"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"compare-marker"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;✓&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"compare-body"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ .Inner | markdownify }}&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Usage:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{&amp;lt;/* compare */&amp;gt;}}
{{&amp;lt;/* before */&amp;gt;}}
Blockquote hacks repurposed as callouts.
{{&amp;lt;/* /before */&amp;gt;}}
{{&amp;lt;/* after */&amp;gt;}}
Purpose-built `callout` shortcode with five types.
{{&amp;lt;/* /after */&amp;gt;}}
{{&amp;lt;/* /compare */&amp;gt;}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rendered:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Blockquote hacks repurposed as callouts.&lt;/p&gt;

&lt;p&gt;Purpose-built &lt;code&gt;callout&lt;/code&gt; shortcode with five types.&lt;/p&gt;

&lt;p&gt;On screens narrower than 600px the panels stack vertically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pullquote
&lt;/h3&gt;

&lt;p&gt;A pullquote is a styled blockquote for emphasis. Use it to surface a key insight or memorable line from the surrounding text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Template&lt;/strong&gt; (&lt;code&gt;layouts/shortcodes/pullquote.html&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;blockquote&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"pullquote"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  {{ .Inner | markdownify }}
&lt;span class="nt"&gt;&amp;lt;/blockquote&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Usage:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{&amp;lt;/* pullquote */&amp;gt;}}
Good writing tools get out of the way. Good components make the writing better.
{{&amp;lt;/* /pullquote */&amp;gt;}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rendered:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Good writing tools get out of the way. Good components make the writing better.&lt;/p&gt;

&lt;h3&gt;
  
  
  CTA
&lt;/h3&gt;

&lt;p&gt;A call-to-action box with a centred button. Takes three named parameters: &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;button&lt;/code&gt;, and &lt;code&gt;href&lt;/code&gt;. The inner body is optional copy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Template&lt;/strong&gt; (&lt;code&gt;layouts/shortcodes/cta.html&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;{{- $title := .Get "title" -}}
{{- $button := .Get "button" -}}
{{- $href := .Get "href" -}}
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"cta"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;h3&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"cta-title"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ $title }}&lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"cta-body"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ .Inner | markdownify }}&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"cta-button"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"{{ $href }}"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ $button }}&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Usage:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{&amp;lt;/* cta title="Try it yourself" button="View the source" href="https://github.com/jonesrussell/blog" */&amp;gt;}}
All six shortcodes and the CSS are in the repo.
{{&amp;lt;/* /cta */&amp;gt;}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rendered:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;All six shortcodes and the CSS are in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  The proving ground
&lt;/h2&gt;

&lt;p&gt;Before calling the system done, retrofit an existing post. I used Minoo Elders, replacing a flat numbered list with a &lt;code&gt;steps&lt;/code&gt; block and a closing paragraph with a &lt;code&gt;cta&lt;/code&gt;. If the shortcodes work in a real post with real content, they are ready.&lt;/p&gt;

&lt;p&gt;The retrofit caught a line-height edge case in the step badge CSS and confirmed the dark mode colours held in both themes. Worth the ten minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vibe coding the component system
&lt;/h2&gt;

&lt;p&gt;This system was built with &lt;a href="https://claude.com/claude-code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; in one session. Describe the component you want, review the draft, push back on anything over-engineered. Nine files and the CSS came together without a lot of manual effort.&lt;/p&gt;

&lt;p&gt;The real gain is in the iteration loop: see a render, request a tweak, get updated CSS in thirty seconds. That speed is the whole point.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>hugo</category>
      <category>claudecode</category>
      <category>papermod</category>
      <category>shortcodes</category>
    </item>
    <item>
      <title>Day One of the Content Pipeline: What Broke and What I Fixed</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Mon, 06 Apr 2026 04:01:00 +0000</pubDate>
      <link>https://dev.to/jonesrussell/day-one-of-the-content-pipeline-what-broke-and-what-i-fixed-3nde</link>
      <guid>https://dev.to/jonesrussell/day-one-of-the-content-pipeline-what-broke-and-what-i-fixed-3nde</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;Yesterday's post walked through &lt;a href="https://jonesrussell.github.io/blog/automated-content-pipeline-github-actions/" rel="noopener noreferrer"&gt;automating a content pipeline with GitHub Actions and Issues&lt;/a&gt;. The idea: a daily scheduled job scans recent commits and closed issues across several repos, filters out the noise, and opens what's left as GitHub issues labeled &lt;code&gt;stage:mined&lt;/code&gt;. One of those issues looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Title: [content] feat: add SovereigntyProfile to Layer 0
Body:
  ## Source
  Commit `abc1234` in `waaseyaa/framework`
  ## Content Seed
  feat: add SovereigntyProfile to Layer 0
  ## Suggested Type
  text-post
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those issues are raw material. You curate them into drafts, produce the copy, and publish. That surfacing step is what the rest of this post calls &lt;em&gt;mining&lt;/em&gt;. This post is about what happened the first time I actually ran that pipeline. The short version: it works, but the first real run turned up three problems no amount of planning could have caught. Here are the three fixes and the meta-lesson underneath them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day One Output: 20 Issues, Too Much Noise
&lt;/h2&gt;

&lt;p&gt;The mining workflow fired on schedule and opened 20 &lt;code&gt;stage:mined&lt;/code&gt; issues overnight, pulled from three repos. Good news: the pipeline saw everything it was supposed to see. Bad news: "everything" is not the same as "a usable drafting queue." The first run had more noise than I expected, and it had noise the filter couldn't see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: Tighten the Mining Filter
&lt;/h2&gt;

&lt;p&gt;Even with the v1 noise filter, too many low-signal commits made it through. Things like &lt;code&gt;fix: align FileRepositoryInterface usage with Waaseyaa\Media\File contract&lt;/code&gt; matter for the codebase and are boring as standalone posts. The first fix was to extend the exclude regex in &lt;code&gt;content-mine.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;COMMITS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gh api &lt;span class="s2"&gt;"repos/&lt;/span&gt;&lt;span class="nv"&gt;$REPO&lt;/span&gt;&lt;span class="s2"&gt;/commits?since=&lt;/span&gt;&lt;span class="nv"&gt;$SINCE&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;per_page=50"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'.[] | select(.commit.message | test("^(Merge |chore|docs|fix typo|bump|update dep|Bump |fix:.*([Pp]hp[Ss]tan|namespace|alignment|placeholder|phpunit|mock|ignore|typo))"; "i") | not) | {sha: .sha[0:7], message: (.commit.message | split("\n") | .[0]), date: .commit.author.date}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The new patterns (&lt;code&gt;phpstan&lt;/code&gt;, &lt;code&gt;namespace&lt;/code&gt;, &lt;code&gt;alignment&lt;/code&gt;, &lt;code&gt;placeholder&lt;/code&gt;, &lt;code&gt;phpunit&lt;/code&gt;, &lt;code&gt;mock&lt;/code&gt;, &lt;code&gt;ignore&lt;/code&gt;, &lt;code&gt;typo&lt;/code&gt;) catch categories of real work nobody wants to read about. A minimum message length of 25 characters cuts drive-by fixes. Fewer mined issues per run, and the ones that survive sit closer to "actually postable." That handled the mechanical noise. The next problem was harder because no regex could see it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: Merge-in-Curation
&lt;/h2&gt;

&lt;p&gt;Filters are a blunt instrument. They cannot tell that eight separate commits all belong to the same post. On day one, the &lt;a href="https://github.com/waaseyaa/giiken" rel="noopener noreferrer"&gt;Giiken&lt;/a&gt; project alone produced eight mined issues: scaffold, entity types, RBAC, ingestion, wiki schema, query layer, plus two support commits. Every one of them was a valid feature commit. Together they were one post. No filter was going to catch that. Only a human reading them side by side could say "these are a story."&lt;/p&gt;

&lt;p&gt;So curation got a new action: &lt;strong&gt;merge into target&lt;/strong&gt;. Instead of picking one winner and closing the rest, you pick a canonical issue, roll the seeds from the others into its body, and close the sources. The target ends up carrying a combined seed (the whole story), and the sub-issues get a &lt;code&gt;skipped&lt;/code&gt; label and a closed state.&lt;/p&gt;

&lt;p&gt;The curation skill now runs like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;→ Approve (move to stage:curated)
→ Skip   (close with skipped label)
→ Merge  (pick target, combine seeds, close sources)
→ Edit   (adjust seed, type, or channels before approving)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running that over the 20 mined issues collapsed them to 4 curated posts: one about the pipeline itself, one about the Giiken project, one about a governance protocol suite in the framework, and one about a specific Symfony refactor. Signal up, count down. Two fixes done. The third was the embarrassing one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: Put the Blog First
&lt;/h2&gt;

&lt;p&gt;The v1 production step went straight from a curated issue to Facebook, X, and LinkedIn copy. That read fine in the design doc. It fell apart the first time I tried to run it, because every one of those social posts had a placeholder where the URL should go. The URL had to point at a blog post. The blog post did not exist yet.&lt;/p&gt;

&lt;p&gt;So I rewrote the &lt;code&gt;/content-produce&lt;/code&gt; skill — a &lt;a href="https://claude.com/claude-code" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt; workflow that turns queue issues into drafts. The new flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A[stage:curated issue] --&amp;gt; B[Draft Hugo post&amp;lt;br/&amp;gt;draft: true]
    B --&amp;gt; C[Draft social copy&amp;lt;br/&amp;gt;docs/social/slug.md]
    C --&amp;gt; D[Commit both to blog repo]
    D --&amp;gt; E{Human review}
    E --&amp;gt;|Flip draft: false| F[GitHub Actions deploys]
    F --&amp;gt; G[/content-pipeline/]
    G --&amp;gt; H[Buffer API → X, LinkedIn, Facebook]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The human controls publication. The skill commits drafts only and never flips &lt;code&gt;draft: false&lt;/code&gt;. Once I flip the flag and push, &lt;a href="https://docs.github.com/en/actions" rel="noopener noreferrer"&gt;GitHub Actions&lt;/a&gt; deploys the post, and a separate &lt;code&gt;/content-pipeline&lt;/code&gt; skill handles the Buffer API for social distribution. Each step has one job. This post you're reading is the first one produced by the new flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Content Pipelines Need Continuous Refinement
&lt;/h2&gt;

&lt;p&gt;You cannot design a content pipeline in the abstract. You ship v1, run it against one day of real input, and watch it lie to you. Then you fix the specific lies. That loop is the work.&lt;/p&gt;

&lt;p&gt;Three days ago this pipeline did not exist. Two days ago it was a spec. Yesterday it shipped. Today it is already different. None of the three fixes in this post were things I could have known up front. They came from running the thing, staring at the output, and asking "what is this queue actually trying to tell me?"&lt;/p&gt;

&lt;p&gt;If you are building your own version of this, expect the same arc. Your v1 will have noise you cannot see yet. Your first curation session will reveal merges a filter could not find. And your production step will probably be backwards, because writing the fun part first (the tweets) is more tempting than writing the part that does the work (the blog post). The refinement is not a sign something went wrong. It is the point.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>automation</category>
      <category>content</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>Domain routing in Waaseyaa: replacing a giant dispatcher with small routers</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Mon, 06 Apr 2026 01:36:27 +0000</pubDate>
      <link>https://dev.to/jonesrussell/domain-routing-in-waaseyaa-replacing-a-giant-dispatcher-with-small-routers-471g</link>
      <guid>https://dev.to/jonesrussell/domain-routing-in-waaseyaa-replacing-a-giant-dispatcher-with-small-routers-471g</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt; had a controller dispatcher that grew past 1,000 lines. Every new feature meant more conditionals in the same file. This post covers how that dispatcher was replaced with domain-specific routers, each implementing a two-method interface that keeps routing logic scoped and testable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Dispatcher Does
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph TD
    A[HTTP Request] --&amp;gt; B[Router]
    B --&amp;gt;|matches URL to route| C[Dispatcher]
    C --&amp;gt;|picks controller, injects context| D[Controller]
    D --&amp;gt; E[Response]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The router matches a URL to a route definition. The dispatcher takes that match and figures out which controller to instantiate, which method to call, and how to pass in the request context. In most frameworks, you never think about it because the framework handles it for you.&lt;/p&gt;

&lt;p&gt;The problem starts when the dispatcher becomes the place where "which code to run" turns into a long chain of conditionals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Monolithic Dispatcher Breaks Down
&lt;/h2&gt;

&lt;p&gt;A single dispatcher that handles every request type accumulates conditionals fast. You end up with something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_controller'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$controller&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'entity_types'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// 40 lines of entity type listing logic&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;elseif&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$controller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'entity_type.'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// 60 lines of lifecycle management&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;elseif&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$controller&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'openapi'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// 80 lines of OpenAPI spec generation&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;elseif&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str_contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$controller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'SchemaController'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// 50 lines of schema handling&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ... and so on for every domain&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Entity CRUD, schema generation, lifecycle management, OpenAPI docs: all funneling through one class. Each new feature adds another branch, and testing any one path means loading the context for all of them.&lt;/p&gt;

&lt;p&gt;The fix isn't a better dispatcher. It's smaller, focused routers that each own one domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DomainRouterInterface Contract
&lt;/h2&gt;

&lt;p&gt;A domain router is a small class that owns one slice of your application's request handling. Instead of one dispatcher knowing about every domain, each router answers two questions: "Is this request mine?" and "How do I handle it?" The interface makes this explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;DomainRouterInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// "Is this request mine?" — inspects the request, returns a boolean.&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;supports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// "Yes, handle it." — does the work, returns a response.&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// The dispatcher iterates registered routers in order.&lt;/span&gt;
&lt;span class="c1"&gt;// First one to return true from supports() wins.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the Chain of Responsibility pattern with an explicit contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  EntityTypeLifecycleRouter: A Complete Example
&lt;/h2&gt;

&lt;p&gt;Waaseyaa lets you define entity types (think "Article", "User", "Comment"). Sometimes you need to disable one, maybe you're deprecating a content type, or re-enable one that was turned off. That's what this router handles. Here's the full class:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EntityTypeLifecycleRouter&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;DomainRouterInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;JsonApiResponseTrait&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// consistent JSON:API response formatting&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="c1"&gt;// Registry: knows which entity types exist and their capabilities&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;EntityTypeManager&lt;/span&gt; &lt;span class="nv"&gt;$entityTypeManager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;// Handles disable/enable state changes&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;EntityTypeLifecycleManager&lt;/span&gt; &lt;span class="nv"&gt;$lifecycleManager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;supports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Prefix match: anything starting with "entity_type." belongs here.&lt;/span&gt;
        &lt;span class="c1"&gt;// Adding entity_type.archive later requires zero changes to this method.&lt;/span&gt;
        &lt;span class="nv"&gt;$controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_controller'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$controller&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'entity_types'&lt;/span&gt;
            &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$controller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'entity_type.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_controller'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s1"&gt;'entity_types'&lt;/span&gt;        &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;listTypes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'entity_type.disable'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;disableType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'entity_type.enable'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;enableType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="k"&gt;default&lt;/span&gt;               &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;jsonApiResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]),&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One class, one domain, fully testable in isolation.&lt;/p&gt;

&lt;h2&gt;
  
  
  SchemaRouter: Same Pattern, Different Domain
&lt;/h2&gt;

&lt;p&gt;Your API also needs to serve OpenAPI specs and schema definitions for each entity type. That's a different domain from lifecycle management, so it gets its own router:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SchemaRouter&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;DomainRouterInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;JsonApiResponseTrait&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;EntityTypeManager&lt;/span&gt; &lt;span class="nv"&gt;$entityTypeManager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;// Schema endpoints enforce the same access rules as the entities themselves.&lt;/span&gt;
        &lt;span class="c1"&gt;// Can't access an entity type? Can't read its schema either.&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;EntityAccessHandler&lt;/span&gt; &lt;span class="nv"&gt;$accessHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;supports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_controller'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$controller&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'openapi'&lt;/span&gt;
            &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;str_contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$controller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'SchemaController'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_controller'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$controller&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'openapi'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;generateOpenApiSpec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// SchemaController routes carry the entity type as a route parameter&lt;/span&gt;
        &lt;span class="nv"&gt;$entityType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'entity_type_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;showSchema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$entityType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both routers pull typed context from the request without doing any parsing themselves. Where does that context come from?&lt;/p&gt;

&lt;h2&gt;
  
  
  Routers Get a Fully Loaded Request
&lt;/h2&gt;

&lt;p&gt;Middleware upstream handles authentication, body parsing, and context assembly before any router sees the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// By the time handle() runs, the request carries everything the router needs.&lt;/span&gt;
&lt;span class="c1"&gt;// No token parsing, no JSON decoding, no service lookups.&lt;/span&gt;
&lt;span class="nv"&gt;$account&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_account'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;           &lt;span class="c1"&gt;// authenticated user&lt;/span&gt;
&lt;span class="nv"&gt;$storage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_broadcast_storage'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// storage backend&lt;/span&gt;
&lt;span class="nv"&gt;$body&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_parsed_body'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// deserialized JSON&lt;/span&gt;
&lt;span class="nv"&gt;$context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_waaseyaa_context'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// framework context&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The infrastructure work happens once, upstream. Routers stay focused on domain logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding a New Router
&lt;/h2&gt;

&lt;p&gt;Here's a skeleton for a new domain. Say you want to handle bulk import operations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BulkImportRouter&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;DomainRouterInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;JsonApiResponseTrait&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;supports&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;str_starts_with&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_controller'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'bulk_import.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;Response&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_controller'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s1"&gt;'bulk_import.csv'&lt;/span&gt;  &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;importCsv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="s1"&gt;'bulk_import.json'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;importJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="k"&gt;default&lt;/span&gt;            &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;jsonApiResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]),&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No existing router changes. No dispatcher modifications. Register it in the router collection and the dispatcher picks it up. The interface guarantees new domains are additive.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Gets You
&lt;/h2&gt;

&lt;p&gt;The 1,000-line dispatcher is gone. In its place: small classes with clear boundaries, each testable in isolation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EntityTypeLifecycleRouter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$entityTypeManager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$lifecycleManager&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_controller'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'entity_type.disable'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'_parsed_body'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'entity_type_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'article'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$router&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// No framework boot, no database, no middleware chain&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you see a request to &lt;code&gt;entity_type.disable&lt;/code&gt;, you know exactly which file handles it. No tracing through a switch statement in a god class.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>architecture</category>
      <category>waaseyaa</category>
    </item>
    <item>
      <title>Remember when server-side rendering was just rendering?</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Mon, 06 Apr 2026 00:41:04 +0000</pubDate>
      <link>https://dev.to/jonesrussell/remember-when-server-side-rendering-was-just-rendering-499l</link>
      <guid>https://dev.to/jonesrussell/remember-when-server-side-rendering-was-just-rendering-499l</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;Somewhere around 2016, "server-side rendering" stopped meaning "the server renders HTML." It started meaning "run your JavaScript framework on the server so it can produce the HTML that the browser will then throw away and rebuild." The industry just forgot what to call it after React came along.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/waaseyaa/waaseyaa" rel="noopener noreferrer"&gt;Waaseyaa's&lt;/a&gt; SSR package does the original thing. A request comes in. PHP resolves a template. Twig renders HTML. The server sends it back. No hydration step, no virtual DOM diffing, no 200MB &lt;code&gt;node_modules&lt;/code&gt; folder for the privilege of generating a &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This post walks through how the rendering pipeline works: from request to HTML, with the entity renderer, field formatters, and theme chain loader that make it more than &lt;code&gt;echo&lt;/code&gt; statements in a &lt;code&gt;.php&lt;/code&gt; file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the rendering pipeline actually does
&lt;/h2&gt;

&lt;p&gt;The entry point is &lt;code&gt;SsrPageHandler::handleRenderPage()&lt;/code&gt;. It takes a path, an account, and an HTTP request. It returns an array with the rendered HTML, a status code, and headers. That's it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handleRenderPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;AccountInterface&lt;/span&gt; &lt;span class="nv"&gt;$account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;HttpRequest&lt;/span&gt; &lt;span class="nv"&gt;$httpRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$requestedViewMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'full'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The method signature tells you what matters: a path to render, who's asking, and what view mode they want. The return type is a structured array, not a framework-specific response object. The kernel decides how to send it.&lt;/p&gt;

&lt;p&gt;Between receiving the path and returning HTML, five things happen in sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Language negotiation&lt;/strong&gt; resolves the content language from URL prefixes and &lt;code&gt;Accept-Language&lt;/code&gt; headers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Path alias resolution&lt;/strong&gt; maps friendly URLs to entity references.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editorial visibility&lt;/strong&gt; checks whether the current account can see the content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Entity rendering&lt;/strong&gt; converts the entity into a Twig variable bag with formatted fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Template resolution&lt;/strong&gt; finds the most specific Twig template and renders it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the path doesn't resolve to an entity, &lt;code&gt;RenderController&lt;/code&gt; tries a path-based template instead. Visit &lt;code&gt;/about&lt;/code&gt; and it looks for &lt;code&gt;about.html.twig&lt;/code&gt;. Visit &lt;code&gt;/&lt;/code&gt; and it looks for &lt;code&gt;home.html.twig&lt;/code&gt;. No route file needed.&lt;/p&gt;

&lt;p&gt;Steps 1 through 3 narrow down what to render. Step 4 is where it gets interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  How entities become template variables
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;EntityRenderer&lt;/code&gt; is where the real work happens. It takes an entity and a view mode, and returns a flat array that Twig can consume directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;EntityInterface&lt;/span&gt; &lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;ViewMode&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$viewMode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'full'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$mode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$viewMode&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nc"&gt;ViewMode&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$viewMode&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$viewMode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$entityTypeId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getEntityTypeId&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$definition&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;entityTypeManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getDefinition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$entityTypeId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$fieldDefinitions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$definition&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getFieldDefinitions&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nv"&gt;$display&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;viewModeConfig&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getDisplay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$entityTypeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$mode&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// ... field formatting happens here ...&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'entity'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'entity_type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$entityTypeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'bundle'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s1"&gt;'view_mode'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'template_suggestions'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;buildTemplateSuggestions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$entityTypeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$entity&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$mode&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="s1"&gt;'fields'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$fields&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The return value is a plain associative array. Every field gets three things: the raw value, a formatted string ready for output, and the field type. Your Twig template can use &lt;code&gt;{{ fields.body.formatted }}&lt;/code&gt; for the processed HTML or &lt;code&gt;{{ fields.body.raw }}&lt;/code&gt; when you need the original.&lt;/p&gt;

&lt;p&gt;View mode configuration controls which fields appear and in what order. A &lt;code&gt;teaser&lt;/code&gt; view mode might show only the title and summary. A &lt;code&gt;full&lt;/code&gt; view mode shows everything. If no display configuration exists for a view mode, the renderer builds a sensible default from the entity's field definitions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Field formatters: type-safe output without the ceremony
&lt;/h2&gt;

&lt;p&gt;Each field type has a formatter that knows how to turn a raw value into safe HTML. The package ships with formatters for the common cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PlainTextFormatter&lt;/code&gt; for strings (with proper escaping)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;HtmlFormatter&lt;/code&gt; for rich text&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DateFormatter&lt;/code&gt; for timestamps&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ImageFormatter&lt;/code&gt; for image fields&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;BooleanFormatter&lt;/code&gt; for flags&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;EntityReferenceFormatter&lt;/code&gt; for relationships between entities&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;FieldFormatterRegistry&lt;/code&gt; maps field types to formatters. When the entity renderer processes a field, it asks the registry for the right formatter and calls it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$fields&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$fieldName&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'raw'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'formatted'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;formatterRegistry&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$formatterType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$settings&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'type'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$fieldType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line of code handles the dispatch. The formatter does the escaping, date formatting, or reference resolution. Your template never has to worry about whether a value is safe for output.&lt;/p&gt;

&lt;p&gt;You can register custom formatters for domain-specific field types. The &lt;code&gt;#[AsFormatter]&lt;/code&gt; attribute marks a class as a formatter, and the registry picks it up automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Template resolution: the chain loader
&lt;/h2&gt;

&lt;p&gt;Waaseyaa uses Twig's &lt;code&gt;ChainLoader&lt;/code&gt; to search for templates in priority order. The &lt;code&gt;ThemeServiceProvider&lt;/code&gt; builds the chain at boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;createTemplateChainLoader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$projectRoot&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$activeTheme&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;ChainLoader&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$chain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ChainLoader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// 1) App templates (highest priority)&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addPathLoaderIfExists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$chain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$root&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/templates'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// 2) Active theme templates&lt;/span&gt;
    &lt;span class="c1"&gt;// ... discovered from composer metadata ...&lt;/span&gt;

    &lt;span class="c1"&gt;// 3) Package templates&lt;/span&gt;
    &lt;span class="c1"&gt;// ... from packages/*/templates ...&lt;/span&gt;

    &lt;span class="c1"&gt;// 4) Base SSR templates (lowest priority)&lt;/span&gt;
    &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;addPathLoaderIfExists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$chain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$root&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/packages/ssr/templates'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$chain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your application's &lt;code&gt;templates/&lt;/code&gt; directory wins over everything. The active theme sits below that. Package templates come next. The base SSR package provides the fallback.&lt;/p&gt;

&lt;p&gt;This means you can override any template at any level. Want a custom 404 page? Drop &lt;code&gt;404.html.twig&lt;/code&gt; in your app's &lt;code&gt;templates/&lt;/code&gt; directory. Want a theme to provide a default layout that individual apps can override? That works too.&lt;/p&gt;

&lt;p&gt;Theme discovery reads &lt;code&gt;composer.json&lt;/code&gt; metadata. Any package with a &lt;code&gt;waaseyaa.theme&lt;/code&gt; key in its &lt;code&gt;extra&lt;/code&gt; block is a theme candidate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"extra"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"waaseyaa"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"theme"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"my-theme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"templates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"templates"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No theme registry, no configuration file, no admin panel. Composer already knows what's installed. The SSR package just reads that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Template suggestions: specificity without complexity
&lt;/h2&gt;

&lt;p&gt;When the entity renderer builds a variable bag, it also generates template suggestions, an ordered list of template filenames from most specific to least:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;buildTemplateSuggestions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$entityTypeId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$bundle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$entityTypeId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$bundle&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$mode&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.html.twig"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// node.article.teaser.html.twig&lt;/span&gt;
        &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$entityTypeId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$bundle&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.full.html.twig"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// node.article.full.html.twig&lt;/span&gt;
        &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$entityTypeId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$mode&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.html.twig"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;              &lt;span class="c1"&gt;// node.teaser.html.twig&lt;/span&gt;
        &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nv"&gt;$entityTypeId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.full.html.twig"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;// node.full.html.twig&lt;/span&gt;
        &lt;span class="s2"&gt;"entity.html.twig"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                               &lt;span class="c1"&gt;// catch-all&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;RenderController&lt;/code&gt; walks this list and uses the first template that exists. Create &lt;code&gt;node.article.teaser.html.twig&lt;/code&gt; and it renders article teasers. Remove it and the renderer falls through to the next match. You only create the templates you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this isn't
&lt;/h2&gt;

&lt;p&gt;This isn't PHP 4. There's no &lt;code&gt;&amp;lt;?php echo $row['title'] ?&amp;gt;&lt;/code&gt; in a file that's also running SQL queries. The rendering layer is separate from data access, has proper escaping through Twig's auto-escape, supports i18n, and handles caching with surrogate keys for CDN invalidation.&lt;/p&gt;

&lt;p&gt;But the fundamental model is the same one PHP has used since the beginning: the server receives a request, finds the right template, fills it with data, and sends HTML to the browser. The browser receives a fully rendered page and displays it. Nothing to hydrate. Nothing to rebuild.&lt;/p&gt;

&lt;p&gt;The JavaScript ecosystem spent a decade reinventing this model and gave it a new name. Waaseyaa just kept doing it.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>php</category>
      <category>waaseyaa</category>
      <category>ssr</category>
      <category>twig</category>
    </item>
    <item>
      <title>How to Build an AI Content Playbook That Actually Protects Your Voice</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Sun, 05 Apr 2026 16:30:39 +0000</pubDate>
      <link>https://dev.to/jonesrussell/how-to-build-an-ai-content-playbook-that-actually-protects-your-voice-446m</link>
      <guid>https://dev.to/jonesrussell/how-to-build-an-ai-content-playbook-that-actually-protects-your-voice-446m</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;You've read the articles warning you not to let AI take over your content. &lt;a href="https://www.ruthwriter.com/post/how-not-to-use-ai-for-content-creation-a-practical-guide-for-startup-marketing-leaders" rel="noopener noreferrer"&gt;Ruth Doherty's latest piece&lt;/a&gt; is one of the best: a clear-eyed breakdown of where AI helps and where it silently destroys your brand. This post shows you how to take that framework and turn it into an actual operating document for your content pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Framework Without a Playbook Doesn't Stick
&lt;/h2&gt;

&lt;p&gt;Ruth's core argument is sharp: AI is an efficiency engine, not a strategy engine. Use it for research, structuring, repurposing, and editing. Keep it away from messaging, customer research, and anything that requires your actual point of view.&lt;/p&gt;

&lt;p&gt;That distinction is easy to agree with. It's harder to enforce on a Tuesday afternoon when you're behind on three social posts and the AI can draft all of them in 90 seconds.&lt;/p&gt;

&lt;p&gt;A framework tells you what to believe. A playbook tells you what to do at 4pm when you're tired and the publish queue is empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define Your AI Boundary Table
&lt;/h2&gt;

&lt;p&gt;The first thing your playbook needs is a boundary table. Two columns: what AI does, what you do.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;AI Does This&lt;/th&gt;
&lt;th&gt;You Do This&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Structure scattered notes into outlines&lt;/td&gt;
&lt;td&gt;Decide what to write about and why&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generate zero-drafts from session transcripts&lt;/td&gt;
&lt;td&gt;Write the actual post with your voice and lived experience&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Convert one post into platform-specific social copy&lt;/td&gt;
&lt;td&gt;Review and approve every piece before it publishes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optimize metadata (titles, tags, descriptions)&lt;/td&gt;
&lt;td&gt;Record yourself on camera, choose the angle, be present&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tighten prose, check consistency, flag voice drift&lt;/td&gt;
&lt;td&gt;Define and evolve your brand voice and positioning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Research acceleration (trends, competitors, grants)&lt;/td&gt;
&lt;td&gt;Make strategic decisions about what to build and who to serve&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This table isn't aspirational. It's operational. Every content task in your week should land on one side or the other. If something sits in the middle, you haven't decided yet, and that ambiguity is where drift starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add Human Gates
&lt;/h2&gt;

&lt;p&gt;Ruth warns about "publishing AI-written content without human POV." The fix isn't vigilance. It's process.&lt;/p&gt;

&lt;p&gt;For every stage where AI generates output, define a human gate: a specific point where a person reads the thing and decides whether it ships.&lt;/p&gt;

&lt;p&gt;Here's how that looks in practice for a weekly content cadence:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Writing day:&lt;/strong&gt; AI helps with outlines and zero-drafts. You write the post. Human gate: nothing publishes until you've read the final version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distribution day:&lt;/strong&gt; AI generates platform-specific social copy from your post. Human gate: you read every variant before it enters your scheduling tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Newsletter day:&lt;/strong&gt; AI can proofread. You write the newsletter. Human gate: this is 100% your voice. AI assists with mechanics only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Video day:&lt;/strong&gt; AI generates metadata options. You record yourself. Human gate: you pick the title and description from AI-generated options. You are the face and voice.&lt;/p&gt;

&lt;p&gt;The pattern is consistent: AI handles the transformation layer. You own the creation layer and the approval layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Draw Harder Lines for Sensitive Content
&lt;/h2&gt;

&lt;p&gt;Ruth's framework applies broadly, but some content carries more weight than others. If you're building something that represents a community, a mission, or a set of values, AI involvement needs a shorter leash.&lt;/p&gt;

&lt;p&gt;If you're building something like &lt;a href="https://oiatc.waaseyaa.org/about" rel="noopener noreferrer"&gt;OIATC&lt;/a&gt;, the Ontario Indigenous AI &amp;amp; Technology Council, the stakes are higher. Indigenous digital sovereignty is not something AI should be framing. The entire point of an organization like that is that communities govern their own digital infrastructure. Having AI write the messaging would undermine the premise.&lt;/p&gt;

&lt;p&gt;Your version might be different: a founder's origin story, a nonprofit's mission statement, a community you represent. Whatever carries identity-level stakes gets a harder boundary. AI can format. AI can research. AI does not speak on behalf of communities.&lt;/p&gt;

&lt;p&gt;But even when the content isn't identity-level sensitive, the tools themselves can create problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prevent the Tool Patchwork
&lt;/h2&gt;

&lt;p&gt;One of Ruth's sharpest observations is about "accidental AI patchwork." Teams adopt tools informally, nobody coordinates, and suddenly you have three things generating social copy with different voice settings and no shared prompts.&lt;/p&gt;

&lt;p&gt;Your playbook needs a tool inventory. Every AI tool in your pipeline, listed once, with its purpose and status:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Your primary AI assistant&lt;/td&gt;
&lt;td&gt;Writing assist, content skills, distribution&lt;/td&gt;
&lt;td&gt;Active&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scheduling tool (e.g., Buffer)&lt;/td&gt;
&lt;td&gt;Social scheduling&lt;/td&gt;
&lt;td&gt;Active&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blog build system&lt;/td&gt;
&lt;td&gt;Publish and deploy&lt;/td&gt;
&lt;td&gt;Active&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Social copy generator&lt;/td&gt;
&lt;td&gt;Platform-specific variants&lt;/td&gt;
&lt;td&gt;Active&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video metadata optimizer&lt;/td&gt;
&lt;td&gt;YouTube titles, tags, descriptions&lt;/td&gt;
&lt;td&gt;Active&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;That thing you installed three months ago&lt;/td&gt;
&lt;td&gt;Unclear&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Decide: wire or deprecate&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is the important one. If a tool sits unused for a quarter, it's either waiting to create confusion or it's dead weight. Name it explicitly and decide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run a Quarterly Review
&lt;/h2&gt;

&lt;p&gt;A playbook that never gets reviewed drifts just like content that never gets edited. Four questions, once every three months:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is any tool on the list unused? Remove it or document why it stays.&lt;/li&gt;
&lt;li&gt;Has a new tool been adopted informally? Add it with clear boundaries.&lt;/li&gt;
&lt;li&gt;Has AI output been published without human review? Fix the gap.&lt;/li&gt;
&lt;li&gt;Does your content still sound like you? Read your last four posts aloud. If they're interchangeable with any other blog in your niche, something slipped.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What This Looks Like After One Session
&lt;/h2&gt;

&lt;p&gt;Here's the proof that this works: you can build a complete AI playbook in a single sitting. Boundary tables, human gates, brand-specific danger zones, tool inventory, quarterly review checklist. One session, one document.&lt;/p&gt;

&lt;p&gt;Here's a preview of what the playbook covers. The full version is &lt;a href="https://github.com/jonesrussell/brand/blob/main/ai-playbook.md" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The boundary table&lt;/strong&gt; draws the line between AI work and human work for every content task in your week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-brand voice rules&lt;/strong&gt; prevent AI from blending your voices when you operate across multiple brands or audiences. Each brand gets its own prompt context and tone markers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pipeline stages with human gates&lt;/strong&gt; map every step of your weekly cadence (writing, distribution, newsletter, video) to where AI helps and where you approve:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;AI Role&lt;/th&gt;
&lt;th&gt;Human Gate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Writing&lt;/td&gt;
&lt;td&gt;Zero-drafts, outlines, structure&lt;/td&gt;
&lt;td&gt;You write the post. Nothing publishes unread.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Distribution&lt;/td&gt;
&lt;td&gt;Platform-specific social copy&lt;/td&gt;
&lt;td&gt;You review every variant before scheduling.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Newsletter&lt;/td&gt;
&lt;td&gt;Proofreading only&lt;/td&gt;
&lt;td&gt;You write it. 100% your voice.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video&lt;/td&gt;
&lt;td&gt;Metadata optimization&lt;/td&gt;
&lt;td&gt;You record. You pick the title.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Danger zones&lt;/strong&gt; name the specific content types where AI must not lead: community messaging, proposals, customer research, thought leadership without lived experience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool inventory&lt;/strong&gt; lists every AI tool in the pipeline with its purpose and status, preventing the silent accumulation Ruth warns about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quarterly review&lt;/strong&gt; keeps the playbook honest with four questions you run every three months.&lt;/p&gt;

&lt;p&gt;The playbook goes in your brand directory, right next to your voice rules and platform templates. It's not a manifesto. It's a reference document you check when you're tired and tempted to let the AI do more than it should.&lt;/p&gt;

&lt;p&gt;Ruth's framework gives you the "why." The playbook gives you the "what to do about it at 4pm on a Tuesday."&lt;/p&gt;

&lt;p&gt;If you're nodding along to articles about AI misuse but haven't drawn your own lines yet, this is the afternoon to do it.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>ai</category>
      <category>contentstrategy</category>
      <category>brand</category>
    </item>
    <item>
      <title>Build a free links page with GitHub Pages</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Sun, 05 Apr 2026 14:48:14 +0000</pubDate>
      <link>https://dev.to/jonesrussell/build-a-free-links-page-with-github-pages-1lag</link>
      <guid>https://dev.to/jonesrussell/build-a-free-links-page-with-github-pages-1lag</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;A &lt;a href="https://bsky.app/profile/rkrn.me/post/3milmywaclkno" rel="noopener noreferrer"&gt;Bluesky thread&lt;/a&gt; recently reminded me how many developers still reach for &lt;a href="https://linktr.ee/" rel="noopener noreferrer"&gt;Linktree&lt;/a&gt; or &lt;a href="https://carrd.co/" rel="noopener noreferrer"&gt;Carrd&lt;/a&gt; when they need a simple links page. You don't need either. GitHub gives you two free surfaces that work as a links hub right now: a profile README and a Pages site. This post walks through both, then covers where to go if you want more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why own your links page
&lt;/h2&gt;

&lt;p&gt;Linktree, Carrd, and similar services are convenient. They're also someone else's domain, someone else's design constraints, and someone else's decision about whether your free tier keeps working next year.&lt;/p&gt;

&lt;p&gt;A links page is one HTML file. It doesn't need a SaaS product. When you host it yourself, you control the URL, the design, and the uptime. GitHub Pages gives you HTTPS, a clean subdomain, and global CDN for free.&lt;/p&gt;

&lt;p&gt;That's the pitch. Here's how to set it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: the profile README
&lt;/h2&gt;

&lt;p&gt;Every GitHub account has a special repo: &lt;code&gt;your-username/your-username&lt;/code&gt;. Create it, add a &lt;code&gt;README.md&lt;/code&gt;, and GitHub renders it at the top of your profile page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create the repo
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://github.com/new" rel="noopener noreferrer"&gt;github.com/new&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Name the repo exactly the same as your GitHub username&lt;/li&gt;
&lt;li&gt;Make it public&lt;/li&gt;
&lt;li&gt;Check "Add a README file"&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create repository&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;GitHub shows a banner confirming this is a special repo. Your &lt;code&gt;README.md&lt;/code&gt; now renders on your profile.&lt;/p&gt;

&lt;h3&gt;
  
  
  Structure it as a links page
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Hey, I'm [Your Name]&lt;/span&gt;

One-liner about what you do.

&lt;span class="gu"&gt;## Links&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Portfolio&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://your-site.com&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Blog&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://your-blog.com&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;LinkedIn&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;https://linkedin.com/in/your-handle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;Email&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;mailto:you@example.com&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a functional links page. Markdown supports links, headings, images, and badges. You can add project descriptions, tech stacks, or whatever context helps visitors understand who you are.&lt;/p&gt;

&lt;p&gt;The profile README is a good starting point, but it lives on github.com. If you want a standalone URL you can share anywhere, GitHub Pages is the next step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: a GitHub Pages links site
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://pages.github.com/" rel="noopener noreferrer"&gt;GitHub Pages&lt;/a&gt; serves a static site from a repo. Create a repo named &lt;code&gt;your-username.github.io&lt;/code&gt;, drop in an &lt;code&gt;index.html&lt;/code&gt;, and you have a live site at &lt;code&gt;https://your-username.github.io&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create the repo
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Create a new repo named &lt;code&gt;your-username.github.io&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Clone it locally:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/your-username/your-username.github.io.git
&lt;span class="nb"&gt;cd &lt;/span&gt;your-username.github.io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you an empty repo ready for your site files.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add a single-file links page
&lt;/h3&gt;

&lt;p&gt;Create an &lt;code&gt;index.html&lt;/code&gt;. Here's a minimal starting point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"UTF-8"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"viewport"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"width=device-width, initial-scale=1.0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;Your Name&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
    &lt;span class="o"&gt;*,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nd"&gt;::before&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nd"&gt;::after&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;box-sizing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;border-box&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;system-ui&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#0a0f14&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#e8eef4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;min-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100vh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;justify-content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;.container&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;560px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;.tagline&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#7a9ab5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;margin-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;line-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;.links&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;.link-card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt; &lt;span class="m"&gt;1.25rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#111820&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#1e2d3d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;text-decoration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#e8eef4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;border-color&lt;/span&gt; &lt;span class="m"&gt;0.15s&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;.link-card&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;border-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#00b4a0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nc"&gt;.link-card&lt;/span&gt; &lt;span class="nc"&gt;.label&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;block&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nc"&gt;.link-card&lt;/span&gt; &lt;span class="nc"&gt;.desc&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.8rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#7a9ab5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;420px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nc"&gt;.links&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"container"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;Your Name&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"tagline"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;What you do, in one or two sentences.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;

    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"links"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"link-card"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://your-portfolio.com"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"label"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Portfolio&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"desc"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Projects and work&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"link-card"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://your-blog.com"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"label"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Blog&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"desc"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Writing and tutorials&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"link-card"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://github.com/your-username"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"label"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;GitHub&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"desc"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Open source&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"link-card"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://linkedin.com/in/your-handle"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"label"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;LinkedIn&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"desc"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Professional profile&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you a dark-themed, responsive two-column grid. No build tools, no dependencies, no framework. One file, under 100 lines of code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Push and go live
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add index.html
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Add links page"&lt;/span&gt;
git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Within a minute, your site is live at &lt;code&gt;https://your-username.github.io&lt;/code&gt;. GitHub Pages is enabled by default for repos with this naming convention.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add Open Graph metadata
&lt;/h3&gt;

&lt;p&gt;Social platforms pull title, description, and image from OG meta tags when someone shares your link. Add these inside &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:title"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Your Name"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Your one-liner."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:type"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"website"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:url"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://your-username.github.io"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://your-username.github.io/photo.jpg"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop a photo in the repo root and reference it in the &lt;code&gt;og:image&lt;/code&gt; tag. Now when you share your link on LinkedIn or Bluesky, the preview card shows your face instead of a blank box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use both together
&lt;/h2&gt;

&lt;p&gt;The profile README and Pages site serve different audiences. The README catches developers who land on your GitHub profile. The Pages site gives you a clean URL to put in your social bios, email signature, and conference slides.&lt;/p&gt;

&lt;p&gt;Point the README's links to your Pages site (or vice versa) so both surfaces reinforce each other. For example, the &lt;a href="https://github.com/jonesrussell" rel="noopener noreferrer"&gt;jonesrussell profile README&lt;/a&gt; introduces the person and the work, while &lt;a href="https://jonesrussell.github.io" rel="noopener noreferrer"&gt;jonesrussell.github.io&lt;/a&gt; is the shareable link card.&lt;/p&gt;

&lt;h2&gt;
  
  
  Taking it further
&lt;/h2&gt;

&lt;p&gt;A single HTML file covers most needs. If you outgrow it, here are three directions worth considering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add a custom domain
&lt;/h3&gt;

&lt;p&gt;GitHub Pages supports custom domains for free. In your repo settings under &lt;strong&gt;Pages&lt;/strong&gt;, add your domain and configure a CNAME DNS record. GitHub handles the SSL certificate automatically. Your links page goes from &lt;code&gt;your-username.github.io&lt;/code&gt; to &lt;code&gt;links.yourdomain.com&lt;/code&gt; (or whatever you prefer).&lt;/p&gt;

&lt;h3&gt;
  
  
  Use a static site generator
&lt;/h3&gt;

&lt;p&gt;If you want templating, multiple pages, or a blog alongside your links page, a static site generator keeps things manageable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://astro.build/" rel="noopener noreferrer"&gt;Astro&lt;/a&gt;&lt;/strong&gt; is beginner-friendly and ships zero JavaScript by default. It has &lt;a href="https://astro.build/themes/" rel="noopener noreferrer"&gt;link page themes&lt;/a&gt; ready to customize.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://gohugo.io/" rel="noopener noreferrer"&gt;Hugo&lt;/a&gt;&lt;/strong&gt; is fast and works well if you're already writing markdown. This blog runs on Hugo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.11ty.dev/" rel="noopener noreferrer"&gt;11ty&lt;/a&gt;&lt;/strong&gt; is minimal and flexible, with no opinions about your frontend stack.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three deploy to GitHub Pages with a simple Actions workflow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Try a CSS framework
&lt;/h3&gt;

&lt;p&gt;If you want better design without writing all the CSS yourself, drop in a utility framework:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tailwindcss.com/" rel="noopener noreferrer"&gt;Tailwind CSS&lt;/a&gt;&lt;/strong&gt; via CDN for rapid styling without a build step&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://picocss.com/" rel="noopener noreferrer"&gt;Pico CSS&lt;/a&gt;&lt;/strong&gt; for classless styling that looks good out of the box&lt;/li&gt;
&lt;li&gt;A single &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tag pointing to &lt;a href="https://watercss.kognise.dev/" rel="noopener noreferrer"&gt;Water.css&lt;/a&gt; for a no-effort dark theme&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these require a build tool. Add a &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag and start using them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Own your links, own your URL
&lt;/h2&gt;

&lt;p&gt;Your links page is the one URL that represents you everywhere. Owning it means you decide how it looks, what it links to, and where it lives. GitHub gives you the hosting for free. The rest is just HTML.&lt;/p&gt;

&lt;p&gt;Baamaapii&lt;/p&gt;

</description>
      <category>github</category>
      <category>githubpages</category>
      <category>html</category>
      <category>personalsite</category>
    </item>
  </channel>
</rss>
