{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "dout.dev — Latest Posts",
  "home_page_url": "https://dout.dev/",
  "feed_url": "https://dout.dev/feed.json",
  "description": "Vanilla-first static blog with WCAG 2.2 AA accessibility",
  "language": "en",
  "authors": [
    {
      "name": "Emiliano \"pixu1980\" Pisu",
      "url": "https://dout.dev"
    }
  ],
  "items": [
    {
      "id": "https://dout.dev/posts/2026-06-16-the-end-of-borrowed-abstractions.html",
      "url": "https://dout.dev/posts/2026-06-16-the-end-of-borrowed-abstractions.html",
      "title": "The End of Borrowed Abstractions",
      "summary": "The reflex that shaped software",
      "content_html": "<h2 id=\"the-reflex-that-shaped-software\" tabindex=\"0\" data-toc-anchor=\"true\">The reflex that shaped software</h2>\n<p>For the last twenty years, a large part of software development has been guided by a very simple reflex: when the platform feels too low-level, we install an abstraction.</p>\n<p>At first, this was not only reasonable, it was necessary. Platforms were incomplete, browser behavior was inconsistent, standard libraries were often limited, and teams needed shared mental models to ship products at a human pace. Frameworks and libraries gave us vocabulary, structure, conventions, and a way to avoid solving the same problems over and over again.</p>\n<p>The problem is that, over time, this useful reflex became almost automatic. Need UI composition? Install a framework. Need routing, state management, validation, forms, styling, animation, authentication glue, build orchestration, dependency injection, data fetching, queues, command handling, or configuration management? Install something.</p>\n<p>Eventually, the product starts becoming only one part of the system. The other part is a carefully negotiated dependency graph, where every new feature has to pass through somebody else's assumptions, release cycle, architecture, migration path, documentation quality, security posture, and long-term maintenance choices.</p>\n<p>This is not a frontend problem. It is not a JavaScript problem. It is not a React problem. It is not even a framework problem. It is a software problem.</p>\n<p>Every ecosystem has its own version of this story. JavaScript has frontend frameworks and npm. Python has web frameworks, ML stacks, data tools, and long chains of packages. Java has decades of enterprise abstractions. .NET has its own layered ecosystems. PHP, Ruby, Go, Rust, Swift, Kotlin, and C# all have communities that build towers of convenience on top of native language and runtime features.</p>\n<p>Again, this did not happen because developers were careless. It happened because abstractions are one of the main tools we have for dealing with complexity. Software is abstraction. The real question is not whether we should use abstractions, but where they should live, who should own them, how much of them should run in production, and whether borrowing them is still the best economic choice.</p>\n<p>That question is becoming more urgent because the ground is moving. The next major abstraction layer may not be another framework. <strong>The next major abstraction layer may be the model.</strong></p>\n<h2 id=\"frameworks-were-built-for-humans\" tabindex=\"0\" data-toc-anchor=\"true\">Frameworks were built for humans</h2>\n<p>Frameworks exist because humans need constraints. We need names, conventions, file structures, lifecycle models, shared patterns, and a limited number of concepts to keep in our heads at the same time. A framework is not just code. It is a cognitive compression format.</p>\n<p>It tells us how to think about the system. Do not think about everything at once. Think in components, routes, controllers, models, hooks, stores, services, middleware, providers, modules, or pipelines. This is extremely useful when human developers are the only abstraction engine in the loop, because humans need stable shapes to coordinate work, communicate intent, and reduce ambiguity.</p>\n<p>But LLMs do not need abstractions in exactly the same way.</p>\n<p>A model does not care whether a pattern is called a hook, a provider, a composable, a bean, a service, a trait, a reducer, a module, or a pipeline. Those names still matter to us, because they help teams communicate and maintain the system, but they are not the deepest source of quality for AI-assisted software.</p>\n<p>What a model needs is context. It needs constraints. It needs examples, tests, schemas, documentation, review rules, runtime signals, tool access, and clear success criteria. In other words, the most important abstraction for an AI-assisted codebase is not always the framework used by the application. It is the operational environment around the codebase.</p>\n<p>That moves the center of gravity.</p>\n<p>Instead of placing all best practices inside runtime abstractions, we can start placing more of them inside instructions, tests, static analysis, design tokens, architecture rules, accessibility checks, security policies, and project-specific agent skills. The abstraction is still there, but it is no longer necessarily something we ship to users or execute on every request.</p>\n<p>This is a very different kind of software architecture.</p>\n<h2 id=\"the-new-abstraction-layer-lives-around-the-code\" tabindex=\"0\" data-toc-anchor=\"true\">The new abstraction layer lives around the code</h2>\n<p>The next abstraction layer will increasingly be made of things that surround the code rather than things that always run inside it. MCP servers, agent skills, local LLMs, cloud LLMs, project-specific prompts, typed schemas, linters, formatters, test suites, static analysis, design tokens, accessibility rules, security policies, architecture decision records, code review agents, and documentation indexed as working memory all point in the same direction.</p>\n<p>They create an environment where code can be generated, constrained, checked, refactored, and reviewed with more project awareness than before.</p>\n<p>This does not mean we stop using abstractions. That would make no sense. It means we can stop shipping so many borrowed abstractions to production when their main value is no longer runtime behavior, but repetition, convention, scaffolding, and guidance.</p>\n<p>A model can generate repetitive implementation. A linter can enforce a project rule. A type system can constrain a data shape. A test can verify behavior. An agent can apply the same refactor across many files. A design system can become tokens, fixtures, examples, and visual checks. A security rule can become a gate. An accessibility expectation can become part of the definition of done.</p>\n<p>At that point, many dependencies start to look different. Some still provide deep value and should absolutely be used. Others begin to look less like leverage and more like inherited risk.</p>\n<p>The question changes from \"which library should we install?\" to \"should this be a dependency at all?\"</p>\n<h2 id=\"the-dependency-tax\" tabindex=\"0\" data-toc-anchor=\"true\">The dependency tax</h2>\n<p>Dependencies are not free. They increase bundle size, install time, build complexity, runtime surface, security exposure, version drift, documentation debt, maintenance overhead, and future migration cost.</p>\n<p>Those are the obvious costs. The deeper cost is architectural.</p>\n<p>Every dependency imports somebody else's decisions into your product. It brings assumptions about how problems should be modeled, how APIs should behave, how edge cases should be handled, and how the future should evolve. It becomes part of your onboarding path, hiring profile, debugging surface, security model, review process, and long-term roadmap.</p>\n<p>The software industry loves reuse, and for good reason. Reuse can be powerful. But reuse is also coupling, and coupling has to be paid for.</p>\n<p>The npm ecosystem makes this easy to see because it is large, fast-moving, and deeply interconnected, but the same dynamic exists in every ecosystem. Sonatype reported more than 454,600 new malicious packages identified throughout 2025 across major open-source ecosystems, bringing the cumulative total of known and blocked malware above 1.233 million packages. Research on npm supply-chain weak links also shows how package metadata, install scripts, expired maintainer domains, inactive maintainers, and account takeover opportunities can expose thousands of downstream packages.</p>\n<p>This does not mean \"never use dependencies\". That would be a childish conclusion. It means dependencies must become deliberate architectural choices again. They should earn their place through real complexity, real specialization, real maintenance value, or real domain expertise.</p>\n<p>They should not be the default answer to every small discomfort.</p>\n<h2 id=\"ai-changes-the-economics-of-code\" tabindex=\"0\" data-toc-anchor=\"true\">AI changes the economics of code</h2>\n<p>For a long time, dependency-heavy development made economic sense. Writing everything in-house was expensive. Maintaining internal abstractions was expensive. Generating boilerplate was boring. Refactoring repeated patterns was slow. Documenting everything was painful. Testing every variation took time.</p>\n<p>So we outsourced complexity to frameworks and libraries. We accepted the dependency tax because the alternative was often slower, more expensive, and more fragile.</p>\n<p>AI changes that equation, not because it magically produces perfect software, but because it changes the cost of producing certain categories of code.</p>\n<p>Stack Overflow's 2025 Developer Survey reports that 84% of respondents are using or planning to use AI tools, and 51% of professional developers use them daily. GitHub reported that nearly 80% of new developers on GitHub used Copilot within their first week in 2025. Controlled research on GitHub Copilot found that developers completed a JavaScript task 55.8% faster when using the tool.</p>\n<p>The picture is not universally simple. Other studies are more nuanced, and they should be. AI coding tools are more useful in some contexts than others, and generated code still requires strong human review, especially in complex systems, proprietary codebases, security-sensitive areas, and large multi-file changes.</p>\n<p>But the important point is not that AI writes perfect code. It does not.</p>\n<p>The important point is that AI makes boilerplate, repetitive implementation, documentation, test scaffolding, migration work, refactoring, API adaptation, pattern replication, simple feature assembly, and codebase navigation cheaper than they used to be.</p>\n<p>Those are exactly the categories of work that made teams install many libraries in the first place.</p>\n<p>If a model can generate a project-specific implementation that follows your coding rules, passes your tests, respects your security constraints, uses your design tokens, and avoids unnecessary runtime dependencies, then the economics of abstraction change.</p>\n<p>At that point, the question is no longer only \"which package solves this?\"</p>\n<p>The better question becomes \"why should this be a package?\"</p>\n<h2 id=\"standards-will-matter-more-not-less\" tabindex=\"0\" data-toc-anchor=\"true\">Standards will matter more, not less</h2>\n<p>One of the common misunderstandings about AI-generated software is that it will make standards less important. I think the opposite is true.</p>\n<p>AI will make standards more valuable because models work better when the target is stable, documented, widely used, and well represented. Language standards, platform APIs, protocol specifications, accessibility rules, security practices, and explicit architectural constraints are model-friendly.</p>\n<p>A framework is a moving target controlled by a smaller community. A standard is a wider agreement.</p>\n<p>On the web platform, we can already see this shift. Modern CSS is absorbing capabilities that once required preprocessors, JavaScript helpers, or framework-level conventions. Typed attr() lets CSS read attributes as typed values. CSS if() brings conditional logic into values. CSS custom functions aim to make reusable CSS logic native to the language.</p>\n<p>When the platform learns these primitives, teams can use them directly only if their abstraction layers do not get in the way. Otherwise, the browser may already support the capability, but the product still has to wait for a framework, a library, a wrapper, a plugin, or a design system abstraction to catch up, expose it, document it, and stop fighting it.</p>\n<p>That is one ecosystem, but the principle is broader. When platforms become more expressive, and AI becomes better at producing code against stable standards, the value of non-standard abstraction decreases.</p>\n<p>The winning move is not to reject every framework. The winning move is to move as much product logic as possible toward stable, inspectable, standard primitives.</p>\n<p>Use the language. Use the runtime. Use the platform. Use the framework when it still buys something real.</p>\n<h2 id=\"best-practices-become-executable-governance\" tabindex=\"0\" data-toc-anchor=\"true\">Best practices become executable governance</h2>\n<p>Many teams use frameworks as delivery mechanisms for best practices. The framework tells them how to organize files, fetch data, render pages, place state, compose UI, and decide which patterns are acceptable.</p>\n<p>In an AI-assisted workflow, many of those rules can move into configuration, automation, and project context.</p>\n<p>Coding standards can become lint rules. Architecture decisions can become generation constraints. Design guidelines can become tokens and visual tests. Accessibility expectations can become automated checks. Security policies can become static analysis and dependency gates. Performance budgets can become CI failures. Reusable patterns can become agent skills. Project knowledge can become context the model can actually use.</p>\n<p>This is a major change because best practices stop being trapped inside framework conventions and become executable governance.</p>\n<p>A convention helps when developers remember it. A rule enforces it. A test verifies it. An agent applies it repeatedly. A model generates code from it.</p>\n<p>That is the future shape of software abstraction. Not \"everyone must memorize the framework\", but \"the system knows the rules and keeps applying them\".</p>\n<h2 id=\"the-end-of-framework-identity\" tabindex=\"0\" data-toc-anchor=\"true\">The end of framework identity</h2>\n<p>The industry has spent years treating frameworks as identity. We say \"I am a React developer\", \"I am a Laravel developer\", \"I am a Rails developer\", \"I am a Spring developer\", \"I am a Django developer\", \"I am a Next developer\".</p>\n<p>This made sense when frameworks were the main interface between developers and complexity. But in an AI-native development environment, that identity becomes too small.</p>\n<p>The valuable skill will not be loyalty to a framework. The valuable skill will be understanding systems: language, runtime, platform, accessibility, security, performance, data, product constraints, and the way AI-generated work must be instructed, constrained, verified, and reviewed.</p>\n<p>The senior developer of the next era is not the person who knows the most framework APIs by memory. It is the person who can design the rules of the system so that humans and models can safely produce good software together.</p>\n<p>That requires more engineering, not less.</p>\n<h2 id=\"this-is-not-no-code\" tabindex=\"0\" data-toc-anchor=\"true\">This is not no-code</h2>\n<p>This future is not no-code. No-code pretends complexity can disappear, and complexity does not disappear. It moves.</p>\n<p>What changes is the layer where engineering work happens. Less time wiring borrowed abstractions, more time defining contracts. Less time adapting to framework churn, more time designing durable systems. Less time installing packages for trivial problems, more time making sure the generated solution is correct, accessible, secure, observable, and maintainable.</p>\n<p>AI does not remove the need for senior engineers. It increases the need for them.</p>\n<p>Because when code becomes cheaper to produce, judgment becomes more valuable.</p>\n<h2 id=\"a-more-vanilla-world\" tabindex=\"0\" data-toc-anchor=\"true\">A more vanilla world</h2>\n<p>\"Vanilla\" does not mean naive. It does not mean writing everything from scratch forever. It does not mean refusing tools, frameworks, libraries, or ecosystems.</p>\n<p>It means preferring the native language, native runtime, and platform standards unless a dependency clearly earns its place.</p>\n<p>A more vanilla world is not a world without abstraction. It is a world where abstraction is generated, governed, inspected, and owned. A world where dependencies are exceptions, not defaults. A world where libraries are chosen because they provide deep, hard, specialized value, not because nobody wanted to write twenty lines of code.</p>\n<p>It is a world where frameworks are useful tools, not architectural religions. A world where the model becomes part of the abstraction layer, but the output remains understandable, standard, testable, and close to the platform.</p>\n<p>This will not happen all at once. There will still be frameworks, libraries, ecosystems, and very good reasons to depend on external code. But the default is going to change.</p>\n<p>The age of installing an abstraction for every small discomfort is ending.</p>\n<p>The next era belongs to teams that can combine human judgment, platform standards, automated governance, and AI-generated implementation. Not because frameworks suddenly became useless, but because many of the reasons we needed them are being absorbed by something else.</p>\n<p>The model is becoming part of the abstraction. The platform is getting stronger. The dependency graph is becoming a liability.</p>\n<p>And software teams are about to rediscover a very old idea: the best code is not the code you borrowed. It is the code you understand, control, verify, and can afford to change.</p>\n<h2 id=\"sources\" tabindex=\"0\" data-toc-anchor=\"true\">Sources</h2>\n<ul>\n<li><a href=\"https://www.sonatype.com/state-of-the-software-supply-chain/2026/open-source-malware?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Sonatype, \"The Evolving Software Supply Chain Attack Surface\"</a></li><li><a href=\"https://arxiv.org/abs/2112.10165?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Zahan et al., \"What are Weak Links in the npm Supply Chain?\"</a></li><li><a href=\"https://survey.stackoverflow.co/2025/ai?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Stack Overflow Developer Survey 2025, AI</a></li><li><a href=\"https://github.blog/news-insights/octoverse/octoverse-a-new-developer-joins-github-every-second-as-ai-leads-typescript-to-1/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">GitHub Octoverse 2025, \"A new developer joins GitHub every second as AI leads TypeScript to #1\"</a></li><li><a href=\"https://arxiv.org/abs/2302.06590?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Peng et al., \"The Impact of AI on Developer Productivity: Evidence from GitHub Copilot\"</a></li><li><a href=\"https://arxiv.org/abs/2406.17910?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Pandey et al., \"Transforming Software Development: Evaluating the Efficiency and Challenges of GitHub Copilot in Real-World Projects\"</a></li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/attr?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">MDN Web Docs, \"attr() CSS function\"</a></li><li><a href=\"https://developer.chrome.com/blog/if-article?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Chrome Developers, \"CSS conditionals with the new if() function\"</a></li><li><a href=\"https://www.w3.org/TR/css-mixins-1/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">W3C, \"CSS Functions and Mixins Module\"</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-06-16-the-end-of-borrowed-abstractions.png",
      "date_published": "2026-06-16T00:00:00.000Z",
      "tags": [
        "Architecture",
        "Ai",
        "Ai-copilot",
        "Vanilla-js",
        "Frontend",
        "Security"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-06-14-why-display-preferences-popover-exists.html",
      "url": "https://dout.dev/posts/2026-06-14-why-display-preferences-popover-exists.html",
      "title": "Why DisplayPreferencesPopover Exists: Accessibility Starts With User Preferences",
      "summary": "Accessibility is not one feature",
      "content_html": "<h2 id=\"accessibility-is-not-one-feature\" tabindex=\"0\" data-toc-anchor=\"true\">Accessibility is not one feature</h2>\n<p>Most teams treat accessibility as a checklist at the end of a sprint. That approach misses the real problem. Accessibility is the baseline behavior of the interface under real user constraints.</p>\n<p>Those constraints are not abstract. They are concrete:</p>\n<ul>\n<li>small screens and zoomed layouts;</li><li>reduced motion preferences;</li><li>higher contrast needs;</li><li>transparent surfaces that lower readability;</li><li>font sizes and font families that make text easier to parse.</li></ul>\n<p><strong>If the UI ignores those settings, it is not accessible</strong>, even when every button has a good label.</p>\n<h2 id=\"responsive-design-is-accessibility\" tabindex=\"0\" data-toc-anchor=\"true\">Responsive design is accessibility</h2>\n<p>\"Responsive\" does not only mean breakpoints. It means the interface keeps working when conditions change. User preferences are one of those conditions.</p>\n<p>A responsive interface should respond to:</p>\n<ol>\n<li>viewport changes;</li><li>input mode changes;</li><li>user readability and comfort settings.</li></ol>\n<p><code>DisplayPreferencesPopover</code> exists to make that third point explicit and immediate.</p>\n<h2 id=\"why-this-component-exists-on-dout-dev\" tabindex=\"0\" data-toc-anchor=\"true\">Why this component exists on dout.dev</h2>\n<p>On dout.dev, I wanted preference controls to be:</p>\n<ul>\n<li>local and persistent;</li><li>available from every page;</li><li>lightweight and framework-free;</li><li>aligned with semantic HTML and progressive enhancement.</li></ul>\n<p>The popover stores choices in <code>localStorage</code>, applies them on the root element, and keeps the page readable without a navigation detour.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;display-preferences-popover&gt;&lt;/display-preferences-popover&gt;</code></pre><p>That single element exposes controls for motion, transparency, contrast, typography, and corner radius. The page reacts through CSS tokens and root data attributes, not through per-component hacks.</p>\n<h2 id=\"what-it-controls-and-why\" tabindex=\"0\" data-toc-anchor=\"true\">What it controls, and why</h2>\n<h3 id=\"motion-and-transparency\" tabindex=\"0\" data-toc-anchor=\"true\">Motion and transparency</h3>\n<p>People with vestibular sensitivity, attention fatigue, or migraine triggers can be affected by excessive animation and blur. The component lets users reduce those effects directly.</p>\n<h3 id=\"contrast\" tabindex=\"0\" data-toc-anchor=\"true\">Contrast</h3>\n<p>Theme palettes can look \"clean\" and still fail readability in real conditions. A direct contrast preference gives users an immediate correction path.</p>\n<h3 id=\"typography\" tabindex=\"0\" data-toc-anchor=\"true\">Typography</h3>\n<p>Not everyone reads best with the same typeface and scale. Heading, body, and code stacks are adjustable because legibility is personal.</p>\n<h3 id=\"shape-and-visual-density\" tabindex=\"0\" data-toc-anchor=\"true\">Shape and visual density</h3>\n<p>Corner radius is not only visual style. It affects edge detection and separation of interactive surfaces. Offering presets helps users pick what they parse faster.</p>\n<h2 id=\"the-contract-user-choice-wins\" tabindex=\"0\" data-toc-anchor=\"true\">The contract: user choice wins</h2>\n<p>The point is not to provide infinite personalization. The point is to respect explicit user intent.</p>\n<p>When a preference is set, the interface should obey it consistently:</p>\n<ul>\n<li>no hidden overrides;</li><li>no animation sneaking back in;</li><li>no reset after navigation;</li><li>no contrast drop in one section while another section is compliant.</li></ul>\n<p>That is why the component writes stable state and the page consumes that state from shared tokens.</p>\n<h2 id=\"final-point\" tabindex=\"0\" data-toc-anchor=\"true\">Final point</h2>\n<p>Accessibility starts before ARIA, before audits, and before tooling. It starts when the layout, motion, and text system respond to the person using the page.</p>\n<p><code>DisplayPreferencesPopover</code> exists to make that principle operational on every visit.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li><a href=\"https://www.w3.org/WAI/standards-guidelines/wcag/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Web Content Accessibility Guidelines (WCAG) Overview - W3C</a></li><li><a href=\"https://www.w3.org/WAI/WCAG22/Understanding/resize-text?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Understanding Success Criterion 1.4.4 Resize text - W3C</a></li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">prefers-reduced-motion - MDN</a></li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-contrast?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">prefers-contrast - MDN</a></li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">color-scheme - MDN</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-06-14-why-display-preferences-popover-exists.png",
      "date_published": "2026-06-14T00:00:00.000Z",
      "tags": [
        "Accessibility",
        "A11y",
        "Frontend",
        "Responsive-design"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-06-13-how-pixhighlighter-is-built.html",
      "url": "https://dout.dev/posts/2026-06-13-how-pixhighlighter-is-built.html",
      "title": "How PixHighlighter Is Built Around the CSS Custom Highlight API",
      "summary": "The contract stays plain",
      "content_html": "<h2 id=\"the-contract-stays-plain\" tabindex=\"0\" data-toc-anchor=\"true\">The contract stays plain</h2>\n<p><code>PixHighlighter</code> begins with the smallest useful shape:</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;pre is=\"pix-highlighter\" data-lang=\"js\"&gt;&lt;code&gt;const answer = 42;&lt;/code&gt;&lt;/pre&gt;</code></pre><p>That is still a real <code>pre</code> element with a real <code>code</code> child. The source is readable before JavaScript runs, copyable as text, and understandable to assistive technology. The component does not need a custom shadow tree to make code look like code.</p>\n<p><strong>The important decision is what it refuses to do by default:</strong> the primary path does not wrap every token in markup. The code block is not a pile of span wrappers. It is one text node that can be painted by the browser.</p>\n<h2 id=\"lexers-describe-positions\" tabindex=\"0\" data-toc-anchor=\"true\">Lexers describe positions</h2>\n<p>Each lexer under <code>src/scripts/components/PixHighlighter/Lexers/</code> walks the source and emits ranges:</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>{ type: 'kw', start: 0, end: 5 }</code></pre><p>That object says \"the characters from 0 to 5 are a keyword.\" It does not say \"insert a span here.\" Parsing and rendering stay separate. The same token stream can power the modern renderer, the fallback renderer, tests, and any future diagnostics without changing the source model.</p>\n<p>Language aliases are normalized before lookup. <code>javascript</code> becomes <code>js</code>, <code>typescript</code> becomes <code>ts</code>, <code>shell</code> and <code>zsh</code> become <code>bash</code>, and empty language values fall back to <code>js</code>. Content authors get a forgiving interface; the renderer gets one clean language key.</p>\n<h2 id=\"css-custom-highlight-api-does-the-painting\" tabindex=\"0\" data-toc-anchor=\"true\">CSS Custom Highlight API does the painting</h2>\n<p>The best version of <code>PixHighlighter</code> uses the CSS Custom Highlight API. When <code>CSS.highlights</code> and <code>Highlight</code> exist, the component creates DOM <code>Range</code> objects for each token and stores them in named highlight registries:</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>const highlight = new Highlight(range);\nCSS.highlights.set('pix-kw', highlight);</code></pre><p>CSS then styles those names directly:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>::highlight(pix-kw) {\n  color: var(--pix-highlighter--kw);\n}</code></pre><p>That is the whole trick. The browser paints a range of text without changing the text into markup. Selection stays clean. Copy stays clean. The DOM stays quiet. Themes remain normal CSS variables instead of a second token system hidden inside generated spans.</p>\n<h2 id=\"the-fallback-is-deliberately-boring\" tabindex=\"0\" data-toc-anchor=\"true\">The fallback is deliberately boring</h2>\n<p>The rule is simple: fallback spans only when the Highlight API is unavailable. In that case, the component maps the same token ranges to conservative markup:</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;span data-token=\"kw\"&gt;const&lt;/span&gt;</code></pre><p>The fallback is not a different highlighter. It is the same lexer output rendered into older browser primitives. The CSS already has matching rules for <code>[data-token='kw']</code>, <code>[data-token='str']</code>, and the rest of the token types, so the visual result remains close without forcing modern browsers to carry extra DOM.</p>\n<h2 id=\"styles-are-shared-once\" tabindex=\"0\" data-toc-anchor=\"true\">Styles are shared once</h2>\n<p>The component imports its CSS with <code>bundle-text:</code>. That bundle includes the base component stylesheet plus the theme files.</p>\n<p>At runtime, <code>ensureComponentStyles()</code> prefers <code>document.adoptedStyleSheets</code>. If constructable stylesheets are available, one <code>CSSStyleSheet</code> is created and shared by every instance. If not, the component injects one managed <code>&lt;style data-styles&gt;</code> element.</p>\n<p>Ten code blocks still get one style payload. The component can appear throughout an article without multiplying its CSS.</p>\n<h2 id=\"themes-are-page-state\" tabindex=\"0\" data-toc-anchor=\"true\">Themes are page state</h2>\n<p>Every instance renders a compact toolbar with copy and theme controls. The picker appears per block, but the selected theme is page-wide state. <code>PixHighlighter.applyTheme()</code> writes the value to <code>document.documentElement.dataset.pixHighlighterTheme</code>, persists it in <code>localStorage</code>, and syncs every active instance.</p>\n<p>That avoids a page where one snippet is using one palette and the next snippet is using another because of local component state. Code theme is a reading preference, so the page owns it.</p>\n<p>The menu also uses progressive layout. CSS anchor positioning handles the clean path. When the browser lacks it, the component measures the trigger and keeps the list inside the viewport with fixed coordinates.</p>\n<h2 id=\"copy-reads-the-source\" tabindex=\"0\" data-toc-anchor=\"true\">Copy reads the source</h2>\n<p>The copy button reads <code>code.textContent</code>. It does not inspect highlight ranges, fallback spans, toolbar state, or theme state. Highlighting is visual; the source text is the contract.</p>\n<p>If the Clipboard API is available, the component calls <code>navigator.clipboard.writeText()</code>. If that path fails, it falls back to a temporary textarea and <code>document.execCommand('copy')</code>. The UI states are intentionally small: idle, copied, or error, each with an accessible label.</p>\n<h2 id=\"why-this-fits-dout-dev\" tabindex=\"0\" data-toc-anchor=\"true\">Why this fits dout.dev</h2>\n<p><code>PixHighlighter</code> is built for a static editorial site: short examples, no frontend framework, no runtime highlighter dependency, and a design system that already owns typography, color, and motion.</p>\n<p>The architecture works because each layer has one job:</p>\n<ol>\n<li>Markdown emits semantic code blocks.</li><li>Lexers produce token ranges.</li><li>The CSS Custom Highlight API paints those ranges without spans.</li><li>Fallback markup covers browsers without highlight support.</li><li>Toolbar actions enhance the block without becoming the content.</li></ol>\n<p>That is the pattern I want here: ordinary HTML first, modern browser APIs where they remove markup, and a fallback that preserves the same source text instead of inventing a second content model.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-06-13-how-pixhighlighter-is-built.png",
      "date_published": "2026-06-13T00:00:00.000Z",
      "tags": [
        "Vanilla-js",
        "Frontend",
        "Architecture"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-06-09-wcag-22-aa-without-aria-spam.html",
      "url": "https://dout.dev/posts/2026-06-09-wcag-22-aa-without-aria-spam.html",
      "title": "WCAG 2.2 AA Without ARIA-Spam: Landmarks, Heading Order, Skip-Links",
      "summary": "The unpopular opinion",
      "content_html": "<h2 id=\"the-unpopular-opinion\" tabindex=\"0\" data-toc-anchor=\"true\">The unpopular opinion</h2>\n<p><em>Most accessibility failures I see in production are not missing features.</em> They are HTML that was never semantic, covered in ARIA attributes that were supposed to fix the damage. That approach is a tax forever. ARIA is a powerful tool and also a trap: the first rule of ARIA is to not use it if a native element would do the job.</p>\n<p>For dout.dev I made a rule for myself. <strong>Every page starts as semantic HTML. ARIA only shows up when there is no native alternative.</strong> That rule covered most of the WCAG 2.2 AA checklist before I wrote a single <code>aria-*</code> attribute.</p>\n<h2 id=\"landmarks-use-the-elements-not-the-roles\" tabindex=\"0\" data-toc-anchor=\"true\">Landmarks: use the elements, not the roles</h2>\n<p>HTML5 already gives you landmarks. A screen reader or a \"Rotor\" in VoiceOver reads them as navigation regions. You do not need to annotate them.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;body&gt;\n  &lt;a class=\"skip-link\" href=\"#main\"&gt;Skip to content&lt;/a&gt;\n\n  &lt;header&gt;\n    &lt;nav aria-label=\"Primary\"&gt;\n      &lt;a href=\"/\"&gt;dout.dev&lt;/a&gt;\n      &lt;ul&gt;\n        &lt;li&gt;&lt;a href=\"/archive.html\"&gt;Archive&lt;/a&gt;&lt;/li&gt;\n        &lt;li&gt;&lt;a href=\"/about.html\"&gt;About&lt;/a&gt;&lt;/li&gt;\n      &lt;/ul&gt;\n    &lt;/nav&gt;\n  &lt;/header&gt;\n\n  &lt;main id=\"main\"&gt;\n    &lt;article&gt;\n      &lt;h1&gt;Post title&lt;/h1&gt;\n      &lt;p&gt;...&lt;/p&gt;\n    &lt;/article&gt;\n  &lt;/main&gt;\n\n  &lt;footer&gt;\n    &lt;p&gt;© 2026 dout.dev&lt;/p&gt;\n  &lt;/footer&gt;\n&lt;/body&gt;</code></pre><p>No <code>role=\"banner\"</code>, no <code>role=\"main\"</code>, no <code>role=\"navigation\"</code>. Each element announces itself. The only ARIA attribute here is <code>aria-label=\"Primary\"</code> on the nav, because the page has more than one nav element and screen reader users benefit from knowing which is which.</p>\n<h2 id=\"heading-order-is-a-real-check-not-a-nicety\" tabindex=\"0\" data-toc-anchor=\"true\">Heading order is a real check, not a nicety</h2>\n<p>Heading order is one of the WCAG 2.2 success criteria most sites fail silently. The rule: each page has one <code>h1</code>, and subsequent headings go down the tree in order - <code>h2</code>, then <code>h3</code>, never skipping.</p>\n<p>On dout.dev, the post layout enforces it. The post title is always <code>h1</code>. Section headings in the markdown body start at <code>h2</code>. The post generator validates the tree at build time; if a post starts with <code>###</code>, the build fails.</p>\n<pre is=\"pix-highlighter\" data-lang=\"markdown\"><code>&lt;!-- Good --&gt;\n\n# Post title\n\n## Section\n\n### Subsection\n\n## Another section\n\n&lt;!-- Bad, the build rejects this --&gt;\n\n# Post title\n\n#### Oops</code></pre><h2 id=\"the-skip-link-nobody-sees-but-everyone-benefits-from\" tabindex=\"0\" data-toc-anchor=\"true\">The skip-link nobody sees, but everyone benefits from</h2>\n<p>Keyboard users who arrive on a page need a way to jump to the main content without tabbing through the entire header. The skip-link is hidden visually until it receives focus, at which point it appears and announces itself.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>.skip-link {\n  position: absolute;\n  inset-inline-start: 0;\n  inset-block-start: 0;\n  transform: translateY(-100%);\n  transition: transform 0.15s;\n}\n\n.skip-link:focus-visible {\n  transform: translateY(0);\n}</code></pre><p>The first keyboard tap on the page reveals it. Screen reader users hear it immediately regardless of visibility.</p>\n<h2 id=\"focus-styles-that-show-up-on-purpose\" tabindex=\"0\" data-toc-anchor=\"true\">Focus styles that show up, on purpose</h2>\n<p>Browsers removed default focus styles from buttons in some contexts and left designers to reinvent them. The result is sites where keyboard users cannot see where they are. The WCAG 2.2 addition <code>2.4.11 Focus Not Obscured</code> codifies how visible focus must be.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>:focus-visible {\n  outline: var(--focus-ring);\n  outline-offset: 2px;\n  border-radius: var(--radius-1);\n}</code></pre><p><code>:focus-visible</code> shows the ring only for keyboard navigation, not for mouse clicks. That keeps the design calm for pointer users and explicit for keyboard users.</p>\n<h2 id=\"color-contrast-as-a-token-not-an-afterthought\" tabindex=\"0\" data-toc-anchor=\"true\">Color contrast as a token, not an afterthought</h2>\n<p>Contrast is mathematical. You either meet the ratio or you do not. WCAG 2.2 AA requires 4.5:1 for body text, 3:1 for large text. I check every token pair against the requirement when the theme is defined, and I do not ship colors that fail.</p>\n<p>A cheap way to keep yourself honest is to wire a contrast check into the build, so the CI fails if a semantic token combination drops below threshold. That is mechanical, not creative, and it pays back every time a designer-in-you wants to pick a \"softer\" foreground color.</p>\n<h2 id=\"when-aria-does-earn-its-keep\" tabindex=\"0\" data-toc-anchor=\"true\">When ARIA does earn its keep</h2>\n<p>ARIA is not evil. It shines when native HTML genuinely does not cover what you are building:</p>\n<ul>\n<li><code>aria-live=\"polite\"</code> on the search results summary, so that changes in result count are announced without stealing focus;</li><li><code>aria-current=\"page\"</code> on the current pagination link;</li><li><code>aria-expanded</code> on a disclosure button that toggles a panel;</li><li><code>aria-describedby</code> to associate an error message with the input it describes.</li></ul>\n<p>All of these are cases where a sighted user has a visual cue that non-sighted users would miss. ARIA fills the gap.</p>\n<h2 id=\"what-i-refused-to-add\" tabindex=\"0\" data-toc-anchor=\"true\">What I refused to add</h2>\n<ul>\n<li><code>aria-label</code> on an icon that already has accessible text nearby.</li><li><code>role=\"button\"</code> on a <code>&lt;button&gt;</code>.</li><li><code>aria-hidden=\"true\"</code> on decorative icons that were inside a labeled parent - <code>&lt;span aria-hidden=\"true\"&gt;</code> on a pure SVG icon is fine; on a structural element it is a bug.</li></ul>\n<p>ARIA without a reason is noise for screen readers and maintenance for you.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>WCAG 2.2 AA is not a ceiling. It is a baseline, and most of it is achievable with semantic HTML, visible focus, proper heading order, and a skip-link. Start there. Add ARIA last, with intent.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li><a href=\"https://www.w3.org/WAI/standards-guidelines/wcag/glance/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">WCAG 2.2 at a Glance - W3C</a></li><li><a href=\"https://www.w3.org/WAI/WCAG22/Understanding/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Understanding WCAG 2.2 - W3C</a></li><li><a href=\"https://www.w3.org/TR/using-aria/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Using ARIA - W3C</a></li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>:focus-visible</code> - MDN</a></li><li><a href=\"https://www.w3.org/WAI/ARIA/apg/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">ARIA Authoring Practices Guide</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-06-09-wcag-22-aa-without-aria-spam.png",
      "date_published": "2026-06-09T00:00:00.000Z",
      "tags": [
        "Accessibility",
        "A11y",
        "Html",
        "Frontend"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-06-02-modern-css-is-enough.html",
      "url": "https://dout.dev/posts/2026-06-02-modern-css-is-enough.html",
      "title": "Modern CSS Is Enough: Container Queries, Nesting, `:has()` in Production",
      "summary": "The short version",
      "content_html": "<h2 id=\"the-short-version\" tabindex=\"0\" data-toc-anchor=\"true\">The short version</h2>\n<p>For about ten years, serious frontend work leaned on tooling that papered over missing CSS features: Sass for nesting, Styled Components for dynamic theming, BEM conventions for scoping, utility frameworks for constraint. Most of those reasons quietly disappeared in the last two browser cycles. Modern CSS has container queries, native nesting, <code>:has()</code>, cascade layers, logical properties, <code>color-mix()</code>, <code>clamp()</code>, and more. On dout.dev I ship all of these, and the CSS is shorter than it would have been five years ago.</p>\n<p>This post is not \"CSS is cool now.\" It is <strong>a walk through specific modern features that let me delete a preprocessor and a handful of conventions</strong>.</p>\n<h2 id=\"container-queries-instead-of-media-queries\" tabindex=\"0\" data-toc-anchor=\"true\">Container queries instead of media queries</h2>\n<p>Media queries size components against the viewport. That is the wrong reference frame for components that can appear in a full-width article or a narrow sidebar. Container queries fix it.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>.post-card {\n  container-type: inline-size;\n  container-name: card;\n}\n\n@container card (min-width: 30rem) {\n  .post-card__layout {\n    display: grid;\n    grid-template-columns: 10rem 1fr;\n    gap: var(--space-5);\n  }\n}</code></pre><p>The card adapts to its container, not the page. You drop it in any layout and it behaves. I removed a half-dozen component-level media queries when I moved to container queries, and I have not missed them.</p>\n<h2 id=\"native-nesting-without-sass\" tabindex=\"0\" data-toc-anchor=\"true\">Native nesting, without Sass</h2>\n<p>Native CSS nesting lets you keep related rules together without compiling:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>.post-card {\n  padding: var(--space-5);\n  background: var(--color-bg-raised);\n\n  &amp; h2 {\n    font-size: var(--text-xl);\n    line-height: var(--font-lineheight-2);\n  }\n\n  &amp;:hover {\n    transform: translateY(-2px);\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    &amp;:hover {\n      transform: none;\n    }\n  }\n}</code></pre><p>The only gotcha is the <code>&amp;</code> for selectors that would otherwise concatenate ambiguously. It is a small price to pay for deleting the Sass build step. Baseline support is wide; check current status on caniuse if you need to be sure.</p>\n<h2 id=\"has-for-parent-aware-styling\" tabindex=\"0\" data-toc-anchor=\"true\"><code>:has()</code> for parent-aware styling</h2>\n<p><code>:has()</code> is the feature CSS was missing for twenty years. \"Style the parent based on the child\" is now a one-liner:</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>/* Collapse the sidebar when the article has no table of contents */\n.post-layout:has(.outline[hidden]) {\n  grid-template-columns: 1fr;\n}\n\n/* Highlight figures that contain a caption */\nfigure:has(figcaption) {\n  border-inline-start: 3px solid var(--color-accent);\n  padding-inline-start: var(--space-4);\n}</code></pre><p>Anywhere I used to reach for JavaScript to toggle a parent class based on child state, <code>:has()</code> does it in the cascade. Less code, no flash of incorrect styling.</p>\n<h2 id=\"logical-properties\" tabindex=\"0\" data-toc-anchor=\"true\">Logical properties</h2>\n<p>If you care about bidirectional text, or you just want your CSS to stop lying about what it does, logical properties are non-negotiable. Instead of <code>margin-left</code> and <code>padding-right</code>, you write <code>margin-inline-start</code> and <code>padding-inline-end</code>. The effect is the same in English and correct in Arabic.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>.callout {\n  padding-inline: var(--space-5);\n  padding-block: var(--space-4);\n  border-inline-start: 3px solid var(--color-accent);\n}</code></pre><p>Even in an English-only site like dout.dev, using logical properties is better hygiene. It documents intent, and it makes the CSS future-proof if the site ever goes multilingual.</p>\n<h2 id=\"color-mix-and-clamp-for-token-derivation\" tabindex=\"0\" data-toc-anchor=\"true\"><code>color-mix()</code> and <code>clamp()</code> for token derivation</h2>\n<p>Two small features that replace a lot of Sass math.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>:root {\n  --color-accent: #ff6b3d;\n  --color-accent-soft: color-mix(in oklch, var(--color-accent) 20%, var(--color-bg));\n}\n\nh1 {\n  /* Responsive type without media queries */\n  font-size: clamp(1.875rem, 1.4rem + 2vw, 3rem);\n}</code></pre><p><code>color-mix()</code> lets you derive shades from semantic tokens at runtime, so theme changes cascade into every derived value. <code>clamp()</code> handles fluid type without a script.</p>\n<h2 id=\"cascade-layers-for-predictable-ordering\" tabindex=\"0\" data-toc-anchor=\"true\">Cascade layers for predictable ordering</h2>\n<p>On a small project, layering is overkill. On any project with an external reset, a design system, and component-level overrides, cascade layers save you from specificity wars.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>@layer reset, tokens, base, components, utilities;\n\n@layer reset {\n  /* modern-normalize or similar */\n}\n\n@layer components {\n  .post-card {\n    /* ... */\n  }\n}</code></pre><p>The layer order determines which rules win, regardless of specificity. I use it as a safety net, not as a crutch. Most rules still work fine without thinking about it.</p>\n<h2 id=\"what-i-did-not-need\" tabindex=\"0\" data-toc-anchor=\"true\">What I did not need</h2>\n<ul>\n<li><strong>No preprocessor.</strong> Nesting, variables, and math are native.</li><li><strong>No CSS-in-JS.</strong> Tokens + semantic aliases cover dynamic theming at the CSS layer.</li><li><strong>No utility framework.</strong> For a text-heavy editorial site, utility frameworks add more noise than they save. Your mileage depends on the project.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>If you last wrote CSS seriously three years ago, the landscape is different. Container queries alone are worth a re-evaluation of your conventions. <code>:has()</code> kills a whole class of JavaScript hacks. Native nesting and layers kill the preprocessor. Ship what the browser already gives you.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_container_queries?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Container queries - MDN</a></li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">CSS nesting - MDN</a></li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/:has?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>:has()</code> selector - MDN</a></li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/@layer?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Cascade layers - MDN</a></li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Logical properties and values - MDN</a></li><li><a href=\"https://caniuse.com/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Can I use</a> - check baseline before you ship</li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-06-02-modern-css-is-enough.png",
      "date_published": "2026-06-02T00:00:00.000Z",
      "tags": [
        "Css",
        "Vanilla-js",
        "Frontend"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-23-vibe-coding-with-rigor.html",
      "url": "https://dout.dev/posts/2026-05-23-vibe-coding-with-rigor.html",
      "title": "Vibe Coding With Rigor",
      "summary": "The term I am reclaiming",
      "content_html": "<h2 id=\"the-term-i-am-reclaiming\" tabindex=\"0\" data-toc-anchor=\"true\">The term I am reclaiming</h2>\n<p>\"Vibe coding\" has become shorthand for \"let the AI write whatever it wants and see what happens.\" That is not what I did on dout.dev, and it is not what the term should mean if it is going to be useful. The version I work with looks more like this:</p>\n<blockquote>\n<p>Vibe coding is <strong>driving an AI copilot hard with clear intent, small units of work, and strict review, while keeping the architectural decisions in your own head.</strong></p>\n</blockquote>\n<p><strong>The vibe is the speed. The rigor is the review.</strong> This post is the workflow that made 19 milestones shippable without the repo turning into a science fair.</p>\n<h2 id=\"the-unit-of-work\" tabindex=\"0\" data-toc-anchor=\"true\">The unit of work</h2>\n<p>Every session had one outcome. \"Wire up the RSS feed.\" \"Add the skip-link.\" \"Extract the pagination component.\" Not \"improve the site.\" Not \"make it more accessible.\" The outcome fit in one commit.</p>\n<p>That discipline mattered more than the prompt engineering. When a session has a scoped outcome, the copilot's proposals can be evaluated against a clear question: did this produce the outcome, and is the diff small? When a session is open-ended, every proposal feels plausible, and you accept changes you later regret.</p>\n<h2 id=\"the-prompt-pattern-i-actually-used\" tabindex=\"0\" data-toc-anchor=\"true\">The prompt pattern I actually used</h2>\n<p>Three parts, in this order.</p>\n<p><strong>State the goal and the constraint in one paragraph.</strong> Not a task list. A paragraph that a smart colleague could act on cold. \"I am adding a tag archive generator. It should produce <code>/tags/&lt;slug&gt;.html</code> for page 1 and <code>/tags/&lt;slug&gt;/&lt;n&gt;/</code> for subsequent pages. Must match the existing pagination contract. Do not touch the post template.\"</p>\n<p><strong>Point at the files that matter.</strong> \"Read <code>scripts/cms/page-generator.js</code> and <code>src/components/pagination.html</code>. Mirror their conventions.\" Explicit pointers beat the copilot's guess about what is relevant.</p>\n<p><strong>Name the done-state.</strong> \"Return the new file and the minimal diff to wire it up. Do not reformat unrelated code.\" Stating the shape of the output up front cuts down on scope creep.</p>\n<p>That pattern saved me hours. The ones I got wrong were the ones where I vibed the prompt itself.</p>\n<h2 id=\"what-i-delegated\" tabindex=\"0\" data-toc-anchor=\"true\">What I delegated</h2>\n<p>Mechanical edits, exploratory scaffolding, documentation stubs, test harnesses, draft implementations I intended to rewrite. Roughly: anything where \"a reasonable first version\" had obvious shape and I wanted the time back.</p>\n<p>A concrete example. The OG image generator needed 12 SVG templates for the various card states. Each was a small variation on the next. I described the first one, had the copilot produce it, reviewed, then asked for the remaining 11 with specific variations. That would have been an hour of tedium; it was ten minutes.</p>\n<h2 id=\"what-i-did-not-delegate\" tabindex=\"0\" data-toc-anchor=\"true\">What I did not delegate</h2>\n<ul>\n<li><strong>Architecture.</strong> Whether to build a template engine or adopt one. Whether to render OG images at build or runtime. Whether to ship a service worker at all.</li><li><strong>Public API shape.</strong> URL structure, front matter schema, CSS custom property naming.</li><li><strong>Accessibility judgments.</strong> When to use <code>aria-current</code>, when to rely on semantic HTML, how to test with a screen reader.</li><li><strong>Any change I did not understand.</strong> If a diff proposed a pattern I had not seen before, I made myself understand it before merging. If that took longer than the time saved, I wrote the code myself.</li></ul>\n<p>The last rule is the one that keeps the copilot from becoming technical debt generator.</p>\n<h2 id=\"the-review-loop\" tabindex=\"0\" data-toc-anchor=\"true\">The review loop</h2>\n<p>Every diff got read line by line. Not \"skimmed.\" Read. That is the work the copilot cannot do for you, and skipping it is how you end up with patterns that drift from the rest of the codebase.</p>\n<p>Three review questions:</p>\n<ol>\n<li><strong>Does this produce the outcome I asked for?</strong> If not, throw the diff away and re-prompt. Do not \"patch\" a wrong starting point.</li><li><strong>Is this the smallest version that works?</strong> The copilot adds defensive code, comments, error handling, and abstractions that are not needed. Ask for a simpler version. Repeatedly.</li><li><strong>Does this match the style of the rest of the codebase?</strong> Consistency is cheap to enforce at review time and expensive to retrofit later.</li></ol>\n<p>Asking for simpler versions is the single highest-leverage feedback I give the copilot. \"Make this boring.\" \"Remove the try/catch.\" \"This function does not need options.\" Boring is a feature.</p>\n<h2 id=\"the-mistakes-that-kept-happening\" tabindex=\"0\" data-toc-anchor=\"true\">The mistakes that kept happening</h2>\n<ul>\n<li><strong>Invented APIs.</strong> Made-up flags, nonexistent methods. Less common on well-known libraries, more common on the boundary of the repo's own code.</li><li><strong>Over-engineered error handling.</strong> Defensive code for cases that cannot occur. I sent the diff back with \"trust the caller\" and the second version was always shorter.</li><li><strong>Task-describing comments.</strong> Comments like <code>// Fetch the user data from the server</code> above <code>fetchUser()</code>. Useless. I strip them on sight.</li><li><strong>Unnecessary abstractions.</strong> \"Let me extract this helper\" - usually no, three lines inline is better than an abstraction with one caller.</li></ul>\n<p>None of these are deal-breakers. They are the shape of what the copilot tends to produce when under-specified.</p>\n<h2 id=\"the-milestone-rhythm\" tabindex=\"0\" data-toc-anchor=\"true\">The milestone rhythm</h2>\n<p>Each of the 19 milestones had a short document with the exit checklist. When the checklist was green, the milestone closed and I moved on. That structure, more than anything, kept the overall scope honest.</p>\n<p>The alternative - a single rolling backlog with no exit criteria - is how personal projects stall. Without milestones I would have still been \"polishing\" after three months and not shipped the blog.</p>\n<h2 id=\"what-the-copilot-taught-me-about-my-own-work\" tabindex=\"0\" data-toc-anchor=\"true\">What the copilot taught me about my own work</h2>\n<p>Two things I will keep after this project.</p>\n<p><strong>Most of my hesitation is not about the hard choice.</strong> It is about the tedium of implementing the choice. Delegating the tedium compresses the space between \"I know what to do\" and \"it is done.\" That is not laziness; it is leverage.</p>\n<p><strong>I am more opinionated than I thought.</strong> When the copilot proposed something generic, I rejected it because it did not match the project's taste. I had to learn to articulate that taste explicitly - in prompts, in code comments, in README rules. Writing those articulations made the project more coherent.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>Vibe coding done well is not \"let the AI drive.\" It is \"drive harder, with better brakes.\" Small units of work, clear prompts, strict review, and a list of things you will never delegate. Hold that line and the copilot is a force multiplier. Drop it and you are doing unpaid editing for a confident junior engineer.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li><a href=\"https://docs.claude.com/claude-code?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Claude Code documentation</a></li><li><a href=\"https://martinfowler.com/articles/on-pair-programming.html?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Pair programming - Martin Fowler</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-23-vibe-coding-with-rigor.png",
      "date_published": "2026-05-23T00:00:00.000Z",
      "tags": [
        "Making-of",
        "Ai-copilot",
        "Workflow"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-22-ci-cd-around-github-pages.html",
      "url": "https://dout.dev/posts/2026-05-22-ci-cd-around-github-pages.html",
      "title": "CI/CD Around GitHub Pages",
      "summary": "The gap GitHub Pages leaves open",
      "content_html": "<h2 id=\"the-gap-github-pages-leaves-open\" tabindex=\"0\" data-toc-anchor=\"true\">The gap GitHub Pages leaves open</h2>\n<p>GitHub Pages is excellent for serving a static site. It is also, by default, missing two features that Netlify and Vercel have made everyone expect: <strong>deploy previews on pull requests</strong>, and <strong>one-click rollback</strong> to a previous deploy.</p>\n<p><em>Neither is actually missing.</em> They are just not turned on. The pieces exist in GitHub Actions; you have to wire them up. On dout.dev, that wiring is about 40 lines of YAML and one simple naming convention.</p>\n<h2 id=\"the-production-pipeline\" tabindex=\"0\" data-toc-anchor=\"true\">The production pipeline</h2>\n<p>Production deploys run from <code>main</code> or from <code>workflow_dispatch</code>. The workflow has two jobs - build and deploy - and produces a site at <code>https://dout.dev</code>.</p>\n<pre is=\"pix-highlighter\" data-lang=\"yaml\"><code>name: Deploy Pages\n\non:\n  push:\n    branches: [main]\n  workflow_dispatch:\n\npermissions:\n  contents: read\n  pages: write\n  id-token: write\n\nconcurrency:\n  group: pages\n  cancel-in-progress: true\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: actions/configure-pages@v5\n      - uses: pnpm/action-setup@v4\n        with: { version: 10.33.0 }\n      - uses: actions/setup-node@v4\n        with: { node-version: 24, cache: pnpm }\n      - run: pnpm install --frozen-lockfile\n      - run: pnpm build\n      - uses: actions/upload-pages-artifact@v3\n        with: { path: dist }\n\n  deploy:\n    needs: build\n    runs-on: ubuntu-latest\n    environment:\n      name: github-pages\n      url: DEPLOYMENT_PAGE_URL\n    steps:\n      - uses: actions/deploy-pages@v4\n        id: deployment</code></pre><p>That is a complete Pages workflow. The <code>concurrency</code> group prevents two overlapping deploys from fighting. The <code>environment: github-pages</code> line is required for the Pages deployment to succeed and also records deploy history.</p>\n<h2 id=\"pr-previews-without-a-third-party-service\" tabindex=\"0\" data-toc-anchor=\"true\">PR previews, without a third-party service</h2>\n<p>GitHub Pages has one \"live\" site per repository. You cannot deploy a PR to a live preview URL the way Netlify does.</p>\n<p>What you can do: <strong>build the PR, upload the <code>dist/</code> as an artifact, and comment on the PR with a link to download it.</strong> A reviewer can extract the zip locally and open it. For a single-author blog, that is often enough.</p>\n<pre is=\"pix-highlighter\" data-lang=\"yaml\"><code>name: PR Build\n\non:\n  pull_request:\n    branches: [main]\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: pnpm/action-setup@v4\n        with: { version: 10.33.0 }\n      - uses: actions/setup-node@v4\n        with: { node-version: 24, cache: pnpm }\n      - run: pnpm install --frozen-lockfile\n      - run: pnpm build\n\n      - uses: actions/upload-artifact@v4\n        with:\n          name: dist-PR_NUMBER\n          path: dist\n          retention-days: 14</code></pre><p>The artifact is named with the PR number, so CI history stays navigable. Retention is two weeks - long enough to review, short enough to not accumulate forever.</p>\n<h2 id=\"real-pr-previews-with-a-second-github-pages-repo\" tabindex=\"0\" data-toc-anchor=\"true\">Real PR previews with a second GitHub Pages repo</h2>\n<p>For reviewers who want a clickable URL rather than a zip, there is a second pattern: a separate repository that hosts \"preview\" deploys at URLs like <code>preview-123.dout.dev</code>.</p>\n<p>The CI workflow pushes the PR's <code>dist/</code> into a subdirectory of the preview repo's <code>main</code> branch, and the preview site is configured to serve those subdirectories. A bot comments on the PR with the preview URL.</p>\n<p>This is more setup than most personal projects need. For dout.dev I use the zip-artifact pattern. For a team project, I would build the separate-repo preview pipeline.</p>\n<h2 id=\"rollback-in-one-minute\" tabindex=\"0\" data-toc-anchor=\"true\">Rollback in one minute</h2>\n<p>This is the feature I use more often than I expected.</p>\n<p>When a bad deploy reaches production - wrong content, broken CSP, accidentally unpublished post - the recovery path is:</p>\n<ol>\n<li>Go to Actions → Deploy Pages in the repo.</li><li>Find the last known-good run.</li><li>Click \"Re-run all jobs.\"</li></ol>\n<p>That rebuilds the old commit and redeploys it. One minute from \"oh no\" to \"fixed.\"</p>\n<p>The prerequisites:</p>\n<ul>\n<li><strong>Every production deploy is triggered by a commit on <code>main</code>.</strong> No manual artifact uploads. Every deploy is reproducible from a specific SHA.</li><li><strong>Build-time inputs are either committed or in secrets.</strong> If a deploy depends on an environment variable that is not tracked, re-running the old commit with a new secret value produces a different artifact. That is the most common rollback failure mode.</li></ul>\n<p>The workflow at the top of this post satisfies both. That is why it rolls back cleanly.</p>\n<h2 id=\"the-commit-based-deploy-trail\" tabindex=\"0\" data-toc-anchor=\"true\">The commit-based deploy trail</h2>\n<p>A useful side effect of \"every deploy is a commit\" is that the Actions history is the deploy history. You can see which commit is live right now, which commits have been deployed before, and how long each deploy took.</p>\n<p>The Pages environment in the repo settings also tracks \"Active deployment\" and shows the deployed commit SHA at the top. If you have to answer \"what is live right now?\" for a coworker, that screen is the authoritative source.</p>\n<h2 id=\"what-i-did-not-add\" tabindex=\"0\" data-toc-anchor=\"true\">What I did not add</h2>\n<ul>\n<li><strong>Automatic atomic rollback on failure.</strong> If the build fails, no deploy happens. If the deploy itself fails, GitHub Pages does not update. That is the correct level of automation for a static site.</li><li><strong>Slack or email notifications on deploy.</strong> Overkill for a single-author blog. The Actions email notifications are enough.</li><li><strong>Canary deploys.</strong> A static blog does not need canaries. The unit of change is one post.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>GitHub Pages with GitHub Actions is a complete deploy pipeline, including previews and rollback, if you accept that previews come as artifacts rather than live URLs. For most personal projects that is the right trade-off - no third-party service, no extra auth, and the same UI you are already using for the repo.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li><a href=\"https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">GitHub Pages: deploy from GitHub Actions</a></li><li><a href=\"https://github.com/actions/deploy-pages?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>actions/deploy-pages</code></a></li><li><a href=\"https://github.com/actions/upload-artifact?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>actions/upload-artifact</code></a></li><li><a href=\"https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-deployments/managing-environments-for-deployment?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">GitHub Environments</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-22-ci-cd-around-github-pages.png",
      "date_published": "2026-05-22T00:00:00.000Z",
      "tags": [
        "Making-of",
        "Ci",
        "Deployment",
        "Github-pages"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-21-quality-gate-lint-format-verify.html",
      "url": "https://dout.dev/posts/2026-05-21-quality-gate-lint-format-verify.html",
      "title": "Quality Gate: Linting, Formatting, and Verify",
      "summary": "Why the gate exists",
      "content_html": "<h2 id=\"why-the-gate-exists\" tabindex=\"0\" data-toc-anchor=\"true\">Why the gate exists</h2>\n<p>A single-author blog does not need CI the way a team product does. But any site that accepts drive-by contributions, or any site that I want to be able to touch in six months without re-learning, benefits from a gate that answers one question at merge time:</p>\n<blockquote>\n<p>Does this change still produce a correct, accessible, valid site?</p>\n</blockquote>\n<p>The gate on dout.dev is a single command, <strong><code>pnpm quality:check</code></strong>, that runs the same checks locally and in GitHub Actions. If it is green, the site is publishable. If it is red, it tells me which layer broke.</p>\n<h2 id=\"what-the-gate-actually-runs\" tabindex=\"0\" data-toc-anchor=\"true\">What the gate actually runs</h2>\n<pre is=\"pix-highlighter\" data-lang=\"bash\"><code>pnpm test\npnpm spellcheck\npnpm format:check\npnpm format:check:html\npnpm validate:all</code></pre><p>Five commands, each doing one thing.</p>\n<p><strong><code>pnpm test</code></strong> - Node's built-in test runner on the CMS and template-engine code. Fast. No Jest, no Vitest, no configuration surface.</p>\n<p><strong><code>pnpm spellcheck</code></strong> - cspell against the markdown and HTML files. Catches typos in posts and navigation labels before they reach the reader.</p>\n<p><strong><code>pnpm format:check</code></strong> - Biome in check mode over JavaScript. Enforces the single formatter.</p>\n<p><strong><code>pnpm format:check:html</code></strong> - Prettier in check mode over HTML templates. HTML formatting is one of those things everyone forgets to verify until a diff becomes impossible to review.</p>\n<p><strong><code>pnpm validate:all</code></strong> - a composite of HTML validation, structural validation, link checking, and accessibility validation. The one that catches the most real bugs.</p>\n<h2 id=\"html-validation\" tabindex=\"0\" data-toc-anchor=\"true\">HTML validation</h2>\n<p>HTML validation is underrated. Bad nesting, missing alt text, heading skips, unused IDs - all of these slip past manual review and all of them matter. The validator on dout.dev runs against every file under <code>src/</code> and <code>dist/</code> and reports problems with file and line.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>import { readFileSync } from 'node:fs';\nimport { HTMLHint } from 'htmlhint';\n\nfunction validateHtml(file) {\n  const rules = {\n    'doctype-first': true,\n    'tag-pair': true,\n    'attr-lowercase': true,\n    'attr-value-double-quotes': true,\n    'alt-require': true,\n    'id-unique': true,\n    'title-require': true,\n  };\n  const source = readFileSync(file, 'utf8');\n  return HTMLHint.verify(source, rules);\n}</code></pre><p>HTMLHint is not the only option. <code>@linthtml/linthtml</code> is similar. For deep validation the W3C Nu Validator is authoritative. For this project HTMLHint is enough.</p>\n<h2 id=\"link-validation\" tabindex=\"0\" data-toc-anchor=\"true\">Link validation</h2>\n<p>Every internal link on the site gets resolved to an actual file at build time. A broken link in a post or in the navigation fails the build.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function validateLinks(dir) {\n  const failures = [];\n  for (const file of walk(dir)) {\n    const html = readFileSync(file, 'utf8');\n    for (const href of extractHrefs(html)) {\n      if (isInternal(href) &amp;&amp; !exists(resolveToFile(href))) {\n        failures.push({ file, href });\n      }\n    }\n  }\n  return failures;\n}</code></pre><p>External links are checked only occasionally, not on every build, because network flakiness should not fail a PR. A scheduled task runs external-link validation once a week.</p>\n<h2 id=\"accessibility-validation\" tabindex=\"0\" data-toc-anchor=\"true\">Accessibility validation</h2>\n<p>The a11y check runs axe-core over every generated page, using Playwright as the headless browser. It catches a significant fraction of WCAG issues automatically - missing labels, insufficient contrast, heading order problems, keyboard trap candidates.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>import { test, expect } from '@playwright/test';\nimport { injectAxe, checkA11y } from '@axe-core/playwright';\n\ntest('posts pass axe', async ({ page }) =&gt; {\n  await page.goto('/posts/2026-06-09-wcag-22-aa-without-aria-spam.html');\n  await injectAxe(page);\n  await checkA11y(page, null, {\n    detailedReport: true,\n    detailedReportOptions: { html: true },\n  });\n});</code></pre><p>Axe does not catch everything. Nothing does. What it catches is the base rate of failures that keep creeping in through drive-by edits.</p>\n<h2 id=\"the-one-i-added-after-getting-burned\" tabindex=\"0\" data-toc-anchor=\"true\">The one I added after getting burned</h2>\n<p>Early in the project, I shipped a build with a broken CSP meta tag. The page rendered; the browser silently refused to load the JavaScript. The fix is a build-time check that verifies the generated CSP is well-formed and covers the scripts referenced in the HTML.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function validateCsp(html) {\n  const csp = extractCspMeta(html);\n  const scripts = extractInlineAndExternalScripts(html);\n  const violations = scripts.filter((s) =&gt; !cspAllows(csp, s));\n  return violations;\n}</code></pre><p>This is the kind of check you write exactly once, after exactly one production incident.</p>\n<h2 id=\"the-gate-lives-in-one-file\" tabindex=\"0\" data-toc-anchor=\"true\">The gate lives in one file</h2>\n<p>All of this is wired in <code>.github/workflows/deploy-pages.yml</code> as a single step:</p>\n<pre is=\"pix-highlighter\" data-lang=\"yaml\"><code>- name: Build site\n  run: pnpm build\n\n- name: Quality gate\n  run: pnpm quality:check</code></pre><p>Locally I run <code>pnpm quality:check</code> before any push. If I am about to merge something significant, I also run <code>pnpm test:visual</code> - the Playwright visual regression suite - which is slower and therefore not on the default path.</p>\n<h2 id=\"what-i-did-not-add\" tabindex=\"0\" data-toc-anchor=\"true\">What I did not add</h2>\n<ul>\n<li><strong>No commit message linting.</strong> I write commits in the shape I need; convention beats enforcement for a single-author repo.</li><li><strong>No bundle size budget as a hard gate.</strong> I check it occasionally with Lighthouse. It has never regressed enough to warrant a budget check.</li><li><strong>No dependency update bot.</strong> Dependabot PRs on a personal site are noise. I update manually when something actually needs updating.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>A quality gate is a contract with your future self. Write it once, keep it small, run it everywhere. The specific checks that matter depend on the project. For a static editorial site, HTML validity, link integrity, and axe-core accessibility cover most of what goes wrong.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li><a href=\"https://biomejs.dev/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Biome</a></li><li><a href=\"https://prettier.io/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Prettier</a></li><li><a href=\"https://htmlhint.com/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">HTMLHint</a></li><li><a href=\"https://github.com/dequelabs/axe-core?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">axe-core</a></li><li><a href=\"https://playwright.dev/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Playwright</a></li><li><a href=\"https://validator.w3.org/nu/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">W3C Nu HTML Checker</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-21-quality-gate-lint-format-verify.png",
      "date_published": "2026-05-21T00:00:00.000Z",
      "tags": [
        "Making-of",
        "Tooling",
        "Linting",
        "Ci"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-20-seo-metadata-without-rituals.html",
      "url": "https://dout.dev/posts/2026-05-20-seo-metadata-without-rituals.html",
      "title": "SEO Metadata Without Rituals",
      "summary": "The short version",
      "content_html": "<h2 id=\"the-short-version\" tabindex=\"0\" data-toc-anchor=\"true\">The short version</h2>\n<p>\"SEO\" is a category name. Inside it are a dozen unrelated concerns, some of which matter for most sites and many of which do not. This post is the handful of tags I ship on dout.dev because they demonstrably help crawlers and discovery, and the handful I do not ship because they are noise.</p>\n<p><strong>The line I draw:</strong> a tag earns its place if a major crawler uses it or if it improves a demonstrable discovery behavior. If it is \"recommended\" but nobody can point to the behavior it improves, I leave it out.</p>\n<h2 id=\"canonical-urls-one-real-url-per-page\" tabindex=\"0\" data-toc-anchor=\"true\">Canonical URLs: one real URL per page</h2>\n<p>Every page on dout.dev has a canonical URL in the head.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;link rel=\"canonical\" href=\"https://dout.dev/posts/2026-10-13-canonical-structured-data-hreflang.html\" /&gt;</code></pre><p>The value is the absolute URL of the page as it lives on the site. Three rules:</p>\n<ol>\n<li><strong>Always absolute.</strong> Relative canonicals work but are ambiguous in practice.</li><li><strong>Always HTTPS.</strong> Never redirect through HTTP; the canonical declares HTTPS directly.</li><li><strong>Self-referential on normal pages.</strong> A post's canonical points at itself. An archive's canonical points at itself.</li></ol>\n<p>Where canonical matters most is when the same content is reachable via multiple URLs. Pagination is the classic case: <code>/tags/accessibility/2/</code> should still canonical to itself, not to <code>/tags/accessibility/</code>. Search result pages, query-string variations, and session-id URLs are the other common cases.</p>\n<p>On a clean static site with one URL per page, canonical is mostly boilerplate. It is still worth shipping, because the day a duplicate URL appears (through a short-link service, a Mastodon share, a paginated archive), the canonical is the difference between a clean index and content fragmentation.</p>\n<h2 id=\"structured-data-json-ld-for-articles\" tabindex=\"0\" data-toc-anchor=\"true\">Structured data: JSON-LD for articles</h2>\n<p>JSON-LD is the format Google, Bing, and LinkedIn want for structured data. It is a <code>&lt;script type=\"application/ld+json\"&gt;</code> block in the head.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;script type=\"application/ld+json\"&gt;\n  {\n    \"@context\": \"https://schema.org\",\n    \"@type\": \"BlogPosting\",\n    \"headline\": \"Canonical, Structured Data, Hreflang: SEO Without Cargo Culting\",\n    \"description\": \"The SEO tags that actually do work on a static blog...\",\n    \"datePublished\": \"2026-10-13\",\n    \"author\": {\n      \"@type\": \"Person\",\n      \"name\": \"Emiliano \\\"pixu1980\\\" Pisu\",\n      \"url\": \"https://dout.dev/about.html\"\n    },\n    \"image\": \"https://dout.dev/assets/og/posts/2026-10-13-canonical-structured-data-hreflang.png\",\n    \"publisher\": {\n      \"@type\": \"Organization\",\n      \"name\": \"dout.dev\",\n      \"url\": \"https://dout.dev\"\n    },\n    \"mainEntityOfPage\": {\n      \"@type\": \"WebPage\",\n      \"@id\": \"https://dout.dev/posts/2026-10-13-canonical-structured-data-hreflang.html\"\n    }\n  }\n&lt;/script&gt;</code></pre><p>That block earns its place because:</p>\n<ul>\n<li><strong>Google Discover</strong> uses it to decide whether a post is article content.</li><li><strong>Mastodon, Slack, and some RSS readers</strong> enrich link previews when structured data is present.</li><li><strong>Google Search Console</strong> reports structured data errors, which means the build can be verified.</li></ul>\n<p>The post generator emits this block from the normalized post record. One source, per-post substitution, no manual upkeep.</p>\n<h2 id=\"what-i-do-not-ship-as-structured-data\" tabindex=\"0\" data-toc-anchor=\"true\">What I do not ship as structured data</h2>\n<p><code>WebSite</code> with <code>SiteNavigationElement</code>, <code>BreadcrumbList</code> on posts that have no breadcrumb UI, <code>Article</code> alongside <code>BlogPosting</code>, <code>@type</code> schemas for which no consumer has a documented behavior.</p>\n<p>The heuristic: if I cannot point at a concrete consumer behavior, I do not ship the schema. \"Might help ranking someday\" is not a reason. Schema spam has a negative cost with some crawlers, which explicitly penalize sites that dump irrelevant structured data.</p>\n<h2 id=\"hreflang-only-when-it-applies\" tabindex=\"0\" data-toc-anchor=\"true\">Hreflang: only when it applies</h2>\n<p><code>hreflang</code> tells crawlers which language a page is in and what the equivalents are in other languages.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;link rel=\"alternate\" hreflang=\"en-US\" href=\"https://dout.dev/posts/...\" /&gt;\n&lt;link rel=\"alternate\" hreflang=\"it-IT\" href=\"https://dout.dev/it/posts/...\" /&gt;\n&lt;link rel=\"alternate\" hreflang=\"x-default\" href=\"https://dout.dev/posts/...\" /&gt;</code></pre><p>dout.dev is English only. The roadmap removed the i18n milestone. So hreflang is a no-op for my case and I do not ship it. Writing it out here for completeness: the moment a site has localized content, hreflang is non-optional, and omitting it is a real SEO hit.</p>\n<p>The <code>x-default</code> row is the one most people miss. It tells crawlers which URL to serve when no language matches. Without it, a user on a Swahili browser with no Swahili translation gets the first-listed alternate, which may or may not be what you want.</p>\n<h2 id=\"open-graph-and-twitter-cards\" tabindex=\"0\" data-toc-anchor=\"true\">Open Graph and Twitter Cards</h2>\n<p>These are not strictly SEO, but they are discovery. Every post has:</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;meta property=\"og:site_name\" content=\"dout.dev\" /&gt;\n&lt;meta property=\"og:type\" content=\"article\" /&gt;\n&lt;meta property=\"og:title\" content=\"...\" /&gt;\n&lt;meta property=\"og:description\" content=\"...\" /&gt;\n&lt;meta property=\"og:url\" content=\"...\" /&gt;\n&lt;meta property=\"og:image\" content=\"...\" /&gt;\n&lt;meta property=\"og:image:width\" content=\"1200\" /&gt;\n&lt;meta property=\"og:image:height\" content=\"630\" /&gt;\n&lt;meta property=\"og:locale\" content=\"en_US\" /&gt;\n&lt;meta property=\"article:published_time\" content=\"2026-10-13\" /&gt;\n\n&lt;meta name=\"twitter:card\" content=\"summary_large_image\" /&gt;</code></pre><p>The <code>og:image:width</code> and <code>og:image:height</code> are not optional in practice; Mastodon's preview system, in particular, falls back to a smaller thumbnail without them.</p>\n<p><code>twitter:card</code> is one of the rare \"Twitter-specific\" tags still worth shipping. Once set, both Twitter and X parse it reliably; omitting it sometimes degrades the card to a small summary.</p>\n<h2 id=\"the-robots-and-meta-pragmas-that-matter\" tabindex=\"0\" data-toc-anchor=\"true\">The robots and meta pragmas that matter</h2>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;meta name=\"robots\" content=\"index,follow\" /&gt; &lt;meta name=\"referrer\" content=\"strict-origin-when-cross-origin\" /&gt;</code></pre><p><code>robots</code> is explicit by default on the posts; the home page and archive have the same value. Drafts (<code>published: false</code>) never render, so there is no need for a <code>noindex</code> path for them.</p>\n<p><code>referrer</code> is a privacy choice. It tells the browser how much referrer information to send when the user clicks a link off-site. <code>strict-origin-when-cross-origin</code> is the reasonable default: same-origin gets the full URL, cross-origin gets only the origin, and HTTP→HTTPS downgrade suppresses it entirely.</p>\n<h2 id=\"what-i-learned-from-watching-crawler-behavior\" tabindex=\"0\" data-toc-anchor=\"true\">What I learned from watching crawler behavior</h2>\n<p>Two findings from the first months after publishing.</p>\n<p><strong>Canonical works, but only after the crawler re-crawls.</strong> A short-link that 301s to your canonical URL may still show up in Search Console as a separate URL for days before it merges. The canonical is correct; the crawl index is eventually consistent.</p>\n<p><strong>Structured data errors are loud.</strong> Google Search Console pings you within days if the JSON-LD is malformed. Fixing it is a round trip; the build-time validation is worth adding so errors are caught before deploy.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>SEO on a blog is not magic. Three tags - canonical, structured data, Open Graph - cover most of the actual behavior. Hreflang only when you have multiple languages. Skip the dozens of \"recommended\" tags unless you can point at the consumer behavior they enable. The goal is a clean, honest document, not a holiday tree of meta tags.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li><a href=\"https://developers.google.com/search/docs/crawling-indexing/canonicalization?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Canonical URLs - Google Search Central</a></li><li><a href=\"https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Structured data general guidelines - Google</a></li><li><a href=\"https://schema.org/BlogPosting?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Schema.org - BlogPosting</a></li><li><a href=\"https://developers.google.com/search/docs/specialty/international/localized-versions?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Hreflang guide - Google</a></li><li><a href=\"https://ogp.me/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Open Graph protocol</a></li><li><a href=\"https://json-ld.org/playground/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">JSON-LD Playground</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-20-seo-metadata-without-rituals.png",
      "date_published": "2026-05-20T00:00:00.000Z",
      "tags": [
        "Making-of",
        "Seo",
        "Opengraph",
        "Html"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-19-og-images-at-build-time.html",
      "url": "https://dout.dev/posts/2026-05-19-og-images-at-build-time.html",
      "title": "OG Images at Build Time",
      "summary": "The problem and the usual solutions",
      "content_html": "<h2 id=\"the-problem-and-the-usual-solutions\" tabindex=\"0\" data-toc-anchor=\"true\">The problem and the usual solutions</h2>\n<p>Every post needs a social preview image - the card that appears when someone shares the URL on Slack, Twitter, or LinkedIn. The options usually break down like this.</p>\n<ul>\n<li><strong>Hand-design each one.</strong> A Figma file, a designer, exported PNGs. Works. Does not scale.</li><li><strong>Runtime service</strong> (Vercel OG, Cloudinary). A URL like <code>og.dev/render?title=...</code> produces an image on demand. Works. Adds a third-party dependency, latency, and a potential point of failure.</li><li><strong>Build-time renderer.</strong> Generate the PNG when the site builds, commit it to the static output. Works. Is under your control.</li></ul>\n<p>For dout.dev I went build-time, for one reason: <strong>I wanted the image to be a byproduct of the build, cacheable as a static file, with zero runtime dependency.</strong> The implementation is about 150 lines of Node.</p>\n<h2 id=\"the-svg-template\" tabindex=\"0\" data-toc-anchor=\"true\">The SVG template</h2>\n<p>The trick is that OG images are rasterized SVGs. SVG is a markup language I can generate with the same template engine as the rest of the site. Sharp then converts the SVG to PNG at 1200×630, which is the OG card spec.</p>\n<pre is=\"pix-highlighter\" data-lang=\"xml\"><code>&lt;svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1200\" height=\"630\" viewBox=\"0 0 1200 630\"&gt;\n  &lt;rect width=\"1200\" height=\"630\" fill=\"#0b0b0f\" /&gt;\n\n  &lt;text x=\"80\" y=\"180\" font-family=\"Inter, sans-serif\" font-size=\"24\" fill=\"#ff6b3d\"\n        font-weight=\"600\" letter-spacing=\"0.08em\"&gt;DOUT.DEV&lt;/text&gt;\n\n  &lt;text x=\"80\" y=\"320\" font-family=\"Inter, sans-serif\" font-size=\"64\" fill=\"#e7e7ef\"\n        font-weight=\"700\" style=\"line-height: 1.1\"&gt;\n    &lt;tspan x=\"80\" dy=\"0\"&gt;OG Images at Build Time&lt;/tspan&gt;\n    &lt;tspan x=\"80\" dy=\"80\"&gt;SVG + Sharp in 150 Lines&lt;/tspan&gt;\n  &lt;/text&gt;\n\n  &lt;text x=\"80\" y=\"540\" font-family=\"Inter, sans-serif\" font-size=\"24\" fill=\"#a0a0b4\"&gt;\n    by Emiliano \"pixu1980\" Pisu · 11 Aug 2026\n  &lt;/text&gt;\n&lt;/svg&gt;</code></pre><p>That template has variables where the title, date, and author go. The generator produces one per post.</p>\n<h2 id=\"the-line-break-problem\" tabindex=\"0\" data-toc-anchor=\"true\">The line-break problem</h2>\n<p>The hardest single problem in OG image generation is fitting a variable-length title inside a fixed-width box without it running off the edge.</p>\n<p>The brute-force solution - \"measure the text, wrap manually\" - requires a font metrics library. The simpler solution - greedy wrapping with a conservative line-length budget - is what I use.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function wrapTitle(title, maxChars = 26) {\n  const words = title.split(/\\s+/);\n  const lines = [];\n  let current = '';\n  for (const word of words) {\n    if ((current + ' ' + word).trim().length &gt; maxChars) {\n      lines.push(current);\n      current = word;\n    } else {\n      current = (current + ' ' + word).trim();\n    }\n  }\n  if (current) lines.push(current);\n  return lines.slice(0, 3);\n}</code></pre><p>26 characters is an empirical value for Inter at 64px in a 1040px box with 80px margins. I tuned it once, compared against the longest real titles, and moved on. The generator truncates to three lines because four looks cramped.</p>\n<h2 id=\"the-rasterization\" tabindex=\"0\" data-toc-anchor=\"true\">The rasterization</h2>\n<p>Sharp handles the SVG-to-PNG conversion. It is a one-liner with reasonable defaults.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>import sharp from 'sharp';\n\nasync function renderOg(svg, outputPath) {\n  await sharp(Buffer.from(svg)).resize(1200, 630).png({ compressionLevel: 9 }).toFile(outputPath);\n}</code></pre><p><code>compressionLevel: 9</code> squeezes the PNG a bit at the cost of build time. On a small blog the total is under a second per image, so it is worth it.</p>\n<h2 id=\"the-full-generator-in-shape\" tabindex=\"0\" data-toc-anchor=\"true\">The full generator, in shape</h2>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>import { readFileSync } from 'node:fs';\nimport sharp from 'sharp';\n\nconst template = readFileSync('src/og-template.svg', 'utf8');\n\nexport async function generateOgImage(post, outputPath) {\n  const lines = wrapTitle(post.title);\n  const titleSvg = lines.map((line, i) =&gt; `&lt;tspan x=\"80\" dy=\"${i === 0 ? 0 : 80}\"&gt;${escapeXml(line)}&lt;/tspan&gt;`).join('');\n\n  const svg = template\n    .replace('&lt;title-placeholder&gt;', titleSvg)\n    .replace('&lt;date-placeholder&gt;', formatDate(post.date))\n    .replace('&lt;author-placeholder&gt;', 'Emiliano &amp;quot;pixu1980&amp;quot; Pisu');\n\n  await sharp(Buffer.from(svg)).png({ compressionLevel: 9 }).toFile(outputPath);\n}</code></pre><p>150 lines with the helpers included. No fonts-as-files wizardry. No headless browser. No Puppeteer.</p>\n<h2 id=\"fonts-the-caveat-you-have-to-handle\" tabindex=\"0\" data-toc-anchor=\"true\">Fonts: the caveat you have to handle</h2>\n<p>SVG <code>font-family=\"Inter\"</code> only works if the system rendering the SVG has Inter installed, which is not the case on GitHub Actions runners.</p>\n<p>Two paths:</p>\n<ol>\n<li><strong>Embed the font as a base64 data URL inside the SVG.</strong> Works. Balloons the file size.</li><li><strong>Preload the font at the Sharp layer.</strong> Sharp uses the system fontconfig; you install the font on the runner and reference it by name.</li></ol>\n<p>On dout.dev I went with option 2: a GitHub Actions step installs Inter before the build runs. That keeps the SVG clean.</p>\n<pre is=\"pix-highlighter\" data-lang=\"yaml\"><code>- name: Install Inter font\n  run: |\n    wget -q https://fonts.google.com/download?family=Inter -O inter.zip\n    unzip -q inter.zip -d /usr/share/fonts/inter\n    fc-cache -f</code></pre><p>The URL for Google Fonts bulk download is stable enough; if it breaks, I self-host the <code>.ttf</code> in the repo.</p>\n<h2 id=\"what-the-generator-outputs\" tabindex=\"0\" data-toc-anchor=\"true\">What the generator outputs</h2>\n<p>One PNG per post, under <code>src/assets/og/posts/&lt;slug&gt;.png</code>. The CMS also generates month OG images under <code>src/assets/og/months/</code> and writes a manifest at <code>src/assets/og/manifest.json</code> so other build steps can reference them.</p>\n<p>The post template references the image in the <code>&lt;head&gt;</code>:</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;meta property=\"og:image\" content=\"https://dout.dev/assets/og/posts/2026-08-11-og-images-at-build-time.png\" /&gt;\n&lt;meta property=\"og:image:width\" content=\"1200\" /&gt;\n&lt;meta property=\"og:image:height\" content=\"630\" /&gt;\n&lt;meta name=\"twitter:card\" content=\"summary_large_image\" /&gt;</code></pre><p><code>og:image:width</code> and <code>og:image:height</code> are not required, but many crawlers check them and Mastodon's preview service will happily show a tiny image if dimensions are missing.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>You do not need a runtime OG service, a headless browser, or a third-party API. A templated SVG, a wrapping function, and Sharp cover the whole problem in 150 lines. It runs at build time, commits to <code>dist/</code>, and costs nothing after the first build.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li><a href=\"https://ogp.me/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Open Graph protocol</a></li><li><a href=\"https://sharp.pixelplumbing.com/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Sharp - high-performance image processing</a></li><li><a href=\"https://developer.x.com/en/docs/x-for-websites/cards/overview/abouts-cards?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Twitter Cards documentation</a></li><li><a href=\"https://developers.facebook.com/tools/debug/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Facebook sharing debugger</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-19-og-images-at-build-time.png",
      "date_published": "2026-05-19T00:00:00.000Z",
      "tags": [
        "Making-of",
        "Opengraph",
        "Seo",
        "Static-site"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-18-feeds-and-sitemaps-at-build-time.html",
      "url": "https://dout.dev/posts/2026-05-18-feeds-and-sitemaps-at-build-time.html",
      "title": "Feeds and Sitemaps at Build Time",
      "summary": "The distribution minimum",
      "content_html": "<h2 id=\"the-distribution-minimum\" tabindex=\"0\" data-toc-anchor=\"true\">The distribution minimum</h2>\n<p>A blog that does not ship feeds and a sitemap is a blog that nobody can reliably subscribe to, and a blog that crawlers have to guess at. Both problems are fixed with three files:</p>\n<ul>\n<li><code>feed.rss</code> for classic RSS readers;</li><li><code>feed.json</code> for modern readers that prefer JSON Feed;</li><li><code>sitemap.xml</code> for crawlers.</li></ul>\n<p>On dout.dev <strong>all three are generated at build time</strong> from the same normalized dataset the page generator uses. No external tooling. No runtime hit.</p>\n<h2 id=\"rss-2-0-is-still-the-lingua-franca\" tabindex=\"0\" data-toc-anchor=\"true\">RSS 2.0 is still the lingua franca</h2>\n<p>Despite being old, RSS remains the format that every reader supports. If you ship exactly one feed, ship RSS. The schema is tiny and the templating is a for-loop.</p>\n<pre is=\"pix-highlighter\" data-lang=\"xml\"><code>&lt;?xml version=\"1.0\" encoding=\"UTF-8\"?&gt;\n&lt;rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\"&gt;\n  &lt;channel&gt;\n    &lt;title&gt;dout.dev&lt;/title&gt;\n    &lt;link&gt;https://dout.dev&lt;/link&gt;\n    &lt;description&gt;Vanilla-first static blog on web standards and AI-assisted engineering.&lt;/description&gt;\n    &lt;language&gt;en-us&lt;/language&gt;\n    &lt;lastBuildDate&gt;Mon, 30 Jun 2026 08:00:00 GMT&lt;/lastBuildDate&gt;\n    &lt;atom:link href=\"https://dout.dev/feed.rss\" rel=\"self\" type=\"application/rss+xml\" /&gt;\n\n    &lt;item&gt;\n      &lt;title&gt;RSS + JSON Feed + Sitemap at Build Time&lt;/title&gt;\n      &lt;link&gt;https://dout.dev/posts/2026-06-30-rss-json-feed-sitemap-at-build-time.html&lt;/link&gt;\n      &lt;guid isPermaLink=\"true\"&gt;https://dout.dev/posts/2026-06-30-rss-json-feed-sitemap-at-build-time.html&lt;/guid&gt;\n      &lt;pubDate&gt;Tue, 30 Jun 2026 08:00:00 GMT&lt;/pubDate&gt;\n      &lt;description&gt;&lt;![CDATA[The three distribution files every blog should ship...]]&gt;&lt;/description&gt;\n    &lt;/item&gt;\n  &lt;/channel&gt;\n&lt;/rss&gt;</code></pre><p>Two things are easy to get wrong here.</p>\n<p><strong>The <code>lastBuildDate</code> and <code>pubDate</code> must be RFC 822.</strong> Not ISO. Not \"a date.\" RFC 822 with a four-digit year is the correct shape. Readers will silently drop items that do not parse.</p>\n<p><strong>The <code>&lt;atom:link rel=\"self\"&gt;</code> is not optional.</strong> It is the self-reference that tells aggregators where the feed lives. Some validators treat it as a warning; some treat it as a bug; some readers ignore it. Ship it.</p>\n<h2 id=\"json-feed-is-not-a-curiosity\" tabindex=\"0\" data-toc-anchor=\"true\">JSON Feed is not a curiosity</h2>\n<p>JSON Feed is worth shipping alongside RSS. The format is human-readable, trivial to parse, and the spec is under one screen. Readers like NetNewsWire and Readwise speak it natively.</p>\n<pre is=\"pix-highlighter\" data-lang=\"json\"><code>{\n  \"version\": \"https://jsonfeed.org/version/1.1\",\n  \"title\": \"dout.dev\",\n  \"home_page_url\": \"https://dout.dev\",\n  \"feed_url\": \"https://dout.dev/feed.json\",\n  \"language\": \"en-US\",\n  \"items\": [\n    {\n      \"id\": \"https://dout.dev/posts/2026-06-30-rss-json-feed-sitemap-at-build-time.html\",\n      \"url\": \"https://dout.dev/posts/2026-06-30-rss-json-feed-sitemap-at-build-time.html\",\n      \"title\": \"RSS + JSON Feed + Sitemap at Build Time\",\n      \"date_published\": \"2026-06-30T08:00:00Z\",\n      \"summary\": \"The three distribution files every blog should ship...\",\n      \"tags\": [\"seo\", \"static-site\", \"architecture\"]\n    }\n  ]\n}</code></pre><p>The cost of adding it is an extra file and twenty lines of generator code. The benefit is any reader that speaks JSON Feed gets richer metadata - tags, summaries, author - without the ceremony of extension namespaces in XML.</p>\n<h2 id=\"sitemap-every-url-or-no-url\" tabindex=\"0\" data-toc-anchor=\"true\">Sitemap: every URL or no URL</h2>\n<p>The sitemap is for crawlers. If you ship it, ship everything. Partial sitemaps are worse than no sitemap because they implicitly tell the crawler \"these are the interesting pages,\" which de-weights everything not listed.</p>\n<p>The dout.dev sitemap includes posts, tag pages, month archives, series archives, the home page, the about page, and the search page. It does not include the 404, the offline page, or the playground.</p>\n<pre is=\"pix-highlighter\" data-lang=\"xml\"><code>&lt;?xml version=\"1.0\" encoding=\"UTF-8\"?&gt;\n&lt;urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"&gt;\n  &lt;url&gt;\n    &lt;loc&gt;https://dout.dev/&lt;/loc&gt;\n    &lt;lastmod&gt;2026-06-30&lt;/lastmod&gt;\n    &lt;changefreq&gt;weekly&lt;/changefreq&gt;\n    &lt;priority&gt;1.0&lt;/priority&gt;\n  &lt;/url&gt;\n  &lt;url&gt;\n    &lt;loc&gt;https://dout.dev/posts/2026-06-30-rss-json-feed-sitemap-at-build-time.html&lt;/loc&gt;\n    &lt;lastmod&gt;2026-06-30&lt;/lastmod&gt;\n  &lt;/url&gt;\n  &lt;!-- ... --&gt;\n&lt;/urlset&gt;</code></pre><p>The <code>changefreq</code> and <code>priority</code> fields are advisory. Google has said publicly that it mostly ignores them. I still ship them because other crawlers read them and the cost is nothing.</p>\n<h2 id=\"per-tag-and-per-month-rss-for-people-who-want-it\" tabindex=\"0\" data-toc-anchor=\"true\">Per-tag and per-month RSS, for people who want it</h2>\n<p>An underrated feature: each tag page and each month archive on dout.dev has its own RSS feed. A reader who cares about <code>accessibility</code> but not about <code>css</code> can subscribe to <code>/tags/accessibility.xml</code> and never see a post outside that tag.</p>\n<p>The feeds are generated from the same dataset as the main feed, filtered to the tag or month. It is free to produce, because the normalized data already has the tag and month indexes.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;link rel=\"alternate\" type=\"application/rss+xml\" title=\"dout.dev - accessibility\" href=\"/tags/accessibility.xml\" /&gt;</code></pre><p>That <code>&lt;link rel=\"alternate\"&gt;</code> is how readers auto-discover the feed. Every tag page ships it.</p>\n<h2 id=\"the-build-time-angle\" tabindex=\"0\" data-toc-anchor=\"true\">The build-time angle</h2>\n<p>None of this happens at runtime. The feeds and sitemap are generated when the CMS builds, committed as artifacts in <code>dist/</code>, and served as plain static files by GitHub Pages. That means:</p>\n<ul>\n<li>No feed service to maintain.</li><li>No cache invalidation problem.</li><li>No rate limiting, no throttling, no 500 errors on publish spikes.</li><li>The feed is always consistent with the site it describes, because they come from the same build.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>Shipping RSS, JSON Feed, and a sitemap is an afternoon of generator code. The payoff is measurable: crawlers find your content, readers subscribe, and the blog stops being a black box to the rest of the web.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li><a href=\"https://www.rssboard.org/rss-specification?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">RSS 2.0 Specification - RSS Advisory Board</a></li><li><a href=\"https://www.jsonfeed.org/version/1.1/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">JSON Feed v1.1 - JSON Feed</a></li><li><a href=\"https://www.sitemaps.org/protocol.html?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Sitemaps Protocol</a></li><li><a href=\"https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Google Search Central: sitemaps</a></li><li><a href=\"https://validator.w3.org/feed/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">W3C Feed Validation Service</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-18-feeds-and-sitemaps-at-build-time.png",
      "date_published": "2026-05-18T00:00:00.000Z",
      "tags": [
        "Making-of",
        "Feeds",
        "Seo",
        "Static-site"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-17-build-assets-pipeline.html",
      "url": "https://dout.dev/posts/2026-05-17-build-assets-pipeline.html",
      "title": "Build Assets Pipeline",
      "summary": "The goal",
      "content_html": "<h2 id=\"the-goal\" tabindex=\"0\" data-toc-anchor=\"true\">The goal</h2>\n<p><strong>Every image on this blog has to satisfy four non-negotiable constraints:</strong></p>\n<ol>\n<li><strong>Correct size for the viewport.</strong> No 2000px hero shipped to a phone.</li><li><strong>Modern format where supported.</strong> WebP for browsers that accept it, raster fallback for the rest.</li><li><strong>Zero cumulative layout shift.</strong> Width and height reserved before the image downloads.</li><li><strong>Lazy on-reveal.</strong> Off-screen images do not block the main thread.</li></ol>\n<p>The pipeline that delivers that is entirely build-time. There is no image CDN, no runtime resize, no magic. This post walks through each layer.</p>\n<h2 id=\"the-input-a-plain-markdown-image\" tabindex=\"0\" data-toc-anchor=\"true\">The input: a plain markdown image</h2>\n<p>The markdown source looks like a normal image:</p>\n<pre is=\"pix-highlighter\" data-lang=\"markdown\"><code>![A keyboard on a wooden desk](../assets/images/keyboard.jpg)</code></pre><p>What makes it responsive is not a special syntax. It is the build pipeline that sees the local path and does work behind the scenes.</p>\n<p>Optionally the author can provide a title meta string to override the default behavior:</p>\n<pre is=\"pix-highlighter\" data-lang=\"markdown\"><code>![Hero](../assets/images/hero.jpg 'Hero | srcset=../img/320.jpg 320w, ../img/640.jpg 640w | sizes=(max-width: 640px) 100vw, 640px | priority=high | loading=eager')</code></pre><p>Segments separated by <code>|</code> let you specify custom <code>srcset</code>, <code>sizes</code>, <code>loading</code>, and <code>priority</code>. For most images I omit all of that and let the pipeline fill in the defaults.</p>\n<h2 id=\"stage-1-variants-and-the-image-manifest\" tabindex=\"0\" data-toc-anchor=\"true\">Stage 1: variants and the image manifest</h2>\n<p>Before the CMS runs, an image pipeline step generates responsive variants of every image under <code>src/assets/images/</code>:</p>\n<ul>\n<li><code>image.jpg</code> → <code>image-320.jpg</code>, <code>image-640.jpg</code>, <code>image-960.jpg</code>, <code>image-1280.jpg</code>, never upscaled;</li><li>a WebP base (<code>image.webp</code>) and matching WebP variants at each size.</li></ul>\n<p>The output of that step is a manifest file (<code>src/assets/images-manifest.json</code>) with entries like:</p>\n<pre is=\"pix-highlighter\" data-lang=\"json\"><code>{\n  \"/assets/images/keyboard.jpg\": {\n    \"width\": 1920,\n    \"height\": 1280,\n    \"variants\": [\n      { \"src\": \"/assets/images/keyboard-320.jpg\", \"width\": 320 },\n      { \"src\": \"/assets/images/keyboard-640.jpg\", \"width\": 640 },\n      { \"src\": \"/assets/images/keyboard-960.jpg\", \"width\": 960 },\n      { \"src\": \"/assets/images/keyboard-1280.jpg\", \"width\": 1280 }\n    ],\n    \"webp\": [{ \"src\": \"/assets/images/keyboard-320.webp\", \"width\": 320 }]\n  }\n}</code></pre><p>Sharp does the resizing. The manifest is the contract between the image step and the markdown renderer.</p>\n<h2 id=\"stage-2-the-markdown-renderer-rewrites-img-into-picture\" tabindex=\"0\" data-toc-anchor=\"true\">Stage 2: the markdown renderer rewrites <code>&lt;img&gt;</code> into <code>&lt;picture&gt;</code></h2>\n<p>When the renderer encounters an image with a local path, it looks up the manifest, computes <code>srcset</code> for WebP and raster sources, and emits a <code>&lt;picture&gt;</code> element:</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;picture&gt;\n  &lt;source\n    type=\"image/webp\"\n    data-srcset=\"/assets/images/keyboard-320.webp 320w,\n                 /assets/images/keyboard-640.webp 640w,\n                 /assets/images/keyboard-960.webp 960w,\n                 /assets/images/keyboard-1280.webp 1280w\"\n    sizes=\"(max-width: 640px) 100vw, 640px\"\n  /&gt;\n  &lt;source\n    type=\"image/jpeg\"\n    data-srcset=\"/assets/images/keyboard-320.jpg 320w, ...\"\n    sizes=\"(max-width: 640px) 100vw, 640px\"\n  /&gt;\n  &lt;img\n    src=\"/assets/images/keyboard.jpg\"\n    alt=\"A keyboard on a wooden desk\"\n    width=\"1920\"\n    height=\"1280\"\n    loading=\"lazy\"\n    decoding=\"async\"\n  /&gt;\n  &lt;noscript&gt;\n    &lt;img src=\"/assets/images/keyboard-640.jpg\" alt=\"A keyboard on a wooden desk\" /&gt;\n  &lt;/noscript&gt;\n&lt;/picture&gt;</code></pre><p>Two details worth calling out.</p>\n<p><strong>Width and height come from the manifest.</strong> The browser reserves the correct aspect-ratio box before the pixels arrive, so there is no layout shift when the image loads. This is the single most impactful CLS fix on any image-heavy page.</p>\n<p><strong><code>data-srcset</code> instead of <code>srcset</code> by default.</strong> The real <code>srcset</code> is swapped in by a tiny script when the image enters the viewport, which is how the \"lazy on-reveal\" policy is enforced. The <code>&lt;noscript&gt;</code> fallback covers users who disable JavaScript.</p>\n<p>For LCP-critical images (covers, above-the-fold hero), the author sets <code>priority=high</code> or <code>loading=eager</code>, and the renderer inlines real <code>srcset</code> attributes instead of <code>data-srcset</code>. No waiting, no flash.</p>\n<h2 id=\"stage-3-the-tiny-observer-that-swaps-data-srcset-to-srcset\" tabindex=\"0\" data-toc-anchor=\"true\">Stage 3: the tiny observer that swaps <code>data-srcset</code> to <code>srcset</code></h2>\n<p>The runtime piece is small:</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>const io = new IntersectionObserver(\n  (entries) =&gt; {\n    for (const entry of entries) {\n      if (!entry.isIntersecting) continue;\n      const source = entry.target;\n      if (source.dataset.srcset) {\n        source.srcset = source.dataset.srcset;\n        source.removeAttribute('data-srcset');\n      }\n      io.unobserve(source);\n    }\n  },\n  { rootMargin: '200px' }\n);\n\ndocument.querySelectorAll('source[data-srcset]').forEach((s) =&gt; io.observe(s));</code></pre><p>That is the entire lazy-load layer. Everything else is declarative markup the browser understands natively.</p>\n<h2 id=\"why-not-loading-lazy-alone\" tabindex=\"0\" data-toc-anchor=\"true\">Why not <code>loading=\"lazy\"</code> alone?</h2>\n<p>Native <code>loading=\"lazy\"</code> is a good default and I use it on <code>&lt;img&gt;</code> elements. But it does not cover <code>&lt;source&gt;</code> elements inside <code>&lt;picture&gt;</code>. The IntersectionObserver swap handles the source selection path, which is where the large WebP variants are chosen.</p>\n<p>On a page with a dozen images below the fold, the difference is measurable in bytes saved on initial load.</p>\n<h2 id=\"what-i-did-not-do\" tabindex=\"0\" data-toc-anchor=\"true\">What I did not do</h2>\n<ul>\n<li><strong>No runtime image CDN.</strong> Every variant is on disk, served by GitHub Pages, cached at the edge.</li><li><strong>No AVIF yet.</strong> AVIF support is excellent, but WebP still beats it on encode time for the gains at the sizes this blog uses. I will reconsider when AVIF encoders catch up on the CPU side.</li><li><strong>No third-party \"lazyload.js\".</strong> Twelve lines of IntersectionObserver replaces it.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>Responsive images do not need a service. A build step that generates variants, a manifest the renderer reads, and a markdown pass that emits <code>&lt;picture&gt;</code> are enough. The result is small pages, correct sizes, known dimensions, and lazy behavior that respects the user.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>&lt;picture&gt;</code> - MDN</a></li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Responsive_images?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Responsive images - MDN</a></li><li><a href=\"https://web.dev/articles/optimize-cls?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Optimize Cumulative Layout Shift - web.dev</a></li><li><a href=\"https://sharp.pixelplumbing.com/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Sharp image processing library</a></li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">IntersectionObserver - MDN</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-17-build-assets-pipeline.png",
      "date_published": "2026-05-17T00:00:00.000Z",
      "tags": [
        "Making-of",
        "Build-assets",
        "Performance",
        "Images"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-16-design-tokens-before-pages.html",
      "url": "https://dout.dev/posts/2026-05-16-design-tokens-before-pages.html",
      "title": "Design Tokens Before Pages",
      "summary": "The mistake I kept making",
      "content_html": "<h2 id=\"the-mistake-i-kept-making\" tabindex=\"0\" data-toc-anchor=\"true\">The mistake I kept making</h2>\n<p>Every time I started a personal site and went \"page first, design system later,\" I ended up with a pile of local exceptions. A <code>margin-top: 24px</code> here, a <code>color: #f5a623</code> there, a breakpoint at <code>768px</code> on one component and <code>720px</code> on another. By the third page the design was already incoherent, and the only way to fix it was to go back and retrofit tokens onto a living codebase. That is the worst moment to do it.</p>\n<p>For dout.dev I flipped the order. <strong>Tokens first, components second, pages third.</strong> This post is what that looks like in practice.</p>\n<h2 id=\"what-i-mean-by-tokens\" tabindex=\"0\" data-toc-anchor=\"true\">What I mean by \"tokens\"</h2>\n<p>Design tokens are not a library. They are a flat set of named decisions, expressed as CSS custom properties, about spacing, type, color, elevation, motion, and layout. The rest of the CSS reads those decisions and never hardcodes.</p>\n<p>The rule I hold to is simple: <strong>if a value appears in two components, it becomes a token</strong>. If it only appears once, I wait.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>:root {\n  /* Spacing scale */\n  --space-1: 0.25rem;\n  --space-2: 0.5rem;\n  --space-3: 0.75rem;\n  --space-4: 1rem;\n  --space-5: 1.5rem;\n  --space-6: 2rem;\n  --space-8: 3rem;\n\n  /* Type scale */\n  --text-sm: 0.875rem;\n  --text-base: 1rem;\n  --text-lg: 1.125rem;\n  --text-xl: 1.25rem;\n\n  /* Line-height families */\n  --font-lineheight-1: 1.1;\n  --font-lineheight-3: 1.5;\n\n  /* Surfaces and text */\n  --surface-1: #0b0b0f;\n  --surface-2: #161621;\n  --text-primary: #e7e7ef;\n  --text-muted: #a0a0b4;\n\n  /* Focus */\n  --focus-ring: 2px solid #ff6b3d;\n}</code></pre><p>That is a single source of truth. It does not know about pages. It does not know about components. It knows about decisions.</p>\n<h2 id=\"two-layers-not-one\" tabindex=\"0\" data-toc-anchor=\"true\">Two layers, not one</h2>\n<p>Raw tokens alone are not enough for a theme-able site. I use two layers:</p>\n<p><strong>Primitive tokens</strong> - the raw decisions above. They never change at runtime.</p>\n<p><strong>Semantic tokens</strong> - aliases that map intent to primitives. These are the ones components actually read, and these are the ones that flip between themes.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>:root {\n  --color-bg: var(--surface-1);\n  --color-bg-raised: var(--surface-2);\n  --color-fg: var(--text-primary);\n  --color-fg-muted: var(--text-muted);\n  --color-accent: #ff6b3d;\n}\n\n[data-color-scheme='light'] {\n  --color-bg: #fafafa;\n  --color-bg-raised: #ffffff;\n  --color-fg: #1a1a1a;\n  --color-fg-muted: #555566;\n}</code></pre><p>Components then write <code>color: var(--color-fg)</code>, never <code>color: #e7e7ef</code>. Theme switching becomes a matter of changing a handful of semantic tokens at the root, not rewriting every rule.</p>\n<h2 id=\"component-rules-i-enforce\" tabindex=\"0\" data-toc-anchor=\"true\">Component rules I enforce</h2>\n<p>Three rules keep the CSS from rotting.</p>\n<p><strong>No magic numbers in components.</strong> If a value is not a token, it is almost always a mistake. The exceptions are genuinely one-off values tied to a specific piece of geometry, and those get a comment.</p>\n<p><strong>No color literals outside the token file.</strong> Not even in media queries, not even in <code>box-shadow</code>. If a color is referenced, it is via a variable.</p>\n<p><strong>No breakpoint literals outside a shared map.</strong> Breakpoints are tokens too, expressed as container query breakpoints or named media queries, never inline.</p>\n<pre is=\"pix-highlighter\" data-lang=\"css\"><code>/* Good */\n.post-card {\n  padding: var(--space-5);\n  background: var(--color-bg-raised);\n  color: var(--color-fg);\n  border-radius: var(--radius-2);\n}\n\n/* Bad */\n.post-card {\n  padding: 24px;\n  background: #161621;\n  color: #e7e7ef;\n  border-radius: 8px;\n}</code></pre><p>The \"bad\" version works. It will also be unfixable in a year.</p>\n<h2 id=\"the-payoff-during-theming\" tabindex=\"0\" data-toc-anchor=\"true\">The payoff during theming</h2>\n<p>When I added the light theme and the accent selector, the change was a few dozen lines. Every component picked it up for free, because no component hardcoded a value. The dark mode toggle, the <code>prefers-color-scheme</code> hookup, and the accent switcher are all variations on the same idea: swap the semantic tokens at the root, let the cascade do the rest.</p>\n<p>Without the two-layer setup, theming a site usually means touching every component. With it, theming is one file.</p>\n<h2 id=\"where-i-drew-the-line\" tabindex=\"0\" data-toc-anchor=\"true\">Where I drew the line</h2>\n<p>I did not build an elaborate token pipeline. No Style Dictionary, no JSON sources compiled into multiple formats, no cross-platform tokens. This is a single-target CSS project. A flat <code>.css</code> file that declares custom properties is enough, and any tooling beyond that would be overhead without a matching benefit.</p>\n<p>If the project grew multiplatform - iOS, Android, email, a design tool plugin - I would pull in Style Dictionary. Until then, one file and discipline.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>If you are starting a personal site or a small design system, write the token layer before the first page. It costs an afternoon. It buys you theming, consistency, and the ability to make design changes in one place instead of twenty.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/CSS/--*?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">CSS Custom Properties - MDN</a></li><li><a href=\"https://www.w3.org/community/design-tokens/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">W3C Design Tokens Community Group</a></li><li><a href=\"https://amzn.github.io/style-dictionary/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Style Dictionary</a></li><li><a href=\"https://open-props.style/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Open Props</a> - a useful reference for a flat token set</li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-16-design-tokens-before-pages.png",
      "date_published": "2026-05-16T00:00:00.000Z",
      "tags": [
        "Making-of",
        "Design-systems",
        "Css"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-15-progressive-enhancement-as-contract.html",
      "url": "https://dout.dev/posts/2026-05-15-progressive-enhancement-as-contract.html",
      "title": "Progressive Enhancement as Contract",
      "summary": "The contract",
      "content_html": "<h2 id=\"the-contract\" tabindex=\"0\" data-toc-anchor=\"true\">The contract</h2>\n<p>Progressive enhancement has had a rough decade. Between single-page apps, islands architecture, and \"modern web\" sermons, the idea that a page should work without JavaScript got treated as either trivial or reactionary. Neither is true.</p>\n<p>The version I hold to, and that dout.dev ships, is this:</p>\n<blockquote>\n<p><strong>Every page renders, navigates, and communicates its core purpose without running a single byte of JavaScript. Every interactive feature is an enhancement, not a prerequisite.</strong></p>\n</blockquote>\n<p><strong>That is a contract. It is falsifiable.</strong> You can test it by disabling JavaScript in the browser and clicking around.</p>\n<h2 id=\"what-still-works-with-js-disabled\" tabindex=\"0\" data-toc-anchor=\"true\">What still works with JS disabled</h2>\n<p>A reader with no JavaScript, either by choice or because the script failed to load, gets:</p>\n<ul>\n<li><strong>The full content of every post.</strong> Text, images, code samples, headings, links.</li><li><strong>Navigation between pages.</strong> The header nav, archive links, tag and month archives, pagination.</li><li><strong>Search.</strong> The <code>search.html</code> page has a <code>&lt;form method=\"get\" action=\"/search.html\"&gt;</code>. With JS it filters live; without JS it is submitted as a GET request and the page loads with the same URL shape.</li><li><strong>Feeds and subscription links.</strong> The RSS and JSON Feed discovery links in the <code>&lt;head&gt;</code>.</li><li><strong>Comments discovery.</strong> Without Giscus running, the \"comments live in GitHub Discussions\" label and a direct link to the discussion.</li></ul>\n<p>What the reader loses: live search filtering, theme switching, scrollspy highlighting, the lazy-load WebP swap (native <code>loading=\"lazy\"</code> on the fallback image still works), and the clipboard-copy button on code blocks.</p>\n<p>Everything the reader loses is an enhancement. Nothing that is essential to reading the blog requires JavaScript.</p>\n<h2 id=\"how-the-markup-makes-that-true\" tabindex=\"0\" data-toc-anchor=\"true\">How the markup makes that true</h2>\n<p>The contract is enforced at markup time. Every interactive feature is layered on top of a working HTML primitive.</p>\n<h3 id=\"search\" tabindex=\"0\" data-toc-anchor=\"true\">Search</h3>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;form role=\"search\" method=\"get\" action=\"/search.html\" data-search-form&gt;\n  &lt;label for=\"q\"&gt;Search&lt;/label&gt;\n  &lt;input type=\"text\" id=\"q\" name=\"q\" /&gt;\n  &lt;button type=\"submit\"&gt;Search&lt;/button&gt;\n&lt;/form&gt;</code></pre><p>With JS disabled, submitting the form loads <code>/search.html?q=whatever</code>. The server-rendered search page parses the URL and displays results from the prebuilt JSON indexes - no JS needed for that match, because the JSON is small enough to be parsed server-side at build.</p>\n<p>Wait, a static blog has no server. Right. The \"server\" in this case is the CMS, which at build time generates <code>/search.html</code> as a shell that reads URL params on page load. Without JS, the form submits, the page reloads with the URL, and the page itself shows a placeholder: \"Enable JavaScript for live search, or browse by tag or month below.\" That placeholder links to the archive pages, which are fully static.</p>\n<p>That is the graceful-degradation path for search. It is not as good as the JS path, but it does not dead-end the reader.</p>\n<h3 id=\"theme-switching\" tabindex=\"0\" data-toc-anchor=\"true\">Theme switching</h3>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;button data-theme-toggle aria-pressed=\"false\" hidden&gt;Toggle theme&lt;/button&gt;</code></pre><p>The button is <code>hidden</code> in the initial markup. Only the JS that actually implements the theme switch removes the <code>hidden</code> attribute. Readers without JS never see a button that does nothing.</p>\n<p>This is a general pattern: any UI element that requires JS to function is hidden by default and revealed by the enhancement script. The opposite pattern - show the button, have it do nothing when clicked - is worse, because it breaks the \"visible things work\" contract that users assume.</p>\n<h3 id=\"scrollspy\" tabindex=\"0\" data-toc-anchor=\"true\">Scrollspy</h3>\n<p>The outline navigation is a normal list of jump-links. Without JS, clicking a link scrolls to the section. With JS, the active section gets highlighted as the user scrolls. The enhancement is purely decorative; the base experience is unchanged.</p>\n<h3 id=\"code-blocks\" tabindex=\"0\" data-toc-anchor=\"true\">Code blocks</h3>\n<p>Fenced code blocks render as plain <code>&lt;pre&gt;&lt;code&gt;</code> in the markdown output, upgraded by <code>&lt;pre is=\"pix-highlighter\"&gt;</code> when JS runs. Without JS, the code is monospaced and unhighlighted. Readable. Not pretty.</p>\n<p>The copy button is added by the custom element's <code>connectedCallback</code> - no element in the pre-JS DOM, nothing to fail.</p>\n<h2 id=\"how-the-build-verifies-it\" tabindex=\"0\" data-toc-anchor=\"true\">How the build verifies it</h2>\n<p>Writing the contract is not enough. The build enforces it with a small no-JS check.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>import { test } from 'node:test';\nimport { chromium } from 'playwright';\n\ntest('home renders without JS', async () =&gt; {\n  const browser = await chromium.launch();\n  const context = await browser.newContext({ javaScriptEnabled: false });\n  const page = await context.newPage();\n\n  await page.goto('http://localhost:3000/');\n\n  const main = await page.$('main');\n  const text = await main.textContent();\n\n  if (!text.includes('Welcome to dout.dev')) {\n    throw new Error('Home content missing with JS disabled');\n  }\n\n  const posts = await page.$$('a[href^=\"/posts/\"]');\n  if (posts.length &lt; 5) {\n    throw new Error('Post list not rendered without JS');\n  }\n\n  await browser.close();\n});</code></pre><p>The check runs against the home, the archive, a random post, and the search page. It asserts that the essential content is present, that internal links resolve, and that no error text is shown. If any of these fails, the quality gate stops the build.</p>\n<p>That is the difference between \"we care about progressive enhancement\" and \"the site provably works without JS.\"</p>\n<h2 id=\"what-i-explicitly-chose-not-to-support\" tabindex=\"0\" data-toc-anchor=\"true\">What I explicitly chose not to support</h2>\n<ul>\n<li><strong>Readers on truly ancient browsers.</strong> The progressive enhancement contract is about JavaScript availability, not CSS support. If your browser does not understand container queries or <code>:has()</code>, you get a simpler layout; you still get the content. But I do not vendor polyfills for IE11.</li><li><strong>Offline fetch without service worker.</strong> Service worker caching is itself an enhancement. Without it, offline means no site.</li><li><strong>Form submission to external endpoints.</strong> The only form on the site is search, which submits to itself. I do not depend on external form processors.</li></ul>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>Progressive enhancement is a testable contract, not a stance. Pick a clear line - \"the core experience works without JS\" - and let the build verify it. The result is a more resilient site and a simpler mental model. The enhancements can then be as ambitious as you want, because you know what they are enhancing.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li><a href=\"https://alistapart.com/article/understandingprogressiveenhancement/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Understanding progressive enhancement - A List Apart</a></li><li><a href=\"https://resilientwebdesign.com/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Resilient Web Design - Jeremy Keith</a></li><li><a href=\"https://playwright.dev/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Playwright</a> - for headless no-JS verification</li><li><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTML/Element/noscript?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>&lt;noscript&gt;</code> - MDN</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-15-progressive-enhancement-as-contract.png",
      "date_published": "2026-05-15T00:00:00.000Z",
      "tags": [
        "Making-of",
        "Progressive-enhancement",
        "Accessibility",
        "Vanilla-js"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-14-archives-tags-months-series.html",
      "url": "https://dout.dev/posts/2026-05-14-archives-tags-months-series.html",
      "title": "Archives, Tags, Months, and Series",
      "summary": "Three ways to slice the same posts",
      "content_html": "<h2 id=\"three-ways-to-slice-the-same-posts\" tabindex=\"0\" data-toc-anchor=\"true\">Three ways to slice the same posts</h2>\n<p>A blog is a time-ordered list of posts. That linear view is useful for the home page and the RSS feed. It is bad for discovery. Readers who arrive on a single post want a way to find more like it, and \"more like it\" is not a single axis.</p>\n<p>dout.dev ships three archives:</p>\n<ul>\n<li><strong>By tag</strong> - <code>/tags/&lt;slug&gt;.html</code>. Topical similarity. \"Show me all the accessibility posts.\"</li><li><strong>By month</strong> - <code>/months/&lt;YYYY-MM&gt;.html</code>. Temporal browsing. \"What did you write in 2026-03?\"</li><li><strong>By series</strong> - <code>/series/&lt;slug&gt;.html</code>. Intentional groupings. \"Read the making-of series in order.\"</li></ul>\n<p><strong>Each is a first-class surface</strong> with its own page, its own RSS feed, its own OG image, and its own pagination. All three are generated from the same normalized dataset the post generator uses, so there is no drift between what a tag page shows and what the post pages claim.</p>\n<h2 id=\"the-url-shape\" tabindex=\"0\" data-toc-anchor=\"true\">The URL shape</h2>\n<p>The URL contract is opinionated and consistent across the three archives.</p>\n<ul>\n<li><strong>Page 1 is flat.</strong> <code>/tags/accessibility.html</code>, <code>/months/2026-08.html</code>, <code>/series/making-of.html</code>.</li><li><strong>Pages 2+ live in a subfolder.</strong> <code>/tags/accessibility/2/</code>, <code>/months/2026-08/2/</code>, <code>/series/making-of/2/</code>.</li></ul>\n<p>Why the split? Two reasons.</p>\n<p><strong>Flat URLs for page 1 are canonical.</strong> They are the URLs that show up in RSS feeds, sitemaps, and shares. They should look like leaves of a tree, not like indexes.</p>\n<p><strong>Subfolders for pages 2+ prevent URL-pattern collision.</strong> A URL like <code>/tags/accessibility/2.html</code> looks like it might be a post slug. <code>/tags/accessibility/2/</code> is unambiguously a paginated archive.</p>\n<p>The templates in the repo enforce this. The paginator receives the scope and the page number and emits the URL based on these rules, not string-concatenated from an input.</p>\n<h2 id=\"the-pagination-component-once\" tabindex=\"0\" data-toc-anchor=\"true\">The pagination component, once</h2>\n<p>Pagination on an archive is the same pagination on the search page, which is the same pagination I will ship on any future list view. It lives in one component, <code>src/components/pagination.html</code>, and gets included.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;include src=\"../components/pagination.html\"&gt;&lt;/include&gt;</code></pre><p>The parent template exposes a <code>pagination</code> object with the shape:</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>{\n  current: 1,\n  total: 3,\n  baseUrl: '/tags/accessibility/',\n  items: [\n    { kind: 'prev', url: null, disabled: true },\n    { kind: 'number', page: 1, url: '/tags/accessibility.html', current: true },\n    { kind: 'number', page: 2, url: '/tags/accessibility/2/', current: false },\n    { kind: 'number', page: 3, url: '/tags/accessibility/3/', current: false },\n    { kind: 'next', url: '/tags/accessibility/2/', disabled: false },\n  ],\n}</code></pre><p>The component renders that shape. Every archive and the search use it. One source of truth for styling, for accessibility, for behavior.</p>\n<h2 id=\"aria-current-rel-prev-rel-next-and-the-ellipsis-problem\" tabindex=\"0\" data-toc-anchor=\"true\"><code>aria-current</code>, <code>rel=\"prev\"</code> / <code>rel=\"next\"</code>, and the ellipsis problem</h2>\n<p>Three small details that make pagination accessible and crawlable.</p>\n<p><strong><code>aria-current=\"page\"</code> on the current page link.</strong> Screen readers announce \"current page, 2 of 5\" instead of just \"2.\" That is the correct value for pagination, as opposed to <code>aria-current=\"location\"</code> which is what I use for scrollspy anchors.</p>\n<p><strong><code>rel=\"prev\"</code> and <code>rel=\"next\"</code> on the adjacent page links.</strong> Crawlers use these to understand that the pages are part of a paginated sequence. Google has officially deprecated <code>rel=\"prev/next\"</code> for Search, but other crawlers still respect it and the cost is nothing.</p>\n<p><strong>Ellipses are not links.</strong> When there are many pages, the pagination compresses with <code>...</code> markers. Those markers are <code>&lt;span&gt;</code>, not <code>&lt;a&gt;</code>. A keyboard user who tabs through pagination should not land on a non-interactive element, and a screen reader should skip them.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;nav data-pagination aria-label=\"Pagination\"&gt;\n  &lt;a href=\"/tags/accessibility.html\" rel=\"prev\"&gt;Previous&lt;/a&gt;\n  &lt;a href=\"/tags/accessibility.html\"&gt;1&lt;/a&gt;\n  &lt;a href=\"/tags/accessibility/2/\" aria-current=\"page\"&gt;2&lt;/a&gt;\n  &lt;span aria-hidden=\"true\"&gt;...&lt;/span&gt;\n  &lt;a href=\"/tags/accessibility/5/\"&gt;5&lt;/a&gt;\n  &lt;a href=\"/tags/accessibility/3/\" rel=\"next\"&gt;Next&lt;/a&gt;\n&lt;/nav&gt;</code></pre><p>The <code>aria-label=\"Pagination\"</code> on the <code>&lt;nav&gt;</code> is required because the page has more than one <code>&lt;nav&gt;</code> element (primary nav, post nav, archive pagination).</p>\n<h2 id=\"per-archive-rss\" tabindex=\"0\" data-toc-anchor=\"true\">Per-archive RSS</h2>\n<p>Each archive has its own RSS feed. <code>/tags/accessibility.xml</code> contains only posts tagged accessibility. Subscribers who care about one topic can follow it without seeing the rest.</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;link rel=\"alternate\" type=\"application/rss+xml\" title=\"dout.dev - accessibility\" href=\"/tags/accessibility.xml\" /&gt;</code></pre><p>The feed is generated from the tag-filtered subset of the dataset. Same template, same pagination-less shape (RSS does not paginate), different inputs. About ten extra lines of generator code per archive type.</p>\n<h2 id=\"the-tag-slug-rules\" tabindex=\"0\" data-toc-anchor=\"true\">The tag slug rules</h2>\n<p>Slugging is the kind of thing that looks trivial until it bites. The rules:</p>\n<ol>\n<li>Lowercase.</li><li>Replace spaces with hyphens.</li><li>Strip punctuation except hyphens.</li><li>Collapse consecutive hyphens.</li><li>Strip leading and trailing hyphens.</li></ol>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>function slugify(input) {\n  return input\n    .toLowerCase()\n    .normalize('NFKD')\n    .replace(/[\\u0300-\\u036f]/g, '') // strip diacritics\n    .replace(/[^a-z0-9-]+/g, '-')\n    .replace(/-+/g, '-')\n    .replace(/^-|-$/g, '');\n}</code></pre><p>The slug normalizer runs on every tag at CMS normalization time. Tags that slugify to the same value are merged with a warning in the build output. That prevents the \"CSS\" / \"css\" / \"Css\" tag fragmentation that happens without a rule.</p>\n<h2 id=\"series-are-different-on-purpose\" tabindex=\"0\" data-toc-anchor=\"true\">Series are different on purpose</h2>\n<p>Tags are unordered. Series are ordered. A series archive renders the posts in publication order, not reverse chronological, because the reader wants part 1 before part 2.</p>\n<pre is=\"pix-highlighter\" data-lang=\"js\"><code>if (archive.kind === 'series') {\n  posts.sort((a, b) =&gt; a.date.localeCompare(b.date));\n} else {\n  posts.sort((a, b) =&gt; b.date.localeCompare(a.date));\n}</code></pre><p>That is a six-line difference in the generator. The templates do not need to know - they receive posts in the correct order and render them.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>Archives are a design surface, not a free byproduct of having tags. Decide the URL shape. Ship a single pagination component. Respect the accessibility details that keep pagination usable with a keyboard and a screen reader. Give each archive its own feed. The result is a blog that is actually discoverable, not just readable.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li><a href=\"https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/rel?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Pagination with <code>rel=\"next\"</code> and <code>rel=\"prev\"</code> - MDN</a></li><li><a href=\"https://www.w3.org/WAI/ARIA/apg/practices/structural-roles/?from=dout.dev#aria-current\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\"><code>aria-current</code> - ARIA Authoring Practices Guide</a></li><li><a href=\"https://www.sitemaps.org/protocol.html?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Sitemaps Protocol</a></li><li><a href=\"https://warpspire.com/posts/url-design/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">URL design - Kyle Neath</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-14-archives-tags-months-series.png",
      "date_published": "2026-05-14T00:00:00.000Z",
      "tags": [
        "Making-of",
        "Archives",
        "Seo",
        "Accessibility"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-13-template-engine-and-template-structure.html",
      "url": "https://dout.dev/posts/2026-05-13-template-engine-and-template-structure.html",
      "title": "Template Engine and Template Structure",
      "summary": "Three constraints that shaped the grammar",
      "content_html": "<h2 id=\"three-constraints-that-shaped-the-grammar\" tabindex=\"0\" data-toc-anchor=\"true\">Three constraints that shaped the grammar</h2>\n<p>Three constraints that usually pull in different directions.</p>\n<p><strong>Readable like HTML.</strong> A template should look like a document, not a programming language wearing angle brackets. No curly-heavy logic mixed into markup.</p>\n<p><strong>Expressive enough for a real blog.</strong> Conditionals, loops, nested layouts, inline expressions - the minimum viable set has to cover these or the CMS ends up exporting special-case data structures for every page type.</p>\n<p><strong>No <code>eval</code>.</strong> Anything user-controlled that turns into runtime code is a liability. The expression evaluator had to parse and interpret, not delegate to the JavaScript engine.</p>\n<p><strong>The resulting engine is a few hundred lines</strong> and lives under <code>scripts/template-engine/</code>. It runs during the CMS build. It does not ship to the browser.</p>\n<h2 id=\"the-four-primitives\" tabindex=\"0\" data-toc-anchor=\"true\">The four primitives</h2>\n<p>The grammar has exactly four things.</p>\n<p><strong><code>&lt;extends src=\"...\"&gt;</code></strong> - a template declares that it extends a base layout. The base layout contains named slots.</p>\n<p><strong><code>&lt;block name=\"...\"&gt;</code></strong> - inside an extending template, a block overrides the same-named slot in the base layout.</p>\n<p><strong><code>&lt;include src=\"...\"&gt;</code></strong> - inline composition. The included fragment is inserted with access to the current context. Typical uses: the pagination component, the post card, the footer.</p>\n<p><strong>Expression references</strong> - an expression reference can use an optional filter pipeline. Filters are ordinary functions. The grammar for expressions supports member access, ternary, logical operators, and literal values.</p>\n<p>Control flow is expressed with custom elements that look like HTML: <code>&lt;if condition=\"...\"&gt;</code>, <code>&lt;for each=\"item in collection\"&gt;</code>, <code>&lt;switch&gt;</code> with <code>&lt;case&gt;</code> and <code>&lt;default&gt;</code>. That keeps the templates in one grammar instead of mixing angle brackets with mustache-style blocks.</p>\n<h2 id=\"how-extends-and-blocks-work\" tabindex=\"0\" data-toc-anchor=\"true\">How extends and blocks work</h2>\n<p>The engine parses the base layout once and indexes its blocks by name. When an extending template is rendered, the engine walks the child template, collects its <code>&lt;block&gt;</code> elements, and uses them to override the parent slots. Anything in the extending template outside of a block is discarded by design - it would have no stable place to land.</p>\n<p>The practical upshot is that a post template reads like this:</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;extends src=\"./layouts/base.html\"&gt;\n  &lt;block name=\"title\"&gt;Title from context - dout.dev&lt;/block&gt;\n  &lt;block name=\"content\"&gt;\n    &lt;article&gt;...&lt;/article&gt;\n  &lt;/block&gt;\n&lt;/extends&gt;</code></pre><p>The base layout declares the slots:</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;title&gt;&lt;block name=\"title\"&gt;dout.dev&lt;/block&gt;&lt;/title&gt;\n&lt;main&gt;&lt;block name=\"content\"&gt;&lt;/block&gt;&lt;/main&gt;</code></pre><p>Slots can have default content. Blocks are required only if the parent says so.</p>\n<h2 id=\"expressions-without-eval\" tabindex=\"0\" data-toc-anchor=\"true\">Expressions without eval</h2>\n<p>Expressions for values like post cover dimensions or tag counts look like JavaScript. They are not - they are parsed into a small AST and walked by an interpreter.</p>\n<p>The interpreter supports:</p>\n<ul>\n<li>literal values (strings, numbers, booleans, null);</li><li>member access and optional chaining;</li><li>arithmetic and comparison operators;</li><li>logical <code>&amp;&amp;</code>, <code>||</code>, <code>!</code>;</li><li>ternary conditional;</li><li>function calls for a whitelisted set of filters.</li></ul>\n<p>Everything else is a syntax error. No global lookup, no prototype walk, no <code>Function</code> constructor. The evaluator is stateless and only reads from the context object passed in.</p>\n<p>This matters for two reasons. First, it means rendering a template is deterministic and side-effect-free. Second, it means I can treat the template language as a safe expression surface, even when the inputs are derived from user content.</p>\n<h2 id=\"why-custom-elements-for-control-flow\" tabindex=\"0\" data-toc-anchor=\"true\">Why custom elements for control flow</h2>\n<p>A common alternative is to use curly-brace blocks for conditionals, loops, and closing statements. I tried that. The mix of angle brackets and curlies made templates hard to read for non-trivial layouts. Using custom elements like <code>&lt;if&gt;</code> and <code>&lt;for&gt;</code> keeps the document shape consistent. Editors and formatters treat them as HTML; the indentation logic is obvious.</p>\n<p>There is one rule I learned the hard way: do not nest <code>&lt;if&gt;</code> inside an opening tag to conditionally add attributes. The parser gets confused, and so does the reader. Use inline expressions in the attribute value instead:</p>\n<pre is=\"pix-highlighter\" data-lang=\"html\"><code>&lt;img width=\"expression: cover width or empty\" height=\"expression: cover height or empty\" /&gt;</code></pre><p>That rule is documented in the repository README, because it is one of those things that tripped me up twice before I wrote it down.</p>\n<h2 id=\"what-the-engine-does-not-do\" tabindex=\"0\" data-toc-anchor=\"true\">What the engine does not do</h2>\n<p>It does not support runtime templates from untrusted content. It does not re-parse the base layout per render; that is cached. It does not allow templates to import JavaScript. It does not have a plugin system.</p>\n<p>It does what a blog needs. The features I do not add are the features I do not have to maintain.</p>\n<h2 id=\"the-takeaway\" tabindex=\"0\" data-toc-anchor=\"true\">The takeaway</h2>\n<p>If you are building a custom SSG and want templates that read like HTML, you do not need a full templating library. A few hundred lines of parser, a four-primitive grammar, a small set of filters, and a sandboxed expression interpreter covers the whole surface of a well-behaved blog. The features you do not add are the features you do not have to maintain.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-13-template-engine-and-template-structure.png",
      "date_published": "2026-05-13T00:00:00.000Z",
      "tags": [
        "Making-of",
        "Template-engine",
        "Html",
        "Architecture"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-12-custom-ssg-pipeline.html",
      "url": "https://dout.dev/posts/2026-05-12-custom-ssg-pipeline.html",
      "title": "Custom SSG Pipeline: Markdown to Static HTML",
      "summary": "The pipeline, in one picture",
      "content_html": "<h2 id=\"the-pipeline-in-one-picture\" tabindex=\"0\" data-toc-anchor=\"true\">The pipeline, in one picture</h2>\n<p>Everything starts in <code>data/posts/</code>. Each post is a markdown file with YAML front matter. The build is four stages:</p>\n<ol>\n<li><strong>Scan.</strong> Read every markdown file, parse front matter, validate required fields.</li><li><strong>Normalize.</strong> Compute slug, excerpt, keywords, reading time, canonical URL, tag and month and series indexes.</li><li><strong>Render.</strong> Convert markdown to HTML, pass the normalized dataset and per-page context into the template engine, write files under <code>src/</code>.</li><li><strong>Emit.</strong> Generate feeds, sitemap, search indexes, and OG images.</li></ol>\n<p><strong>The pipeline is a plain Node script</strong> orchestrated from <code>scripts/cms/build.js</code>. No framework, no plugin system, no watcher magic outside of a small <code>cms:watch</code> entry point.</p>\n<h2 id=\"stage-1-scan\" tabindex=\"0\" data-toc-anchor=\"true\">Stage 1: scan</h2>\n<p>The scan step reads the directory and returns an array of raw post records. It is the thinnest possible layer: one call to <code>gray-matter</code> per file, a sanity check on required fields, and a sort by date.</p>\n<p>What the scan does not do is also important: no rendering, no side effects, no cross-post computation. If the scan fails, it fails loud, at one file, with a clear message. That makes debugging content errors a five-second loop.</p>\n<h2 id=\"stage-2-normalize\" tabindex=\"0\" data-toc-anchor=\"true\">Stage 2: normalize</h2>\n<p>Normalization is where most of the thinking happens. Given the raw post records, it produces:</p>\n<ul>\n<li>a stable slug per post, derived from the filename, with a predictable URL;</li><li>an excerpt and a keyword set, computed from the rendered body (after a first pass of markdown);</li><li>the tag index, the month index, and the series index, each with counts and canonical slugs;</li><li>the next/previous pointers for each post;</li><li>the pagination plan for every listing view.</li></ul>\n<p>This step is deliberately pure. Given the same inputs it returns the same outputs. That lets the page generator be a dumb renderer and lets the tests be boring.</p>\n<h2 id=\"stage-3-render\" tabindex=\"0\" data-toc-anchor=\"true\">Stage 3: render</h2>\n<p>The renderer walks the normalized dataset and writes files. For each post it renders the post page. For each tag, month, and series it renders the listing page and, if needed, subsequent paginated pages at <code>/{scope}/{slug}/{n}/</code>. It also renders the home, archive, about, playground, offline, and 404 pages.</p>\n<p>The template engine is small and understands four things: extending a base layout, declaring blocks, including fragments, and evaluating expressions with filters. There is no virtual DOM, no hydration layer, no partial rendering. The output is HTML.</p>\n<p>The renderer is where the editorial shape of the site is visible. Every view is a template that describes a real document, not a component tree that happens to serialize to HTML. You can open the generated <code>src/posts/*.html</code> and read it like a normal document. That is the property I wanted.</p>\n<h2 id=\"stage-4-emit\" tabindex=\"0\" data-toc-anchor=\"true\">Stage 4: emit</h2>\n<p>The final stage produces everything that is derived but not a page:</p>\n<ul>\n<li><code>feed.rss</code>, <code>feed.json</code>, <code>feed.xml</code> for subscribers;</li><li><code>sitemap.xml</code> for crawlers;</li><li><code>src/data/*.json</code> for the client-side search index;</li><li><code>src/assets/og/posts/*.png</code> for social previews.</li></ul>\n<p>OG images are worth their own post; they are rendered server-side at build time from an SVG template and rasterized with Sharp. That one decision - \"generate them at build, never at runtime\" - eliminated an entire category of infrastructure I did not want to maintain.</p>\n<h2 id=\"why-the-pipeline-stays-small\" tabindex=\"0\" data-toc-anchor=\"true\">Why the pipeline stays small</h2>\n<p>Three constraints keep the code honest:</p>\n<p><strong>One pass, no hidden passes.</strong> Every derivation lives in the normalize step. The renderer never computes global state. The emitter never reaches back into the renderer. The dataset flows in one direction.</p>\n<p><strong>No dynamic content at runtime.</strong> Everything that can be precomputed is precomputed. The only thing running in the browser is the small progressive-enhancement layer: theme switching, search over the prebuilt indexes, scrollspy, and the Giscus comments.</p>\n<p><strong>No framework to appease.</strong> There are no \"pages\" folders, no special file-based routing rules, no magic exports. Every page exists because a script wrote it.</p>\n<p>The result is a build that runs in under a few seconds on a cold cache and is cheap enough that I never think about it. The pipeline is small enough to read end-to-end in an afternoon.</p>\n<h2 id=\"the-template-engine\" tabindex=\"0\" data-toc-anchor=\"true\">The template engine</h2>\n<p>The next post in this series goes into how <code>extends</code>, <code>block</code>, <code>include</code>, and the expression grammar work without <code>eval</code> - and why the grammar is close to HTML instead of curly-brace syntax.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-12-custom-ssg-pipeline.png",
      "date_published": "2026-05-12T00:00:00.000Z",
      "tags": [
        "Making-of",
        "Architecture",
        "Static-site",
        "Cms"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-05-05-why-no-astro-eleventy-next.html",
      "url": "https://dout.dev/posts/2026-05-05-why-no-astro-eleventy-next.html",
      "title": "Why I Skipped Astro, Eleventy and Next for My Blog",
      "summary": "The default answer and why I ignored it",
      "content_html": "<h2 id=\"the-default-answer-and-why-i-ignored-it\" tabindex=\"0\" data-toc-anchor=\"true\">The default answer and why I ignored it</h2>\n<p>If someone asks me today \"what should I use for a personal developer blog,\" my answer is almost always Astro. It is a reasonable default. The ergonomics are good, the output is lean, the community is active, and the opinionated parts are mostly defensible.</p>\n<p>For dout.dev, <strong>I did not pick Astro. Nor Eleventy, nor Next</strong>. I wrote a static site generator from scratch, on purpose. This post is the honest accounting of why - not to argue the frameworks are wrong, but to explain when building your own pipeline is actually a reasonable choice rather than a vanity project.</p>\n<h2 id=\"the-three-hidden-costs-of-a-framework-for-a-blog\" tabindex=\"0\" data-toc-anchor=\"true\">The three hidden costs of a framework for a blog</h2>\n<p>Frameworks are not free. The invoice just arrives late.</p>\n<p><strong>Upgrade churn.</strong> A blog is a ten-year project, not a sprint. Every framework I have ever used for a side project demanded a non-trivial migration every two to three years. The migrations are rarely hard; they are consistently annoying. Multiply by the number of projects you maintain. Compare to plain markdown, HTML, and CSS, which have needed zero migrations for twenty years.</p>\n<p><strong>Opinion bleed.</strong> Frameworks encode opinions about routing, data fetching, layout composition, hydration, bundling. Most of those opinions are good. A few will collide with what you actually want, and the escape hatches are usually worse than the happy path. For a blog, I wanted opinions I already hold - about accessibility, about URL shapes, about progressive enhancement - not opinions a framework has about islands or RSC.</p>\n<p><strong>Runtime ballast.</strong> Even \"zero-JS-by-default\" frameworks ship a non-trivial runtime once you add interactivity. For a blog that is mostly text, every kilobyte of framework JavaScript is paying for a feature I am not using. Users on slow networks pay for it twice.</p>\n<p>None of these are deal-breakers. They are line items. For some projects the line items are fine. For this one, I wanted the invoice to be short.</p>\n<h2 id=\"what-i-got-by-going-custom\" tabindex=\"0\" data-toc-anchor=\"true\">What I got by going custom</h2>\n<p>I did not rebuild the world. I rebuilt a small, boring world where every component is easy to replace and every layer is under two hundred lines.</p>\n<p>The SSG is a markdown-to-HTML pipeline that emits posts, tag and month archives, series, paginated listings, RSS, JSON Feed, sitemap, and OG images. The template engine is a small one that reads templates extending a base layout and fills blocks. The CMS is a normalization step that reads front matter, derives slugs, builds indexes, and hands typed data to the page generator. The design system is a set of CSS custom properties and a few dozen components.</p>\n<p>The total amount of code I maintain is small enough that I could rewrite the whole pipeline over a weekend if I needed to. That is the ceiling I wanted.</p>\n<h2 id=\"what-it-costs-me\" tabindex=\"0\" data-toc-anchor=\"true\">What it costs me</h2>\n<p><strong>No ecosystem.</strong> No plugins, no content collections, no MDX components written by someone smarter. Whatever I need, I write. That is fine for a blog; it would not be fine for a product.</p>\n<p><strong>No free lunches.</strong> The responsive image pipeline is mine. The OG image renderer is mine. The search index is mine. The pagination URLs, the <code>rel=\"next\"</code> tags, the structured data - all of it mine. That is where the AI copilot paid for itself, because writing those from scratch without help would have been the kind of slog that kills side projects.</p>\n<p><strong>No upgrade path.</strong> When a web platform API becomes interesting, I integrate it myself. When a framework adds a feature I envy, I reimplement the interesting part.</p>\n<h2 id=\"when-to-choose-a-framework-anyway\" tabindex=\"0\" data-toc-anchor=\"true\">When to choose a framework anyway</h2>\n<p>This post is not \"frameworks are bad.\" They are often the right choice. I would reach for Astro or Eleventy when:</p>\n<ul>\n<li>the site will have more than one author, and the mental overhead of a custom system would tax contributors;</li><li>the site is a product, not a blog, and plugin velocity matters;</li><li>the time budget is days, not weeks, and the quality of the default output is acceptable;</li><li>the project will be handed off to someone who did not build it;</li><li>the feature surface includes anything I have never built before and do not want to.</li></ul>\n<p>None of those applied here. dout.dev is a personal editorial project with a single author who has opinions and time.</p>\n<h2 id=\"the-actual-conclusion\" tabindex=\"0\" data-toc-anchor=\"true\">The actual conclusion</h2>\n<p>Pick the tool that matches the ten-year horizon of the thing you are building, not the ninety-minute horizon of the first post. For most people that points at Astro. For this project - single author, strong opinions, no migration budget - it pointed at a handful of scripts and a design system I own end-to-end.</p>\n<p>If you are starting a blog today and you want to ship something this weekend, use Astro. If you want to understand every line that runs, build the pipeline yourself. Both are defensible. The mistake is building the pipeline when you wanted to ship, or picking a framework when you wanted to understand.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-05-05-why-no-astro-eleventy-next.png",
      "date_published": "2026-05-05T00:00:00.000Z",
      "tags": [
        "Making-of",
        "Architecture",
        "Static-site"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-04-28-rebuilding-dout-dev-with-ai-copilot.html",
      "url": "https://dout.dev/posts/2026-04-28-rebuilding-dout-dev-with-ai-copilot.html",
      "title": "Rebuilding dout.dev in Weeks With an AI Copilot",
      "summary": "The honest framing",
      "content_html": "<h2 id=\"the-honest-framing\" tabindex=\"0\" data-toc-anchor=\"true\">The honest framing</h2>\n<p>I did not \"ask an AI to build my blog.\" I rebuilt dout.dev as a hands-on engineering project and used an AI copilot the same way you would use a very patient, very fast pair-programmer who happens to remember every file you pointed at. The difference from previous attempts is not that I wrote less code. It is that I spent far less time on the parts that used to drain me: boilerplate, repetitive refactors, sanity-checking conventions across hundreds of files, and the kind of careful mechanical work that makes a codebase consistent.</p>\n<p><strong>What used to be a three-month weekend project compressed into a handful of focused sessions</strong>, split across nineteen milestones. This is the tally.</p>\n<h2 id=\"the-milestones-i-actually-shipped\" tabindex=\"0\" data-toc-anchor=\"true\">The milestones I actually shipped</h2>\n<p>The roadmap is not a vague \"rewrite\" entry. It is a sequence of concrete units of work:</p>\n<ul>\n<li>M0-M2: repository bootstrap, template engine, and the in-repo CMS.</li><li>M3-M7: layouts, post page, archives, universal pagination, home/about/404/playground/offline.</li><li>M8-M10: responsive images, theming, client-side search.</li><li>M11-M15: SEO and OG images at build time, feeds, progressive-enhancement micro-UX, accessibility pass, CSP.</li><li>M17-M19: analytics (page hits only), CI/CD with preview on PR, quality and regression gate.</li></ul>\n<p>Each milestone had an exit checklist. When the list was green, I moved on. That discipline is what kept the AI useful instead of making it a distraction.</p>\n<h2 id=\"what-the-copilot-did-well\" tabindex=\"0\" data-toc-anchor=\"true\">What the copilot did well</h2>\n<p>Three things carried most of the weight.</p>\n<p><strong>Repeat-at-scale edits.</strong> Rename a token across the design system, adjust heading semantics in dozens of templates, regenerate feed entries when the schema changes. I described the intent once and audited the diff afterwards. That is maybe a third of the total time I spent on the rewrite, and it is the part I would otherwise have abandoned.</p>\n<p><strong>Search-and-explain inside the repo.</strong> \"Show me every place where we compute the canonical URL.\" \"What renders the article sidebar and where does the scrollspy wire up?\" I would have answered these questions eventually with ripgrep and memory. The copilot answered them in seconds and kept me in flow.</p>\n<p><strong>Drafts I could edit.</strong> For the template engine, the CMS normalization, the OG image renderer, the archive paginator, I asked for a starting draft, read it, threw away the parts I did not like, and shaped the rest. That is faster than a blank file, and it is faster than copying-and-adapting something I half remember from a past project.</p>\n<h2 id=\"what-i-did-not-delegate\" tabindex=\"0\" data-toc-anchor=\"true\">What I did not delegate</h2>\n<p>The architecture decisions. Whether to use a framework or build a template engine. Whether to render OG images with Sharp at build time or punt to a runtime service. Whether to accept an extra dependency for code highlighting or write a custom element. What \"WCAG 2.2 AA\" actually means in the post layout and how to verify it. The CSP. The information architecture of archives. The pagination URL shape.</p>\n<p>I also did not delegate judgment on code quality. The copilot would happily write something that works; I read the diff and asked for a smaller, simpler, more boring version at least half the time. Boring is a feature.</p>\n<h2 id=\"what-it-got-wrong\" tabindex=\"0\" data-toc-anchor=\"true\">What it got wrong</h2>\n<p>It invented a nonexistent flag in a tool once. It occasionally produced patterns that passed tests but did not match the rest of the codebase. It sometimes wrote comments that described the task instead of the code. And it needed to be told, often, that adding error handling for an impossible case is worse than not adding it.</p>\n<p>None of these are catastrophic. All of them are the reason a human still reads every diff.</p>\n<h2 id=\"the-time-i-actually-saved\" tabindex=\"0\" data-toc-anchor=\"true\">The time I actually saved</h2>\n<p>Realistic estimate, without the copilot, for the nineteen milestones in the shape they landed: three to five months of evenings and weekends. With the copilot, driven hard with clear prompts and strict reviews: a handful of focused weeks. The ratio is not 10x and it is not 2x. It depends on the task. Mechanical refactors and exploratory scaffolding are much faster; architectural decisions and accessibility audits are not.</p>\n<p>The more honest measure is that I finished. The previous rewrites stalled around milestone M5 or M6, because the gap between \"I can see the finish line\" and \"I have the energy to walk there\" was too wide.</p>\n<h2 id=\"what-actually-changed\" tabindex=\"0\" data-toc-anchor=\"true\">What actually changed</h2>\n<p>Writing posts is no longer a project. The pipeline turns markdown into a pre-rendered, accessible, fast page with feeds, sitemap, OG image, and comments - in the time it takes to run one build command. That is what nineteen milestones of engineering bought.</p>\n<p>The rest of this series walks through each layer: what it does, why it is shaped that way, and where the tradeoffs landed.</p>\n",
      "image": "https://dout.dev/assets/og/posts/2026-04-28-rebuilding-dout-dev-with-ai-copilot.png",
      "date_published": "2026-04-28T00:00:00.000Z",
      "tags": [
        "Making-of",
        "Ai-copilot",
        "Workflow"
      ]
    },
    {
      "id": "https://dout.dev/posts/2026-04-21-cms-and-markdown-processing.html",
      "url": "https://dout.dev/posts/2026-04-21-cms-and-markdown-processing.html",
      "title": "CMS and Markdown Processing",
      "summary": "The useful distinction",
      "content_html": "<h2 id=\"the-useful-distinction\" tabindex=\"0\" data-toc-anchor=\"true\">The useful distinction</h2>\n<p>A recent OpenReplay piece, <a href=\"https://blog.openreplay.com/markdown-cms-pros-cons/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">The Good And Bad Of Using Markdown As A CMS</a>, makes a distinction that matters more than it first sounds. \"Markdown as a CMS\" can mean at least three different systems:</p>\n<ul>\n<li>plain <code>.md</code> files in a repository;</li><li>MDX tied to a framework runtime and component model;</li><li>a Git-backed editorial tool that stores content as Markdown.</li></ul>\n<p><em>Those are related, but they are not the same thing.</em> The trade-offs move with each one.</p>\n<p>On dout.dev, the setup is plain Markdown plus front matter, a custom CMS pipeline, and an HTML-native template engine. No MDX. No admin UI. No database. That choice is excellent for some problems and the wrong answer for others.</p>\n<h2 id=\"where-markdown-is-still-the-right-answer\" tabindex=\"0\" data-toc-anchor=\"true\">Where Markdown is still the right answer</h2>\n<p>For developer-owned content, Markdown remains hard to beat.</p>\n<p>The obvious reason is version control. A post is a text file in Git. Diffs are readable. Rollback is trivial. Branches and pull requests already are the review workflow. If the site generator changes in five years, the content survives because the content format is not proprietary.</p>\n<p>The less obvious reason is maintenance. Raw HTML content ages badly. Inline styles accumulate. Broken nesting sneaks in. One-off classes appear for formatting hacks. Markdown constrains the authoring surface on purpose, and that constraint is usually a feature.</p>\n<p>For a blog, documentation site, changelog, or engineering handbook, this is a strong fit. The people writing are usually the same people maintaining the build, and the content itself is mostly narrative rather than highly relational data.</p>\n<h2 id=\"where-plain-markdown-stops-being-a-cms\" tabindex=\"0\" data-toc-anchor=\"true\">Where plain Markdown stops being a CMS</h2>\n<p>The OpenReplay article is right about the breaking points.</p>\n<p>Plain files do not give you content relationships for free. \"Posts by author\", \"localized variants\", \"schedule this for next Tuesday\", \"send it through approval\", \"let marketing edit it without Git\" - none of that is inherent in Markdown. At that point Markdown is a storage format, not the whole publishing system.</p>\n<p>This is also where a lot of Markdown debates get sloppy. People say \"Markdown cannot do X\" when what they really mean is \"a directory full of <code>.md</code> files without surrounding tooling cannot do X.\" That is true. It is also incomplete, because a project can add a lot of structure around Markdown before it reaches for a headless CMS.</p>\n<h2 id=\"what-dout-dev-adds-on-top-of-markdown\" tabindex=\"0\" data-toc-anchor=\"true\">What dout.dev adds on top of Markdown</h2>\n<p>dout.dev is not \"just Markdown files in a repo.\"</p>\n<p>The CMS layer reads front matter, normalizes content, derives slugs, builds tags, month archives, and series datasets, and generates pages from those derived records. The renderer also builds feeds, search data, and OG images. The image pipeline turns a normal Markdown image into a responsive <code>&lt;picture&gt;</code> with WebP sources, known dimensions, lazy loading, and a <code>&lt;noscript&gt;</code> fallback. Pull requests also get a built preview artifact, which is enough for review even though the site stays fully static.</p>\n<p>That changes the shape of the problem. For this project, Markdown is the authoring format but the CMS is the surrounding pipeline: content normalization, derived relationships, image processing, deterministic HTML output, validation and CI before publish. Structured taxonomies exist. Media handling exists. Preview exists. Parser behavior is consistent because there is one Markdown pipeline.</p>\n<h2 id=\"what-dout-dev-still-does-not-pretend-to-solve\" tabindex=\"0\" data-toc-anchor=\"true\">What dout.dev still does not pretend to solve</h2>\n<p>The system is better than plain files alone, but it is still intentionally narrow.</p>\n<p>It is not a good fit for non-technical editors. There is no admin UI. The workflow is still Git-first.</p>\n<p>It is not a good fit for approval-heavy editorial teams. There are drafts through <code>published: false</code>, local watch mode, and CI preview artifacts, but there is no role model, no approval chain, and no true scheduled publishing workflow today.</p>\n<p>It is not a good fit for localization-heavy content. The site is English-only, and that is a product decision as much as a technical one.</p>\n<p>It is not a good fit for dynamic or highly relational domains. Comments can be outsourced to Giscus, search can be client-side, and analytics can be added carefully. But if the core content is products, inventory, user-generated data, or frequently mutating records, a database-backed CMS or API is the correct tool.</p>\n<p>This is the part worth being honest about: dout.dev bypasses some Markdown problems by adding custom infrastructure, but it does not magically turn Markdown into a universal CMS.</p>\n<h2 id=\"when-to-use-which\" tabindex=\"0\" data-toc-anchor=\"true\">When to use which</h2>\n<p>Use Markdown when the content is text-first, developer-owned, and benefits from living next to the code. Use a headless CMS when the content is structured, multi-author, localized, workflow-heavy, or owned by people who should never have to think about Git.</p>\n<p>For dout.dev, Markdown is the right source format: single author, strong opinions about portability and static output, content that is mostly narrative. For a marketing team, product catalog, or newsroom, I would not stretch this model.</p>\n<p><strong>Markdown is a great source format.</strong> It is sometimes a CMS. It is never all of the CMS by itself.</p>\n<h2 id=\"references\" tabindex=\"0\" data-toc-anchor=\"true\">References</h2>\n<ul>\n<li><a href=\"https://blog.openreplay.com/markdown-cms-pros-cons/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">OpenReplay Team, \"The Good And Bad Of Using Markdown As A CMS\"</a></li><li><a href=\"https://tina.io/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Tina CMS</a></li><li><a href=\"https://decapcms.org/?from=dout.dev\" target=\"_blank\" referrerpolicy=\"strict-origin-when-cross-origin\" rel=\"noopener\">Decap CMS</a></li></ul>\n",
      "image": "https://dout.dev/assets/og/posts/2026-04-21-cms-and-markdown-processing.png",
      "date_published": "2026-04-21T00:00:00.000Z",
      "tags": [
        "Making-of",
        "Cms",
        "Markdown",
        "Static-site"
      ]
    }
  ]
}
