<?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>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>
    <item>
      <title>Automate your content pipeline with GitHub Actions and Issues</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Sun, 05 Apr 2026 02:22:03 +0000</pubDate>
      <link>https://dev.to/jonesrussell/automate-your-content-pipeline-with-github-actions-and-issues-1fhp</link>
      <guid>https://dev.to/jonesrussell/automate-your-content-pipeline-with-github-actions-and-issues-1fhp</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;You ship work every day, but most of it never becomes a post. The problem isn't writing. It's remembering what you shipped three days ago that was actually worth talking about. This post walks through a content pipeline that mines your &lt;a href="https://github.com" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; repos daily and queues content ideas as issues, so nothing slips through.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Pipeline Works
&lt;/h2&gt;

&lt;p&gt;The system has three moving parts: a &lt;a href="https://docs.github.com/en/actions" rel="noopener noreferrer"&gt;GitHub Actions&lt;/a&gt; workflow that runs on a cron schedule, an issue template that standardizes the format, and label-based stages that track each idea from raw commit to published post.&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[commit lands] --&amp;gt; B[Action mines it]
    B --&amp;gt; C[issue created&amp;lt;br/&amp;gt;stage:mined]
    C --&amp;gt; D[you curate&amp;lt;br/&amp;gt;stage:curated]
    D --&amp;gt; E[produce copy&amp;lt;br/&amp;gt;stage:ready]
    E --&amp;gt; F[distribute]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every stage is a GitHub label. You always know where each content idea sits, and nothing moves forward without your decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mining Workflow
&lt;/h2&gt;

&lt;p&gt;The workflow runs daily at 8am ET, scans a list of repos, and creates issues for commits that look like real work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Content Mining&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;12&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;issues&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;workflow_dispatch&lt;/code&gt; trigger lets you run it manually when you want to catch up. Permissions are scoped to just what the job needs: reading commits and writing issues.&lt;/p&gt;

&lt;p&gt;The core loop iterates over repos and fetches recent commits via the GitHub API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.CROSS_REPO_TOKEN }}&lt;/span&gt;
&lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
  &lt;span class="s"&gt;SINCE=$(date -u -d '1 day ago' +%Y-%m-%dT%H:%M:%SZ)&lt;/span&gt;
  &lt;span class="s"&gt;REPOS="waaseyaa/framework waaseyaa/giiken jonesrussell/jonesrussell"&lt;/span&gt;

  &lt;span class="s"&gt;for REPO in $REPOS; do&lt;/span&gt;
    &lt;span class="s"&gt;COMMITS=$(gh api "repos/$REPO/commits?since=$SINCE&amp;amp;per_page=50" \&lt;/span&gt;
      &lt;span class="s"&gt;--jq '.[] | select(.commit.message | test("...filter...") | not) | ...')&lt;/span&gt;
  &lt;span class="s"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;CROSS_REPO_TOKEN&lt;/code&gt; is a personal access token with read access to all repos you want to mine. Without it, the workflow can only see public repos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filtering Noise
&lt;/h2&gt;

&lt;p&gt;Not every commit is content. The filter regex excludes merge commits, dependency bumps, docs changes, and housekeeping fixes:&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="nb"&gt;test&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"^(Merge |chore|docs|fix typo|bump|update dep|Bump |fix:.*
  ([Pp]hp[Ss]tan|namespace|alignment|placeholder|phpunit|mock|ignore|typo))"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="s2"&gt;"i"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This catches the patterns that showed up as noise in practice: PHPStan fixes, namespace alignment, test placeholders. Commits also need a minimum message length of 25 characters to filter out low-context changes like "fix test" or "update readme".&lt;/p&gt;

&lt;p&gt;The filter will evolve. After your first curation pass, you'll know which patterns your repos produce that aren't worth posting about. Update the regex and the next run gets cleaner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deduplication
&lt;/h2&gt;

&lt;p&gt;Before creating an issue, the workflow checks whether a commit has already been queued:&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;EXISTING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gh issue list &lt;span class="nt"&gt;--repo&lt;/span&gt; jonesrussell/jonesrussell &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--label&lt;/span&gt; &lt;span class="s2"&gt;"content-queue"&lt;/span&gt; &lt;span class="nt"&gt;--search&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SHA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--json&lt;/span&gt; number &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'length'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXISTING&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"0"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Skipping (already queued): &lt;/span&gt;&lt;span class="nv"&gt;$MSG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;continue
fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents duplicate issues when you re-run the workflow manually or when the cron overlaps with a manual trigger.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Issue Template
&lt;/h2&gt;

&lt;p&gt;Each mined commit becomes an issue with a structured body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Source&lt;/span&gt;
Commit &lt;span class="sb"&gt;`abc1234`&lt;/span&gt; in &lt;span class="sb"&gt;`waaseyaa/framework`&lt;/span&gt;

&lt;span class="gu"&gt;## Content Seed&lt;/span&gt;
feat(#571): add DomainRouterInterface, EntityTypeLifecycleRouter, SchemaRouter

&lt;span class="gu"&gt;## Suggested Type&lt;/span&gt;
text-post

&lt;span class="gu"&gt;## Suggested Channels&lt;/span&gt;
x, linkedin, facebook

&lt;span class="gu"&gt;## Generated Artifacts&lt;/span&gt;
&lt;span class="c"&gt;&amp;lt;!-- To be filled by production skill --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "Generated Artifacts" section stays empty until you curate and produce the content. Labels track the stage: &lt;code&gt;stage:mined&lt;/code&gt;, &lt;code&gt;stage:curated&lt;/code&gt;, &lt;code&gt;stage:ready&lt;/code&gt;, &lt;code&gt;stage:distributed&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Curation Step
&lt;/h2&gt;

&lt;p&gt;Mining is automated. Curation is not. You review each &lt;code&gt;stage:mined&lt;/code&gt; issue and decide: approve, skip, merge with another item, or edit the seed. Skipped items get closed with an audit comment explaining why. Approved items move to &lt;code&gt;stage:curated&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is where judgment lives. A commit that says "feat: Community RBAC policies" might be a standalone post or might merge with two other commits into a broader story about your data model. The pipeline gives you the raw material. You shape it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closed Issues as Content Sources
&lt;/h2&gt;

&lt;p&gt;The workflow also scans recently closed issues across your repos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh issue list &lt;span class="nt"&gt;--repo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REPO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--state&lt;/span&gt; closed &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--json&lt;/span&gt; number,title,closedAt,labels &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s2"&gt;".[] | select(.closedAt &amp;gt; &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$SINCE&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;) | ..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Closed issues often represent shipped features with richer context than a commit message. The workflow creates content queue items for those too, with the same deduplication and labeling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting It Up in Your Repos
&lt;/h2&gt;

&lt;p&gt;You need three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A personal access token&lt;/strong&gt; (&lt;code&gt;CROSS_REPO_TOKEN&lt;/code&gt;) with &lt;code&gt;repo&lt;/code&gt; scope, stored as a repository secret&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The workflow file&lt;/strong&gt; at &lt;code&gt;.github/workflows/content-mine.yml&lt;/code&gt; in whichever repo you want to host the content queue&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The labels&lt;/strong&gt; created in that repo: &lt;code&gt;content-queue&lt;/code&gt;, &lt;code&gt;stage:mined&lt;/code&gt;, &lt;code&gt;stage:curated&lt;/code&gt;, &lt;code&gt;stage:ready&lt;/code&gt;, &lt;code&gt;stage:distributed&lt;/code&gt;, &lt;code&gt;stage:skipped&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Create the labels first:&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="k"&gt;for &lt;/span&gt;label &lt;span class="k"&gt;in &lt;/span&gt;content-queue stage:mined stage:curated stage:ready stage:distributed stage:skipped&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;gh label create &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--repo&lt;/span&gt; your-org/your-repo
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add the workflow, update the &lt;code&gt;REPOS&lt;/code&gt; list with your repos, and trigger it manually to verify.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Doesn't Do
&lt;/h2&gt;

&lt;p&gt;This pipeline handles discovery, not writing. It won't draft a blog post or compose a tweet. Those are separate steps that happen after curation, when you know the angle and audience for each piece.&lt;/p&gt;

&lt;p&gt;It also won't decide what's worth posting. That's the point. Automated mining with human curation gives you a reliable queue without losing editorial control.&lt;/p&gt;

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

</description>
      <category>githubactions</category>
      <category>automation</category>
      <category>content</category>
    </item>
    <item>
      <title>What a real AI-assisted PR looks like</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Thu, 02 Apr 2026 17:17:03 +0000</pubDate>
      <link>https://dev.to/jonesrussell/what-a-real-ai-assisted-pr-looks-like-1981</link>
      <guid>https://dev.to/jonesrussell/what-a-real-ai-assisted-pr-looks-like-1981</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;A lot of AI coding content ends too early.&lt;/p&gt;

&lt;p&gt;The model writes a patch. The patch looks plausible. A few tests pass. Someone posts a screenshot and calls it proof.&lt;/p&gt;

&lt;p&gt;That is not the part I care about.&lt;/p&gt;

&lt;p&gt;What I want to know is whether an AI-assisted change can survive the whole engineering process: audit, review, CI, static analysis, contract repair, docs drift, and merge.&lt;/p&gt;

&lt;p&gt;PR &lt;a href="https://github.com/waaseyaa/framework/pull/1022" rel="noopener noreferrer"&gt;#1022&lt;/a&gt; was the first pull request in &lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt; where that full chain played out end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Invariant
&lt;/h2&gt;

&lt;p&gt;The bug looked small: pipeline navigation in the admin SPA was non-deterministic.&lt;/p&gt;

&lt;p&gt;Whether the pipeline link showed up depended on whether a mount-time request happened to succeed. If a &lt;code&gt;board-config&lt;/code&gt; request failed for incidental reasons, the UI could act like the entity type had no pipeline at all.&lt;/p&gt;

&lt;p&gt;That is not just a UI bug. That is a contract problem.&lt;/p&gt;

&lt;p&gt;The invariant was simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Pipeline navigation visibility must be a pure function of &lt;code&gt;runtime.catalog&lt;/code&gt; actions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the catalog entry declares &lt;code&gt;board-config&lt;/code&gt;, show pipeline navigation. If it does not, do not. Request failures do not get to define capability truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Workflow
&lt;/h2&gt;

&lt;p&gt;The first step was not refactoring. It was audit.&lt;/p&gt;

&lt;p&gt;I had the model inspect only the affected surfaces, identify exactly where visibility depended on incidental request failure, state the minimal deterministic invariant, and identify the contract boundary that had to be restored.&lt;/p&gt;

&lt;p&gt;That exposed the real issue quickly: the problem was not only in the component. The admin runtime was also dropping &lt;code&gt;actions&lt;/code&gt; when it built the runtime catalog. Once that contract data disappeared, the UI fell back to probing with &lt;code&gt;runAction()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After that, the first patch was straightforward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;remove mount-time probing from &lt;code&gt;NavBuilder.vue&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;preserve &lt;code&gt;actions&lt;/code&gt; in the runtime catalog&lt;/li&gt;
&lt;li&gt;add tests proving visibility comes from declared catalog actions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If this were a typical AI coding story, that would have been the end.&lt;/p&gt;

&lt;p&gt;It was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where The PR Got Interesting
&lt;/h2&gt;

&lt;p&gt;Review found another component, &lt;code&gt;EntityViewNav.vue&lt;/code&gt;, still violating the same invariant. So the first fix was only partial.&lt;/p&gt;

&lt;p&gt;Then CI started surfacing deeper inconsistencies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;nuxi typecheck&lt;/code&gt; exposed an admin catalog type surface that no longer matched runtime reality&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;build:contracts&lt;/code&gt; failed because the admin contract build crossed a package boundary it should not have crossed&lt;/li&gt;
&lt;li&gt;PHPStan failed on a dispatcher contract mismatch elsewhere in the repo&lt;/li&gt;
&lt;li&gt;spec drift failed because the architecture docs now lagged behind the code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the part I think people miss about AI-assisted development.&lt;/p&gt;

&lt;p&gt;The value is not that the model gets you to the first patch faster. The value is whether you can keep following the consequences after that patch lands.&lt;/p&gt;

&lt;p&gt;A serious workflow does not treat those as annoying side failures. It treats them as the remediation chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Merged
&lt;/h2&gt;

&lt;p&gt;By the time PR #1022 merged, it had gone through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;invariant audit&lt;/li&gt;
&lt;li&gt;deterministic refactor plan&lt;/li&gt;
&lt;li&gt;initial implementation&lt;/li&gt;
&lt;li&gt;review-discovered residual drift&lt;/li&gt;
&lt;li&gt;follow-up invariant enforcement&lt;/li&gt;
&lt;li&gt;runtime contract restoration&lt;/li&gt;
&lt;li&gt;type-surface repair&lt;/li&gt;
&lt;li&gt;contract build repair&lt;/li&gt;
&lt;li&gt;PHPStan repair&lt;/li&gt;
&lt;li&gt;spec drift repair&lt;/li&gt;
&lt;li&gt;merge&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is why this PR matters to me.&lt;/p&gt;

&lt;p&gt;It is the first one in this workflow that exercised the whole model instead of just one slice of it.&lt;/p&gt;

&lt;p&gt;The model is not “AI writes code and I tidy it up.”&lt;/p&gt;

&lt;p&gt;The model is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;define the invariant&lt;/li&gt;
&lt;li&gt;constrain the agent to the governed surface&lt;/li&gt;
&lt;li&gt;audit before refactor&lt;/li&gt;
&lt;li&gt;keep the refactor minimal&lt;/li&gt;
&lt;li&gt;review adversarially for drift&lt;/li&gt;
&lt;li&gt;treat CI failures as evidence&lt;/li&gt;
&lt;li&gt;repair every broken surface the change exposes&lt;/li&gt;
&lt;li&gt;merge only when the whole chain is green&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I’m Learning
&lt;/h2&gt;

&lt;p&gt;The biggest lesson is that the first answer is rarely the interesting one.&lt;/p&gt;

&lt;p&gt;The interesting part is whether the system can keep reasoning correctly after the easy fix is in. Can it preserve the invariant through review? Can it repair the contract without weakening it? Can it follow the consequences into docs and verification instead of pretending those are separate tasks?&lt;/p&gt;

&lt;p&gt;That is where trust gets built.&lt;/p&gt;

&lt;p&gt;AI is awesome. More awesome than a lot of people realize. But not because it can one-shot production code from a prompt.&lt;/p&gt;

&lt;p&gt;It is awesome when you put it inside a disciplined engineering model and it helps you push a real change all the way through the system without losing the thread.&lt;/p&gt;

&lt;p&gt;That is what I am trying to build in public right now.&lt;/p&gt;

&lt;p&gt;Not a demo.&lt;/p&gt;

&lt;p&gt;An operating model.&lt;/p&gt;

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

</description>
      <category>ai</category>
      <category>workflow</category>
      <category>governance</category>
      <category>buildinginpublic</category>
    </item>
    <item>
      <title>Waaseyaa governance series</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Wed, 01 Apr 2026 13:12:43 +0000</pubDate>
      <link>https://dev.to/jonesrussell/waaseyaa-governance-series-2mlc</link>
      <guid>https://dev.to/jonesrussell/waaseyaa-governance-series-2mlc</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;This series covers how &lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt; — a PHP framework monorepo of 52 packages — went from accumulated architectural drift to a governed, verifiable implementation platform.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. &lt;a href="https://jonesrussell.github.io/blog/waaseyaa-governance-audit/" rel="noopener noreferrer"&gt;The audit that started everything&lt;/a&gt;
&lt;/h3&gt;

&lt;p&gt;What architectural drift looks like in a 52-package PHP monorepo, how the invariant-driven M1 audit was designed with frozen vocabularies before the first finding was written, what it found across five concern passes, and how M2 turned 36 findings into a dependency-ordered eight-milestone program.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Eight milestones, one chain
&lt;/h3&gt;

&lt;p&gt;How the remediation program ran from M3 through M8, how the exit-gate verified nothing drifted during execution, and how the program completion artifact locked the outputs as fixed inputs to everything downstream.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The conformance engine
&lt;/h3&gt;

&lt;p&gt;How M9 froze a canonical model, classified repo-wide drift, built a dependency-ordered execution DAG, and activated M10 batch execution — including the live code changes landing on &lt;code&gt;m10-batch-d1&lt;/code&gt; right now.&lt;/p&gt;

&lt;p&gt;Each post stands alone if you need a specific part. Start at Part 1 for the full story.&lt;/p&gt;

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

</description>
      <category>architecture</category>
      <category>platform</category>
      <category>php</category>
      <category>governance</category>
    </item>
    <item>
      <title>The audit that started everything: how Waaseyaa designed an invariant-driven architectural review</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Wed, 01 Apr 2026 13:12:35 +0000</pubDate>
      <link>https://dev.to/jonesrussell/the-audit-that-started-everything-how-waaseyaa-designed-an-invariant-driven-architectural-review-1ki</link>
      <guid>https://dev.to/jonesrussell/the-audit-that-started-everything-how-waaseyaa-designed-an-invariant-driven-architectural-review-1ki</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;This is Part 1 of the &lt;a href="https://jonesrussell.github.io/blog/waaseyaa-governance/" rel="noopener noreferrer"&gt;Waaseyaa Governance series&lt;/a&gt;. It covers how &lt;a href="https://github.com/waaseyaa/framework" rel="noopener noreferrer"&gt;Waaseyaa&lt;/a&gt; — a PHP framework monorepo of 52 packages — ran a formal invariant-driven architectural audit, what it found across five concern passes, and how Milestone 2 turned those findings into the eight-milestone remediation program covered in Part 2.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Familiarity with PHP Composer package mechanics (&lt;code&gt;extra&lt;/code&gt;, &lt;code&gt;autoload&lt;/code&gt;, provider discovery)&lt;/li&gt;
&lt;li&gt;Comfort reading GitHub issue-driven governance workflows&lt;/li&gt;
&lt;li&gt;No prior knowledge of Waaseyaa required&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Drift Looks Like at Scale
&lt;/h2&gt;

&lt;p&gt;Waaseyaa is a PHP framework organized into a 7-layer architecture across 52 Composer packages. The layers run from L0 (foundation infrastructure) up through L6 (interfaces — the admin SPA, SSR, and other user-facing surfaces). The constraint that makes the model useful is simple: packages may only import from their own layer or lower. Upward communication must go through sanctioned seams.&lt;/p&gt;

&lt;p&gt;That constraint is easy to state and hard to maintain. Feature work happens fast. A developer needs something from a higher layer, adds a dependency, moves on. A provider needs access to a kernel-composed service, reconstructs it locally instead. A CLI command gets implemented, never wired into the registered console surface. None of these are catastrophic individually. Over time they accumulate into a codebase where the architectural model and the actual dependency graph have quietly diverged.&lt;/p&gt;

&lt;p&gt;The standard response is ad hoc cleanup: fix things as you notice them, add notes to the CLAUDE.md, hope the team remembers. That works up to a point. It stops working when the divergence is widespread enough that you cannot trust the layer model as an enforceable invariant. At that point you need an audit — not as a one-time cleanup, but as a structured baseline that everything downstream can depend on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Designing the Audit Before Running It
&lt;/h2&gt;

&lt;p&gt;The critical decision in &lt;a href="https://github.com/waaseyaa/framework/issues/817" rel="noopener noreferrer"&gt;#817&lt;/a&gt; was to freeze the audit's vocabulary before the first finding was written.&lt;/p&gt;

&lt;p&gt;The concern model was frozen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;boundaries&lt;/code&gt; — package dependency edges and layer violations&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;contracts&lt;/code&gt; — interface and API contract gaps&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;testing&lt;/code&gt; — harness coverage and test quality&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docs-governance&lt;/code&gt; — specification drift and documentation alignment&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dx-tooling&lt;/code&gt; — developer experience and build tooling gaps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The layer model was frozen: L0-foundation through L6-interfaces, plus cross-layer for concerns that span multiple layers.&lt;/p&gt;

&lt;p&gt;The closed vocabularies were frozen: subsystem taxonomy (14 values), severity (critical / high / medium / low), remediation class (8 values: invariant-break, contract-gap, coverage-gap, governance-drift, docs-drift, tooling-gap, cleanup-candidate, framework-uplift), audit phase, and evidence sources.&lt;/p&gt;

&lt;p&gt;These are not just taxonomies. They are constraints on the audit's own output. A finding that does not fit one of the frozen remediation classes cannot be created. An issue with an ad hoc severity value is invalid. The vocabulary freeze meant every finding would be comparable, sortable, and clusterable without disambiguation work later.&lt;/p&gt;

&lt;p&gt;The scope was equally constrained:&lt;/p&gt;

&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;Framework repository only&lt;/li&gt;
&lt;li&gt;No downstream consumer apps&lt;/li&gt;
&lt;li&gt;No remediation work&lt;/li&gt;
&lt;li&gt;No finding issues until each pass rubric is stable&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;That last rule matters most. Finding issues were blocked until the pass rubric — the set of questions each pass would ask and the evidence format it would use — was stable. Without that gate, early findings would have been written under different assumptions than later ones, making the inventory inconsistent before M2 tried to cluster it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five Passes, Fixed Order
&lt;/h2&gt;

&lt;p&gt;M1 ran five passes sequentially. Each pass had its own concern, its own subsystem focus, and its own pass issue that owned the rubric before findings were created.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pass&lt;/th&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Focus&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;M1-boundaries&lt;/td&gt;
&lt;td&gt;Package dependency edges and layer violations&lt;/td&gt;
&lt;td&gt;L0–L6 layer graph&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M1-contracts&lt;/td&gt;
&lt;td&gt;Interface and API contract gaps&lt;/td&gt;
&lt;td&gt;Public surface correctness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M1-testing&lt;/td&gt;
&lt;td&gt;Harness coverage and test quality&lt;/td&gt;
&lt;td&gt;Foundation, kernel, integration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M1-docs-governance&lt;/td&gt;
&lt;td&gt;Specification drift&lt;/td&gt;
&lt;td&gt;Specs vs. implementation alignment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M1-dx-tooling&lt;/td&gt;
&lt;td&gt;Developer experience gaps&lt;/td&gt;
&lt;td&gt;CLI, build tooling, console surface&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The order was intentional. Boundary violations had to be catalogued first because contracts, testing, and docs findings often depend on knowing which package boundaries are already broken. You cannot reason about whether a contract is correctly expressed if the package expressing it is importing from the wrong layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What M1 Found
&lt;/h2&gt;

&lt;p&gt;M1 produced 36 finding issues (#823 through #858). A sample across concerns shows the range of what the audit captured.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Boundary violations were the most severe class.&lt;/strong&gt; &lt;a href="https://github.com/waaseyaa/framework/issues/823" rel="noopener noreferrer"&gt;#823&lt;/a&gt; caught a direct upward dependency in the API layer:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The API package declares waaseyaa/ssr as a package dependency even though the documented architecture places api in Layer 4 and ssr in Layer 6.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Evidence:&lt;/strong&gt; &lt;code&gt;packages/api/composer.json&lt;/code&gt; lines 39–50 declare &lt;code&gt;../ssr&lt;/code&gt; and require &lt;code&gt;waaseyaa/ssr&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is not an ambiguous finding. The layer model says Layer 4 cannot depend on Layer 6. The Composer manifest says it does. Remediation direction: untangle the dependency before treating the layer table as an enforceable invariant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Some findings were subtler topology problems.&lt;/strong&gt; &lt;a href="https://github.com/waaseyaa/framework/issues/824" rel="noopener noreferrer"&gt;#824&lt;/a&gt; caught a representation mismatch rather than a dependency edge:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;CLAUDE.md lists admin in the Layer 6 package table, while &lt;code&gt;packages/admin&lt;/code&gt; is a Node/Nuxt workspace with &lt;code&gt;package.json&lt;/code&gt; and no &lt;code&gt;composer.json&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The admin SPA exists outside the Composer package graph entirely. Any mechanical boundary check that reads from &lt;code&gt;vendor/composer/installed.json&lt;/code&gt; cannot see it. The finding was not that admin was in the wrong layer — it was that the layer model's topology representation was inconsistent about what counts as a package. Remediation direction: clarify whether the layer model governs all repo workspaces or only Composer packages, then align the representation accordingly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hidden composition roots were flagged as invariant breaks.&lt;/strong&gt; &lt;a href="https://github.com/waaseyaa/framework/issues/831" rel="noopener noreferrer"&gt;#831&lt;/a&gt; found the admin-surface provider reconstructing core security wiring from manifest storage rather than consuming a kernel-composed service:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;AdminSurfaceServiceProvider&lt;/code&gt; loads &lt;code&gt;storage/framework/packages.php&lt;/code&gt;, rebuilds a &lt;code&gt;PackageManifest&lt;/code&gt;, instantiates &lt;code&gt;AccessPolicyRegistry&lt;/code&gt;, and reconstructs an &lt;code&gt;EntityAccessHandler&lt;/code&gt; for its host wiring.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The architecture reserves cross-layer orchestration for the kernel composition root. An interface-layer provider that rebuilds access-policy wiring independently is a hidden composition root — one that will silently diverge from the kernel's version over time. Remediation direction: consume a kernel-composed access service or explicitly model admin-surface access bootstrapping as a sanctioned seam.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The testing pass found brittleness in the harness itself.&lt;/strong&gt; &lt;a href="https://github.com/waaseyaa/framework/issues/845" rel="noopener noreferrer"&gt;#845&lt;/a&gt; found foundation kernel tests coupled to implementation detail:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;packages/foundation/tests/Unit/Kernel/HttpKernelTest.php&lt;/code&gt; repeatedly uses &lt;code&gt;ReflectionMethod&lt;/code&gt;, &lt;code&gt;ReflectionProperty&lt;/code&gt;, and &lt;code&gt;setAccessible()&lt;/code&gt; to invoke private methods and mutate internal kernel state.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Tests that reach private state through reflection are not testing invariants — they are testing implementation. They pass when the internal structure is intact and fail when it is refactored, regardless of whether the public behavior changed. The audit classified this as a &lt;code&gt;cleanup-candidate&lt;/code&gt; with medium severity: real technical debt, but not an invariant break.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DX tooling pass found phantom CLI commands.&lt;/strong&gt; &lt;a href="https://github.com/waaseyaa/framework/issues/858" rel="noopener noreferrer"&gt;#858&lt;/a&gt; found a gap between what was implemented and what was reachable:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Implemented command classes exist for &lt;code&gt;queue:work&lt;/code&gt;, &lt;code&gt;queue:failed&lt;/code&gt;, &lt;code&gt;queue:retry&lt;/code&gt;, &lt;code&gt;queue:flush&lt;/code&gt;, &lt;code&gt;schedule:run&lt;/code&gt;, &lt;code&gt;schedule:list&lt;/code&gt;, &lt;code&gt;scaffold:auth&lt;/code&gt;, and &lt;code&gt;telescope:validate&lt;/code&gt; under &lt;code&gt;packages/cli/src/Command/*&lt;/code&gt;. &lt;code&gt;php bin/waaseyaa list --raw&lt;/code&gt; does not expose any of those commands.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The operations playbooks documented these commands as operational workflows. The actual CLI surface did not expose them. Any operator following the playbook would find their commands missing. This was classified as &lt;code&gt;tooling-gap&lt;/code&gt; with high severity: the gap between documented and actual behavior was wide enough to cause operational incidents.&lt;/p&gt;

&lt;h2&gt;
  
  
  From Findings to a Program: M2
&lt;/h2&gt;

&lt;p&gt;With 36 findings across five concerns, the question M2 had to answer was: how do you turn an inventory of problems into a sequence of work that closes them without creating new ones?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/waaseyaa/framework/issues/859" rel="noopener noreferrer"&gt;#859&lt;/a&gt; defined the M2 scope:&lt;/p&gt;

&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;cluster M1 findings #823–#858 into remediation themes&lt;/li&gt;
&lt;li&gt;derive a dependency-ordered remediation graph&lt;/li&gt;
&lt;li&gt;identify cross-layer sequencing constraints&lt;/li&gt;
&lt;li&gt;define uplift phases that preserve architectural invariants&lt;/li&gt;
&lt;li&gt;produce a milestone-ready remediation roadmap&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;Clustering findings into themes meant grouping by what needed to be stable before something else could be fixed. The boundary violations could not be the last thing addressed — they had to come first, because contract, testing, and governance work all depended on a stable layer model. The CLI tooling gaps could not be fixed before the kernel bootstrap paths that should have wired those commands were themselves corrected.&lt;/p&gt;

&lt;p&gt;M2 produced the eight-milestone structure: M3 (architectural base recovery), M4 (public-surface unification), M5 (verification lock-in), M6 (governance alignment), M7 (workflow ergonomics), M8 (implementation-surface alignment). Each milestone consumed its predecessor's outputs as fixed inputs. Each had sequencing constraints that prevented it from reaching into territory that belonged to a later milestone.&lt;/p&gt;

&lt;p&gt;The dependency ordering was not arbitrary. It followed from the finding inventory. Boundary violations in M3 had to stabilize before M4 could unify public surfaces — you cannot unify contracts across a broken layer graph. M5 verification lock-in depended on M4's stable surfaces to verify against. The chain was determined by the findings, not by preference.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Governance Scaffold
&lt;/h2&gt;

&lt;p&gt;The two-artifact pattern that every milestone used — a bootstrap gate and an execution umbrella — was itself a finding from M2's synthesis work. A remediation program that does not constrain its own scope will drift. Each milestone needs an explicit statement of what it can and cannot touch before execution starts.&lt;/p&gt;

&lt;p&gt;The bootstrap gate restates the dependency prerequisites, sequencing constraints, and exit criteria before any track begins. It is not a planning document — it is an activation gate. A milestone that cannot satisfy its bootstrap gate does not start.&lt;/p&gt;

&lt;p&gt;The execution umbrella owns the tracks and holds the exit criteria. When the umbrella's exit criteria are satisfied, the milestone closes. Nothing else triggers closure.&lt;/p&gt;

&lt;p&gt;Together, the two artifacts make the program self-describing and self-constraining. Any reviewer reading the issue chain can see what each milestone was allowed to do, what it actually did, and whether its exit criteria were met — without reading the code.&lt;/p&gt;

&lt;p&gt;That structure carried the program from M3 through M8. Part 2 covers how it ran, how it closed, and what it handed off to conformance work.&lt;/p&gt;

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

</description>
      <category>architecture</category>
      <category>platform</category>
      <category>php</category>
      <category>governance</category>
    </item>
    <item>
      <title>Build an eval harness for 184 AI agent prompts with promptfoo</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Mon, 30 Mar 2026 18:50:37 +0000</pubDate>
      <link>https://dev.to/jonesrussell/build-an-eval-harness-for-184-ai-agent-prompts-with-promptfoo-13ac</link>
      <guid>https://dev.to/jonesrussell/build-an-eval-harness-for-184-ai-agent-prompts-with-promptfoo-13ac</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/msitarzewski/agency-agents" rel="noopener noreferrer"&gt;Agency-agents&lt;/a&gt; is an open-source collection of 184 specialist AI agent prompts (&lt;a href="https://github.com/jonesrussell/agency-agents/tree/feat/eval-harness-clean" rel="noopener noreferrer"&gt;my fork with the eval harness&lt;/a&gt;). Backend architects, UX designers, historians, game developers. Each prompt is a detailed markdown file with identity, workflows, deliverable templates, and success metrics. But there's no way to know if any of them actually produce good output. You can build a &lt;a href="https://www.promptfoo.dev/" rel="noopener noreferrer"&gt;promptfoo&lt;/a&gt;-based eval harness that scores them automatically using LLM-as-judge, and the first run already found a real quality gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Agent Prompts Need Evals
&lt;/h2&gt;

&lt;p&gt;You can read an agent prompt and think it looks good. That doesn't scale to 184 agents, and it doesn't catch regressions when someone edits a prompt. You need a system that answers five questions every time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Did the agent complete the task?&lt;/li&gt;
&lt;li&gt;Did it follow its own defined workflow?&lt;/li&gt;
&lt;li&gt;Did it stay in character?&lt;/li&gt;
&lt;li&gt;Is the output actually useful?&lt;/li&gt;
&lt;li&gt;Is it safe and unbiased?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the &lt;strong&gt;eval flywheel&lt;/strong&gt;. Define scoring criteria, run agents against representative tasks, judge the outputs automatically, and turn failures into regression tests. The collection can only improve because every failure becomes a test case.&lt;/p&gt;

&lt;p&gt;The existing CI for agency-agents is a bash linter that checks if frontmatter fields exist. It can tell you an agent has a &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;description&lt;/code&gt;. It can't tell you the agent produces garbage output. What would it look like if it could?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Eval Architecture
&lt;/h2&gt;

&lt;p&gt;The eval harness lives in an &lt;code&gt;evals/&lt;/code&gt; directory at the repo root, self-contained with its own &lt;code&gt;package.json&lt;/code&gt;. It uses promptfoo to orchestrate three steps per test:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Load an agent's markdown file as the system prompt&lt;/li&gt;
&lt;li&gt;Send it a task from a per-category YAML file&lt;/li&gt;
&lt;li&gt;Score the output with a separate LLM acting as judge&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The judge scores on five criteria, each rated 1-5. An agent passes if the average is 3.5 or higher.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;evals/&lt;/span&gt;
  &lt;span class="s"&gt;promptfooconfig.yaml&lt;/span&gt;          &lt;span class="c1"&gt;# main config&lt;/span&gt;
  &lt;span class="s"&gt;rubrics/&lt;/span&gt;
    &lt;span class="s"&gt;universal.yaml&lt;/span&gt;              &lt;span class="c1"&gt;# 5 scoring criteria with anchors&lt;/span&gt;
  &lt;span class="s"&gt;tasks/&lt;/span&gt;
    &lt;span class="s"&gt;engineering.yaml&lt;/span&gt;            &lt;span class="c1"&gt;# tasks per category&lt;/span&gt;
    &lt;span class="s"&gt;design.yaml&lt;/span&gt;
    &lt;span class="s"&gt;academic.yaml&lt;/span&gt;
  &lt;span class="s"&gt;scripts/&lt;/span&gt;
    &lt;span class="s"&gt;extract-metrics.ts&lt;/span&gt;          &lt;span class="c1"&gt;# parse agent markdown into structured data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The harness doesn't modify any agent files. But it does need to understand them, which means parsing their markdown structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extract Success Metrics from Agent Prompts
&lt;/h2&gt;

&lt;p&gt;Each agent markdown file has a "Success Metrics" section with criteria like "API response times under 200ms" or "every historical claim includes a confidence level." These feed into the judge's rubric so it knows what good output looks like for each specific agent.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;extract-metrics.ts&lt;/code&gt; script parses agent files and pulls out three sections: success metrics, critical rules, and deliverable templates.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseAgentFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;AgentMetrics&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;frontmatter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;matter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;frontmatter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.md&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;frontmatter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;filePath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;successMetrics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;extractSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Success Metrics&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;criticalRules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;extractSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Critical Rules&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;deliverableFormat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;extractRawSection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Technical Deliverables&lt;/span&gt;&lt;span class="dl"&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;The section extraction handles emoji-prefixed headings (&lt;code&gt;## 🎯 Your Success Metrics&lt;/code&gt;) and nested sub-headings. It matches case-insensitively on the key phrase, not the exact heading text.&lt;/p&gt;

&lt;p&gt;Agent files are inconsistent in ways that matter here. Engineering agents have measurable KPIs ("zero critical vulnerabilities"). Design agents have vague ones ("CSS remains maintainable"). The eval system surfaces this inconsistency, which turns out to be useful feedback for prompt authors even before you look at scores.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the Scoring Rubric
&lt;/h2&gt;

&lt;p&gt;The universal rubric defines five criteria. Each one gets explicit anchor descriptions so the judge knows what a 1 versus a 5 looks like.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;criteria&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;task_completion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Task Completion&lt;/span&gt;
    &lt;span class="na"&gt;rubric&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;5 - Fully completed with all requested deliverables present and thorough&lt;/span&gt;
      &lt;span class="s"&gt;4 - Completed with minor gaps&lt;/span&gt;
      &lt;span class="s"&gt;3 - Partially completed; key elements missing&lt;/span&gt;
      &lt;span class="s"&gt;2 - Attempted but incomplete or off-target&lt;/span&gt;
      &lt;span class="s"&gt;1 - Did not attempt or completely failed&lt;/span&gt;

  &lt;span class="na"&gt;instruction_adherence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Instruction Adherence&lt;/span&gt;
    &lt;span class="na"&gt;rubric&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;The agent defines specific workflows and deliverable templates.&lt;/span&gt;
      &lt;span class="s"&gt;Score how well the output follows these defined processes.&lt;/span&gt;
      &lt;span class="s"&gt;5 - Closely follows defined workflow and templates&lt;/span&gt;
      &lt;span class="s"&gt;4 - Mostly follows with minor deviations&lt;/span&gt;
      &lt;span class="s"&gt;3 - Partially follows; some structure present but loosely applied&lt;/span&gt;
      &lt;span class="s"&gt;2 - Shows awareness but largely ignores defined formats&lt;/span&gt;
      &lt;span class="s"&gt;1 - Completely ignores the agent's defined workflow&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The remaining three criteria (&lt;code&gt;identity_consistency&lt;/code&gt;, &lt;code&gt;deliverable_quality&lt;/code&gt;, &lt;code&gt;safety&lt;/code&gt;) follow the same pattern. Each rubric can also include agent-specific context from the extracted metrics, so the judge evaluates against the agent's own standards. The question is whether the rubric actually produces meaningful differentiation in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write Category Tasks
&lt;/h2&gt;

&lt;p&gt;Each category gets a YAML file with representative tasks at different difficulty levels. Here's the academic category testing the Historian agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;acad-period-check&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Verify&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;historical&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;accuracy&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;of&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;passage"&lt;/span&gt;
  &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;I'm writing a novel set in 1347 Florence, just before the Black Death.&lt;/span&gt;
    &lt;span class="s"&gt;Here's a passage I need you to check for historical accuracy:&lt;/span&gt;

    &lt;span class="s"&gt;"Marco adjusted his cotton shirt and leather boots as he walked&lt;/span&gt;
    &lt;span class="s"&gt;through the cobblestone streets to the bank. He pulled out a few&lt;/span&gt;
    &lt;span class="s"&gt;paper bills to pay for a loaf of white bread and a cup of coffee&lt;/span&gt;
    &lt;span class="s"&gt;at the market stall."&lt;/span&gt;

    &lt;span class="s"&gt;Please identify any anachronisms and suggest corrections.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That passage has at least five anachronisms (paper bills, coffee, cotton availability, white bread, the banking details). A good historian agent should catch them all with source citations. A bad one will miss the subtle ones.&lt;/p&gt;

&lt;p&gt;The second task per category is harder: it requires the agent to use its full workflow, not just answer a question. That's where you find out if the prompt's workflow definition actually influences behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire Up promptfoo
&lt;/h2&gt;

&lt;p&gt;The config connects agents, tasks, and rubrics. Each test case loads an agent markdown file via &lt;code&gt;file://&lt;/code&gt;, sends a task prompt, and runs five LLM-as-judge assertions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;anthropic:messages:claude-haiku-4-5-20251001&lt;/span&gt;
    &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4096&lt;/span&gt;
      &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

&lt;span class="na"&gt;defaultTest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;anthropic:messages:claude-haiku-4-5-20251001&lt;/span&gt;

&lt;span class="na"&gt;tests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;backend-architect&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;REST&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;endpoint&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;design"&lt;/span&gt;
    &lt;span class="na"&gt;vars&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;agent_prompt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;file://../engineering/engineering-backend-architect.md&lt;/span&gt;
      &lt;span class="na"&gt;task&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;Design a user registration endpoint for Node.js Express&lt;/span&gt;
        &lt;span class="s"&gt;with PostgreSQL. Include schema, route, and validation.&lt;/span&gt;
    &lt;span class="na"&gt;assert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;llm-rubric&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;TASK COMPLETION: Did the agent produce a REST endpoint design&lt;/span&gt;
          &lt;span class="s"&gt;with database schema, API route, and validation?&lt;/span&gt;
          &lt;span class="s"&gt;5 - Fully completed with schema, route, validation, security&lt;/span&gt;
          &lt;span class="s"&gt;4 - Completed with minor gaps&lt;/span&gt;
          &lt;span class="s"&gt;3 - Partially completed; key elements missing&lt;/span&gt;
          &lt;span class="s"&gt;2 - Attempted but incomplete&lt;/span&gt;
          &lt;span class="s"&gt;1 - Did not address the task&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both the agent model and the judge model use Haiku. Using the same model family for both is a trade-off: it's cheap but introduces potential self-preference bias. For a proof-of-concept, the cost savings win. For production evals, you'd want the judge on a different model family.&lt;/p&gt;

&lt;h2&gt;
  
  
  Score Tables: 5 Out of 6 Passed
&lt;/h2&gt;

&lt;p&gt;Three agents, two tasks each, five criteria per task. Here are the actual scores from the first run.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backend Architect
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;REST Endpoint&lt;/th&gt;
&lt;th&gt;Scaling Review&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Task Completion&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instruction Adherence&lt;/td&gt;
&lt;td&gt;4.8&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity Consistency&lt;/td&gt;
&lt;td&gt;4.7&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deliverable Quality&lt;/td&gt;
&lt;td&gt;4.8&lt;/td&gt;
&lt;td&gt;4.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safety&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Result&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;PASS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;PASS&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Near-perfect across the board. The scaling review scored a perfect 5.0 on instruction adherence because it followed the agent's System Architecture Specification template exactly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Historian
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Florence 1347&lt;/th&gt;
&lt;th&gt;Mali Empire&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Task Completion&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instruction Adherence&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;td&gt;4.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity Consistency&lt;/td&gt;
&lt;td&gt;4.8&lt;/td&gt;
&lt;td&gt;4.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deliverable Quality&lt;/td&gt;
&lt;td&gt;4.8&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safety&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Result&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;PASS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;PASS&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Mali Empire reconstruction earned a perfect 5.0 on deliverable quality for grounding trade goods, currency, and daily life in evidence while clearly marking where it was extrapolating.&lt;/p&gt;

&lt;h3&gt;
  
  
  UX Architect
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Landing Page&lt;/th&gt;
&lt;th&gt;Responsive Audit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Task Completion&lt;/td&gt;
&lt;td&gt;3.5&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instruction Adherence&lt;/td&gt;
&lt;td&gt;4.5&lt;/td&gt;
&lt;td&gt;4.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity Consistency&lt;/td&gt;
&lt;td&gt;4.5&lt;/td&gt;
&lt;td&gt;3.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deliverable Quality&lt;/td&gt;
&lt;td&gt;4.5&lt;/td&gt;
&lt;td&gt;4.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safety&lt;/td&gt;
&lt;td&gt;5.0&lt;/td&gt;
&lt;td&gt;4.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Result&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;FAIL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;PASS&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two different problems in one agent. The landing page failed on task completion. The responsive audit passed overall but scored 3.5 on identity consistency: it solved the problem competently but didn't sound like "ArchitectUX." Numbers tell you something is wrong. You need the actual outputs to understand what.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Agents Actually Produced
&lt;/h2&gt;

&lt;p&gt;Here's what the Historian produced when asked to check the 1347 Florence passage:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Agent output (excerpt):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Anachronism&lt;/th&gt;
&lt;th&gt;Why It's Wrong&lt;/th&gt;
&lt;th&gt;What's Accurate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Paper bills&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Paper money didn't circulate in Florence until the 15th-16th centuries. Florence in 1347 used &lt;strong&gt;gold florins&lt;/strong&gt; and &lt;strong&gt;silver denari&lt;/strong&gt;, plus credit instruments like bills of exchange for large transactions.&lt;/td&gt;
&lt;td&gt;Marco would carry a &lt;strong&gt;leather purse with gold or silver coins&lt;/strong&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Coffee&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Coffee was unknown in Europe until the 16th-17th century. It was an Ottoman/Arab beverage.&lt;/td&gt;
&lt;td&gt;Marco would drink &lt;strong&gt;wine&lt;/strong&gt; (even at breakfast), &lt;strong&gt;ale&lt;/strong&gt;, or &lt;strong&gt;small beer&lt;/strong&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cotton shirt&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cotton was rare and expensive in 1347 Florence. Imported from the Levant and Egypt via Venice. Only the wealthy wore it.&lt;/td&gt;
&lt;td&gt;Marco would wear &lt;strong&gt;linen&lt;/strong&gt; (if middle-class) or &lt;strong&gt;wool&lt;/strong&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;White bread&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Affordable white bread is a post-industrial product. In 1347, white bread was a &lt;strong&gt;luxury&lt;/strong&gt; requiring expensive sifting.&lt;/td&gt;
&lt;td&gt;Marco would eat &lt;strong&gt;dark rye or barley bread&lt;/strong&gt;, or if prosperous, &lt;strong&gt;mixed grain bread&lt;/strong&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;/blockquote&gt;

&lt;p&gt;The judge's reasoning for scoring task completion at 5.0:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"The output comprehensively identifies all major anachronisms (paper bills, coffee, white bread, cotton shirt, horse-drawn carriages) with detailed explanations. It provides historically accurate alternatives for each item (gold florins/silver denari, wine/ale, dark bread, linen, pack animals). The response goes beyond the basic requirement by including a period authenticity report, sensory details, and a before/after revision example."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now compare that to the UX Architect's failed test. The task asked for a CSS design system foundation for a SaaS landing page. The agent started strong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--bg-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--bg-secondary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#f8fafc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--text-primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#1e293b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c"&gt;/* ... full typography scale, spacing, shadows ... */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the output hit the token limit and cut off mid-sentence in the button styling section. The hero section, features grid, pricing table, and footer layouts were never delivered. The judge caught it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"The agent delivered approximately 60-70% of the requested deliverables. It cuts off mid-sentence in the button styling section and does not include the promised layout structure for hero, features grid, pricing table, and footer sections."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's a 3.5 on task completion, right at the pass threshold. It pulled the average below 3.5 for a FAIL. The fix is straightforward: either increase the token limit for design agents that produce verbose CSS, or tune the prompt to prioritize layout structure over exhaustive variable definitions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Score Spread Tells You Where to Focus
&lt;/h2&gt;

&lt;p&gt;A Backend Architect scoring 4.7-5.0 across everything means the prompt is well-calibrated. A UX Architect bouncing between 3.5 and 5.0 means the prompt works for some tasks but breaks down on others. That's exactly the kind of signal prompt authors need.&lt;/p&gt;

&lt;p&gt;Total cost for this run: 166K tokens, roughly $0.05 at Haiku pricing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run It Yourself
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;-b&lt;/span&gt; feat/eval-harness-clean https://github.com/jonesrussell/agency-agents.git
&lt;span class="nb"&gt;cd &lt;/span&gt;agency-agents/evals
npm &lt;span class="nb"&gt;install
export &lt;/span&gt;&lt;span class="nv"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-key
npx promptfoo &lt;span class="nb"&gt;eval&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The eval takes about two minutes. After it finishes, open the interactive results viewer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx promptfoo view
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get a browser UI showing each test case, the agent's full output, and the judge's reasoning for every score.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Comes Next
&lt;/h2&gt;

&lt;p&gt;This proof-of-concept covers 3 of 184 agents. The eval harness lives on &lt;a href="https://github.com/jonesrussell/agency-agents/tree/feat/eval-harness-clean" rel="noopener noreferrer"&gt;my fork&lt;/a&gt; with an &lt;a href="https://github.com/msitarzewski/agency-agents/pull/371" rel="noopener noreferrer"&gt;upstream PR&lt;/a&gt; pending. The roadmap has three milestones:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;M1 (submitted):&lt;/strong&gt; Eval harness with 3 proof-of-concept agents&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;M2:&lt;/strong&gt; Benchmark dataset covering all 184 agents with baseline scores&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;M3:&lt;/strong&gt; CI quality gate so PRs that degrade an agent are automatically flagged&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full 184-agent suite would cost about $1.50 per run. Run it nightly and you have a quality trendline. Run it on PRs and you have a regression gate. The collection can only get better.&lt;/p&gt;

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

</description>
      <category>promptfoo</category>
      <category>evals</category>
      <category>aiagents</category>
      <category>llm</category>
    </item>
    <item>
      <title>Generate Open Graph images with Playwright and an HTML template</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Sun, 29 Mar 2026 19:53:47 +0000</pubDate>
      <link>https://dev.to/jonesrussell/generate-open-graph-images-with-playwright-and-an-html-template-5d8l</link>
      <guid>https://dev.to/jonesrussell/generate-open-graph-images-with-playwright-and-an-html-template-5d8l</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;Every blog post you share on LinkedIn or X gets a preview card. Without an &lt;code&gt;og:image&lt;/code&gt;, the platform picks whatever it finds or shows nothing. This post covers how to generate branded OG images automatically from an HTML template using &lt;a href="https://playwright.dev/" rel="noopener noreferrer"&gt;Playwright&lt;/a&gt; screenshots, so every post gets a consistent social card without opening a design tool. The full source is in the &lt;a href="https://github.com/jonesrussell/blog/tree/main/scripts" rel="noopener noreferrer"&gt;blog repo&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://nodejs.org/" rel="noopener noreferrer"&gt;Node.js&lt;/a&gt; 18+&lt;/li&gt;
&lt;li&gt;Playwright (&lt;code&gt;npm install playwright&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/jonmayo/gray-matter" rel="noopener noreferrer"&gt;gray-matter&lt;/a&gt; for frontmatter parsing (&lt;code&gt;npm install gray-matter&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A static site generator that uses frontmatter (this post uses &lt;a href="https://gohugo.io/" rel="noopener noreferrer"&gt;Hugo&lt;/a&gt;, but the approach works with any SSG)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Design the HTML template
&lt;/h2&gt;

&lt;p&gt;The OG image spec is 1200x630 pixels. Create an HTML file that renders at exactly that size:&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&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;style&amp;gt;&lt;/span&gt;
  &lt;span class="o"&gt;*&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="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="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;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1200px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;630px&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;-apple-system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BlinkMacSystemFont&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;'Segoe UI'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Roboto&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;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&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;1200px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;630px&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="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;135deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;{{&lt;/span&gt;&lt;span class="n"&gt;gradient&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nt"&gt;display&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;flex&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nt"&gt;flex-direction&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;column&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nt"&gt;justify-content&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;center&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nt"&gt;padding&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="err"&gt;60&lt;/span&gt;&lt;span class="nt"&gt;px&lt;/span&gt; &lt;span class="err"&gt;80&lt;/span&gt;&lt;span class="nt"&gt;px&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nt"&gt;position&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;relative&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nt"&gt;overflow&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;hidden&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
  &lt;span class="err"&gt;}&lt;/span&gt;
  &lt;span class="nc"&gt;.badge&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;inline-block&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="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;0.2&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="no"&gt;white&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;14px&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;700&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;6px&lt;/span&gt; &lt;span class="m"&gt;14px&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;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;text-transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;uppercase&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;letter-spacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.5px&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;24px&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="n"&gt;fit-content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nc"&gt;.title&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="no"&gt;white&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="err"&gt;{{&lt;/span&gt;&lt;span class="n"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;px&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nt"&gt;font-weight&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="err"&gt;800&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nt"&gt;line-height&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="err"&gt;1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;2&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="nt"&gt;max-width&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="err"&gt;90&lt;/span&gt;&lt;span class="o"&gt;%;&lt;/span&gt;
  &lt;span class="err"&gt;}&lt;/span&gt;
  &lt;span class="nc"&gt;.author&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="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="m"&gt;0.7&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;16px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;margin-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;24px&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;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"badge"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{series}}&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;"title"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{title}}&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;"author"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{author}}&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;The template uses &lt;code&gt;{{placeholder}}&lt;/code&gt; tokens that the script replaces at runtime. The gradient, font size, series badge, title, and author are all injected per post. You can add decorative elements like semi-transparent circles for visual interest without complicating the layout.&lt;/p&gt;

&lt;h2&gt;
  
  
  Map series to color gradients
&lt;/h2&gt;

&lt;p&gt;Posts in a series should share a visual identity. A simple lookup object handles this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SERIES_MAP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;waaseyaa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gradient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#667eea, #764ba2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Waaseyaa&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;php-fig-standards&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gradient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#0f9b8e, #1a5276&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PHP-FIG Standards&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;codified-context&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gradient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#f093fb, #f5576c&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Codified Context&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production-linux&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gradient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#e65100, #bf360c&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Production Linux&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;_default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;          &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;gradient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#2c3e50, #4ca1af&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Blog&lt;/span&gt;&lt;span class="dl"&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;Posts without a series get the &lt;code&gt;_default&lt;/code&gt; gradient. The label appears in the badge above the title.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale the font size to the title length
&lt;/h2&gt;

&lt;p&gt;Long titles need smaller text to avoid overflow. Three breakpoints cover most cases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getFontSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&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="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;64&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="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;36&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;Short punchy titles get 64px. Medium titles drop to 48px. Anything over 80 characters gets 36px, which still reads well at the 1200px card width.&lt;/p&gt;

&lt;h2&gt;
  
  
  Walk the content directory for post metadata
&lt;/h2&gt;

&lt;p&gt;The script needs each post's slug, title, and series. Walk the content directory and parse frontmatter with &lt;code&gt;gray-matter&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;matter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gray-matter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;findPosts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;postsDir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__dirname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;..&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;withFileTypes&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="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;full&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isDirectory&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;index.md&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;matter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&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="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cleanSlug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;2018&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;2019&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;201C&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;201D&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cleanSlug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;series&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;series&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;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="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postsDir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;posts&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;replace&lt;/code&gt; on line 12 strips curly quotes from slugs. YAML parsers sometimes convert straight quotes to smart quotes, and those characters in a filename cause the image to silently not match the post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Screenshot each post with Playwright
&lt;/h2&gt;

&lt;p&gt;Launch a headless browser, set the viewport to 1200x630, inject each post's values into the template, and screenshot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setViewportSize&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;630&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getSeriesInfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;series&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fontSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getFontSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;template&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\{\{&lt;/span&gt;&lt;span class="sr"&gt;gradient&lt;/span&gt;&lt;span class="se"&gt;\}\}&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gradient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\{\{&lt;/span&gt;&lt;span class="sr"&gt;series&lt;/span&gt;&lt;span class="se"&gt;\}\}&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\{\{&lt;/span&gt;&lt;span class="sr"&gt;title&lt;/span&gt;&lt;span class="se"&gt;\}\}&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\{\{&lt;/span&gt;&lt;span class="sr"&gt;fontSize&lt;/span&gt;&lt;span class="se"&gt;\}\}&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\{\{&lt;/span&gt;&lt;span class="sr"&gt;author&lt;/span&gt;&lt;span class="se"&gt;\}\}&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Russell Jones&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;load&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`static/images/og/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.png`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;png&lt;/span&gt;&lt;span class="dl"&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;await&lt;/span&gt; &lt;span class="nx"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&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;setContent&lt;/code&gt; + &lt;code&gt;screenshot&lt;/code&gt; pattern avoids the overhead of navigating to a URL. Playwright renders the HTML string directly. Each image takes under 100ms, so even a blog with 100+ posts finishes in seconds.&lt;/p&gt;

&lt;p&gt;Note the HTML escaping on the title: &lt;code&gt;&amp;amp;&lt;/code&gt; and &lt;code&gt;&amp;lt;&lt;/code&gt; would break the template markup if injected raw.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cache images with a template hash
&lt;/h2&gt;

&lt;p&gt;Regenerating every image on every run wastes time. Hash the template file and compare against the last run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;templateHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;template&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HASH_FILE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;OUTPUT_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.og-template-hash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;regenerateAll&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;force&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;force&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;HASH_FILE&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;oldHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;HASH_FILE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&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="nx"&gt;oldHash&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;templateHash&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Template changed — regenerating all images&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;regenerateAll&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;toGenerate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;regenerateAll&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;OUTPUT_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.png`&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;

&lt;span class="c1"&gt;// After generation:&lt;/span&gt;
&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;HASH_FILE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;templateHash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the template hasn't changed, only posts without an existing image get generated. Change the template and every image rebuilds. A &lt;code&gt;--force&lt;/code&gt; flag overrides the cache for manual regeneration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire it into your build
&lt;/h2&gt;

&lt;p&gt;Add a task that runs the script before your static site build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Taskfile.yml&lt;/span&gt;
&lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;og:generate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;desc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Generate OG images for all posts&lt;/span&gt;
    &lt;span class="na"&gt;cmds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;node scripts/generate-og-images.js&lt;/span&gt;

  &lt;span class="na"&gt;og:force&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;desc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Force-regenerate all OG images&lt;/span&gt;
    &lt;span class="na"&gt;cmds&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;node scripts/generate-og-images.js --force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hugo auto-detects OG images by convention when they're at &lt;code&gt;static/images/og/{slug}.png&lt;/code&gt;. Your &lt;code&gt;hugo.toml&lt;/code&gt; or theme template just needs to reference that path in the &lt;code&gt;og:image&lt;/code&gt; meta tag.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the generated images look like
&lt;/h2&gt;

&lt;p&gt;Here are three examples from this blog showing different series gradients and font sizes in action.&lt;/p&gt;

&lt;p&gt;A standalone post gets the default slate-to-cyan gradient:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fjonesrussell.github.io%2Fblog%2Fdevops%2Fgenerating-og-images-playwright%2F%2Fblog%2Fimages%2Fog%2Fhugo-devto-sync-engine.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fjonesrussell.github.io%2Fblog%2Fdevops%2Fgenerating-og-images-playwright%2F%2Fblog%2Fimages%2Fog%2Fhugo-devto-sync-engine.png" alt="Default gradient OG image for a standalone blog post" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A post in the Codified Context series gets the pink gradient with the series badge:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fjonesrussell.github.io%2Fblog%2Fdevops%2Fgenerating-og-images-playwright%2F%2Fblog%2Fimages%2Fog%2Fcodified-context-constitution.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fjonesrussell.github.io%2Fblog%2Fdevops%2Fgenerating-og-images-playwright%2F%2Fblog%2Fimages%2Fog%2Fcodified-context-constitution.png" alt="Codified Context series OG image with pink gradient" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And a PHP-FIG Standards post gets the teal-to-blue gradient:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fjonesrussell.github.io%2Fblog%2Fdevops%2Fgenerating-og-images-playwright%2F%2Fblog%2Fimages%2Fog%2Fpsr-7-http-message-interfaces.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fjonesrussell.github.io%2Fblog%2Fdevops%2Fgenerating-og-images-playwright%2F%2Fblog%2Fimages%2Fog%2Fpsr-7-http-message-interfaces.png" alt="PHP-FIG Standards series OG image with teal gradient" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every image is 1200x630, uses the same layout, and the font size scales automatically based on title length. The series badge and gradient are the only things that change between posts in a series, which gives each series a consistent visual identity on social feeds.&lt;/p&gt;

&lt;p&gt;You can see the full implementation in the blog repo: &lt;a href="https://github.com/jonesrussell/blog/blob/main/scripts/generate-og-images.js" rel="noopener noreferrer"&gt;&lt;code&gt;generate-og-images.js&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/jonesrussell/blog/blob/main/scripts/og-template.html" rel="noopener noreferrer"&gt;&lt;code&gt;og-template.html&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

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

</description>
      <category>playwright</category>
      <category>node</category>
      <category>hugo</category>
      <category>seo</category>
    </item>
    <item>
      <title>Build a Hugo-to-Dev.to sync engine in Go</title>
      <dc:creator>Russell Jones</dc:creator>
      <pubDate>Sun, 29 Mar 2026 19:33:58 +0000</pubDate>
      <link>https://dev.to/jonesrussell/build-a-hugo-to-devto-sync-engine-in-go-o1b</link>
      <guid>https://dev.to/jonesrussell/build-a-hugo-to-devto-sync-engine-in-go-o1b</guid>
      <description>&lt;p&gt;Ahnii!&lt;/p&gt;

&lt;p&gt;Publishing on your own blog and cross-posting to &lt;a href="https://dev.to"&gt;Dev.to&lt;/a&gt; means maintaining two copies of every article. This post covers how to build a sync engine in Go that pushes Hugo posts to Dev.to via the &lt;a href="https://developers.forem.com/api/v1" rel="noopener noreferrer"&gt;Forem API&lt;/a&gt;, keeps canonical URLs pointed at your blog, and handles the API quirks that the documentation doesn't warn you about. The full source is in &lt;a href="https://github.com/jonesrussell/blog/tree/main/tools/devto-sync" rel="noopener noreferrer"&gt;&lt;code&gt;tools/devto-sync/&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Go 1.21+&lt;/li&gt;
&lt;li&gt;A &lt;a href="https://dev.to/settings/extensions"&gt;Dev.to API key&lt;/a&gt; (Settings &amp;gt; Extensions &amp;gt; Generate API Key)&lt;/li&gt;
&lt;li&gt;A Hugo blog with posts using page bundles (&lt;code&gt;content/posts/category/slug/index.md&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How frontmatter drives sync behavior
&lt;/h2&gt;

&lt;p&gt;The sync engine reads Hugo frontmatter to decide what to do with each post. Three fields control everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;My&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Post"&lt;/span&gt;
&lt;span class="na"&gt;draft&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;devto&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;devto_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12345&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;devto&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Set to &lt;code&gt;false&lt;/code&gt; to exclude a post from sync entirely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;devto_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Links the local post to a Dev.to article. Zero means create, nonzero means update&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;draft&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Maps directly to Dev.to's published state: &lt;code&gt;draft: true&lt;/code&gt; unpublishes the article&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The eligibility check is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ShouldSync&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="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DevtoEnabled&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Archived&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;DevtoEnabled&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;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Devto&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Devto&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Posts opt in by default. Setting &lt;code&gt;devto: false&lt;/code&gt; or &lt;code&gt;archived: true&lt;/code&gt; excludes them. This means your existing posts start syncing the moment you run the tool, so add &lt;code&gt;devto: false&lt;/code&gt; to anything you want to keep off Dev.to. (&lt;a href="https://github.com/jonesrussell/blog/blob/main/tools/devto-sync/internal/hugo/content.go" rel="noopener noreferrer"&gt;source: &lt;code&gt;content.go&lt;/code&gt;&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  The push flow: create, update, or deduplicate
&lt;/h2&gt;

&lt;p&gt;The core of the engine is a single &lt;code&gt;PushPost&lt;/code&gt; method that handles all three cases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;PushPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;hugo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dryRun&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;devto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;canonicalURL&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%s/%s/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;devto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArticleCreate&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Article&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;devto&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ArticleBody&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Title&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;        &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;BodyMarkdown&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Published&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Draft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Tags&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;         &lt;span class="n"&gt;sanitizeTags&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tags&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;CanonicalURL&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;canonicalURL&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="c"&gt;// Has a devto_id? Update.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DevtoID&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UpdateArticle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DevtoID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// No devto_id, but an article with this canonical URL already exists?&lt;/span&gt;
    &lt;span class="c"&gt;// Update it instead of creating a duplicate.&lt;/span&gt;
    &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FindByCanonicalURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;canonicalURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UpdateArticle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c"&gt;// No match anywhere. Create.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateArticle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&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 canonical URL check prevents duplicates. If a previous push created an article but the &lt;code&gt;devto_id&lt;/code&gt; writeback PR hasn't merged yet, the next push finds the existing article by its canonical URL and updates it instead of creating a second copy. (&lt;a href="https://github.com/jonesrussell/blog/blob/main/tools/devto-sync/internal/sync/engine.go" rel="noopener noreferrer"&gt;source: &lt;code&gt;engine.go&lt;/code&gt;&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Tag sanitization: hyphens and the 4-tag limit
&lt;/h2&gt;

&lt;p&gt;Dev.to silently rejects tags containing hyphens. The tag &lt;code&gt;php-fig&lt;/code&gt; fails with no useful error. Strip them before sending:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&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="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tags&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tags&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sanitized&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReplaceAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"-"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sanitized&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sanitized&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;if&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="m"&gt;4&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;php-fig&lt;/code&gt; becomes &lt;code&gt;phpfig&lt;/code&gt;, and any post with more than four tags gets truncated. Dev.to enforces the four-tag limit server-side, but trimming locally gives you control over which four survive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The FlexTags problem: one field, two formats
&lt;/h2&gt;

&lt;p&gt;The Forem API returns the &lt;code&gt;tag_list&lt;/code&gt; field as an array of strings on list endpoints (&lt;code&gt;GET /api/articles/me/all&lt;/code&gt;) but as a comma-separated string on create/update responses. Unmarshalling into &lt;code&gt;[]string&lt;/code&gt; works for one and breaks on the other.&lt;/p&gt;

&lt;p&gt;A custom JSON unmarshaler handles both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;FlexTags&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ft&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;FlexTags&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;UnmarshalJSON&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;arr&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;arr&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;", "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Try the array first. If that fails, split the string on &lt;code&gt;", "&lt;/code&gt; (comma-space, not just comma). This handles every response the API throws at you without branching in the calling code. (&lt;a href="https://github.com/jonesrussell/blog/blob/main/tools/devto-sync/internal/devto/types.go" rel="noopener noreferrer"&gt;source: &lt;code&gt;types.go&lt;/code&gt;&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Rate limiting: the real numbers
&lt;/h2&gt;

&lt;p&gt;The Forem API docs say 10 requests per 30 seconds. In practice, creates are throttled harder. Three creates per 30 seconds is the safe ceiling. Separate read and write budgets with a token bucket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;rateLimiter&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;max&lt;/span&gt;      &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;tokens&lt;/span&gt;   &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;
    &lt;span class="n"&gt;last&lt;/span&gt;     &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;rateLimiter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Since&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&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="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&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;The client uses two limiters: one for creates (&lt;code&gt;3/30s&lt;/code&gt;) and one for reads (&lt;code&gt;10/30s&lt;/code&gt;). On a 429 response, the client reads the &lt;code&gt;Retry-After&lt;/code&gt; header and sleeps before retrying once. (&lt;a href="https://github.com/jonesrussell/blog/blob/main/tools/devto-sync/internal/devto/client.go" rel="noopener noreferrer"&gt;source: &lt;code&gt;client.go&lt;/code&gt;&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Dev.to also has a separate "title already used in the last 5 minutes" rate limit that fires if you create, delete, and recreate an article with the same title. There is no workaround except waiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Transforming Hugo content for Dev.to
&lt;/h2&gt;

&lt;p&gt;Hugo shortcodes like &lt;code&gt;{{&amp;lt;/* relref "post-slug" */&amp;gt;}}&lt;/code&gt; mean nothing on Dev.to. The transform step converts them to full URLs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;warnings&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;hugo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TransformForDevto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;postPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The transformer resolves &lt;code&gt;relref&lt;/code&gt; shortcodes to absolute URLs using the blog's base URL, converts relative image paths to absolute URLs, and strips unknown shortcodes with a warning. Each warning is logged so you can fix shortcodes that don't have a Dev.to equivalent. (&lt;a href="https://github.com/jonesrussell/blog/blob/main/tools/devto-sync/internal/hugo/transform.go" rel="noopener noreferrer"&gt;source: &lt;code&gt;transform.go&lt;/code&gt;&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding orphan articles with match
&lt;/h2&gt;

&lt;p&gt;Old RSS imports or manual cross-posts can leave articles on Dev.to that hold your canonical URL but have no &lt;code&gt;devto_id&lt;/code&gt; in your frontmatter. The &lt;code&gt;match&lt;/code&gt; command finds them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/devto-sync match
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It pulls all your Dev.to articles, then runs two passes against your local posts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Canonical URL match&lt;/strong&gt;: compares &lt;code&gt;article.canonical_url&lt;/code&gt; against the expected &lt;code&gt;{baseURL}/{slug}/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Title match (fallback)&lt;/strong&gt;: case-insensitive title comparison for articles without canonical URLs&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Output is tab-separated for easy scripting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CANONICAL   my-post-slug    12345   My Post Title
TITLE       other-post      67890   Other Post
NONE        new-post        0       no match found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Matched IDs can be written back to frontmatter with a force pull, linking the local post to its Dev.to counterpart without creating a duplicate. (&lt;a href="https://github.com/jonesrussell/blog/blob/main/tools/devto-sync/cmd/match.go" rel="noopener noreferrer"&gt;source: &lt;code&gt;match.go&lt;/code&gt;&lt;/a&gt;)&lt;/p&gt;

&lt;h2&gt;
  
  
  Automated sync with GitHub Actions
&lt;/h2&gt;

&lt;p&gt;The sync runs automatically after every deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Push changed posts to Dev.to&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bin/devto-sync push --all&lt;/span&gt;
  &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;DEVTO_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DEVTO_API_KEY }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After creating new articles, the tool writes &lt;code&gt;devto_id&lt;/code&gt; back into the post frontmatter. A second workflow step opens a PR with those changes so the IDs are tracked in git. The next push uses the IDs for updates instead of creates.&lt;/p&gt;

&lt;p&gt;This closes the loop: push to main, deploy triggers, sync runs, IDs come back as a PR. (&lt;a href="https://github.com/jonesrussell/blog/blob/main/.github/workflows/devto-sync.yml" rel="noopener noreferrer"&gt;source: &lt;code&gt;devto-sync.yml&lt;/code&gt;&lt;/a&gt;)&lt;/p&gt;

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

</description>
      <category>devto</category>
      <category>hugo</category>
      <category>go</category>
      <category>automation</category>
    </item>
  </channel>
</rss>
