<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Fran Gonzalez</title><description>10⁻¹X Engineer stuff</description><link>https://frangonf.com</link><item><title>Replacing My Custom Pagefind Modal with astro-pagefind</title><link>https://frangonf.com/blog/replacing-custom-pagefind-modal-with-astro-pagefind</link><guid isPermaLink="true">https://frangonf.com/blog/replacing-custom-pagefind-modal-with-astro-pagefind</guid><description>How the astro-pagefind integration replaced three Vite workarounds with a single dependency.</description><pubDate>Fri, 05 Jun 2026 12:23:15 GMT</pubDate><content:encoded>&lt;p&gt;I recommended &lt;a href=&quot;https://github.com/shishkin/astro-pagefind&quot;&gt;astro-pagefind&lt;/a&gt; at the end of &lt;a href=&quot;/blog/pagefind-astro-integration-gotchas&quot;&gt;my gotchas post&lt;/a&gt;. Then I tried it.&lt;/p&gt;
&lt;h2&gt;The Problem&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;is:inline&lt;/code&gt; workarounds from the gotchas post were functional. But they carried costs I didn&apos;t fully appreciate at the time.&lt;/p&gt;
&lt;p&gt;35 lines of inline JavaScript sat in the layout — search loading, debounced input, result rendering, a manual trailing-slash fix. A custom &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; element handled the search modal. I built both from scratch.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;data-pagefind-body&lt;/code&gt; attribute was on the &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; element in the layout. This meant Pagefind indexed the entire site — header, footer, nav — alongside the actual page content. On a small site the noise was manageable. It was still wrong.&lt;/p&gt;
&lt;p&gt;The build script ran Pagefind as a post-build step: &lt;code&gt;&quot;astro build &amp;amp;&amp;amp; pagefind --site dist&quot;&lt;/code&gt;. Two commands where one would do.&lt;/p&gt;
&lt;p&gt;The gotchas post recommended trying &lt;a href=&quot;https://github.com/shishkin/astro-pagefind&quot;&gt;astro-pagefind&lt;/a&gt; to avoid all three Vite issues. I wrote that recommendation and moved on. I should have tried it immediately.&lt;/p&gt;
&lt;h2&gt;What Changed&lt;/h2&gt;
&lt;h3&gt;Integration setup&lt;/h3&gt;
&lt;p&gt;I added &lt;code&gt;astro-pagefind&lt;/code&gt; as a dependency and registered it in the Astro config:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// astro.config.ts
import pagefind from &quot;astro-pagefind&quot;;

export default defineConfig({
  integrations: [sitemap(), vue(), pagefind()],
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The integration handles Pagefind&apos;s build step internally. The build script went from &lt;code&gt;&quot;astro build &amp;amp;&amp;amp; pagefind --site dist&quot;&lt;/code&gt; to &lt;code&gt;&quot;astro build&quot;&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Search modal&lt;/h3&gt;
&lt;p&gt;The custom &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; with manual input handling was replaced by the &lt;code&gt;&amp;lt;pagefind-searchbox&amp;gt;&lt;/code&gt; component:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!-- src/layouts/BaseLayout.astro --&amp;gt;
&amp;lt;div id=&quot;search-overlay&quot; data-pf-theme=&quot;dark&quot; class=&quot;fixed inset-0 z-50 hidden bg-black/60 backdrop-blur-sm&quot;&amp;gt;
  &amp;lt;div class=&quot;mx-auto flex h-full w-full items-start justify-center overflow-y-auto p-4 pt-[10vh] sm:max-w-4xl&quot;&amp;gt;
    &amp;lt;PagefindConfig /&amp;gt;
    &amp;lt;pagefind-searchbox shortcut=&quot;none&quot; placeholder=&quot;Search...&quot;&amp;gt;&amp;lt;/pagefind-searchbox&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The overlay open/close logic — click trigger, &lt;code&gt;Cmd+K&lt;/code&gt;, &lt;code&gt;Escape&lt;/code&gt;, click-outside — stayed as inline JS. The search input, debouncing, result rendering, and &lt;code&gt;import(&quot;/pagefind/pagefind.js&quot;)&lt;/code&gt; loading were removed.&lt;/p&gt;
&lt;h3&gt;Indexing scope&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;data-pagefind-body&lt;/code&gt; moved from the &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; element in the layout to individual page wrappers. The &lt;code&gt;&amp;lt;article&amp;gt;&lt;/code&gt; in blog posts. The &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; wrappers on the about and now pages. Pagefind now indexes only the content that matters.&lt;/p&gt;
&lt;h3&gt;Trailing slashes&lt;/h3&gt;
&lt;p&gt;I changed &lt;code&gt;trailingSlash&lt;/code&gt; from &lt;code&gt;&quot;never&quot;&lt;/code&gt; to &lt;code&gt;&quot;ignore&quot;&lt;/code&gt; in the Astro config. The &lt;code&gt;.replace(/\/$/, &quot;&quot;)&lt;/code&gt; fix from the gotchas post was no longer needed — astro-pagefind handles URL resolution internally.&lt;/p&gt;
&lt;h3&gt;Dark theme&lt;/h3&gt;
&lt;p&gt;Pagefind&apos;s component UI ships with its own CSS variables. I mapped them to the existing Gruvbox tokens in &lt;code&gt;global.css&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/* src/assets/css/global.css */
:root {
  --pf-text: var(--ui-text);
  --pf-background: var(--ui-bg);
  --pf-border: var(--ui-border);
  --pf-mark: var(--color-yellow-500);
  /* ... */
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Tag pills&lt;/h3&gt;
&lt;p&gt;I added a &lt;code&gt;#&lt;/code&gt; prefix to tag pills across the blog index, blog post, and tag pages. A small visual change bundled into the same changeset.&lt;/p&gt;
&lt;h2&gt;Results&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Removed ~50 lines of inline JavaScript and the custom &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; markup from the layout&lt;/li&gt;
&lt;li&gt;The three gotchas from the previous post — Vite dynamic imports, ES module loading, trailing-slash URLs — are no longer problems I manage manually&lt;/li&gt;
&lt;li&gt;Search indexing is scoped to individual pages instead of the full site layout&lt;/li&gt;
&lt;li&gt;Build script is one command instead of two&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The trade-off: the search UI is now Pagefind&apos;s component-based design rather than the custom modal I built. The component handles input, results, keyboard navigation, and theming. I lost direct control over the result rendering — the component renders its own result list.&lt;/p&gt;
&lt;h2&gt;What I&apos;d Do Differently&lt;/h2&gt;
&lt;p&gt;I should have tried astro-pagefind before building the custom modal. The gotchas post documented real Vite issues, but the workarounds I built to solve them were unnecessary if I&apos;d started with the integration. The custom modal was the reason I rolled my own — but Pagefind 1.5.x ships its own component UI, which covers the same use case.&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;/blog/pagefind-astro-integration-gotchas&quot;&gt;Pagefind in Astro: The Gotchas I Hit&lt;/a&gt; — the previous post this replaces&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/shishkin/astro-pagefind&quot;&gt;astro-pagefind&lt;/a&gt; — the Astro integration&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pagefind.app/&quot;&gt;Pagefind&lt;/a&gt; — the search library&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pagefind.app/docs/ui/&quot;&gt;Pagefind Component UI&lt;/a&gt; — the built-in search component&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>astro</category><category>pagefind</category><category>search</category><category>vite</category></item><item><title>Pagefind in Astro: The Gotchas I Hit</title><link>https://frangonf.com/blog/pagefind-astro-integration-gotchas</link><guid isPermaLink="true">https://frangonf.com/blog/pagefind-astro-integration-gotchas</guid><description>Three issues I ran into integrating Pagefind search into an Astro site with Vite&apos;s bundler.</description><pubDate>Fri, 05 Jun 2026 09:24:14 GMT</pubDate><content:encoded>&lt;p&gt;I added &lt;a href=&quot;https://pagefind.app/&quot;&gt;Pagefind&lt;/a&gt; to this site for static search. The setup is straightforward — run &lt;code&gt;pagefind --site dist&lt;/code&gt; after the build, and it generates a search index alongside your HTML. The integration took longer than expected because of three Vite-specific gotchas that don&apos;t appear in the Pagefind docs.&lt;/p&gt;
&lt;h2&gt;The Problem&lt;/h2&gt;
&lt;p&gt;The site uses &lt;a href=&quot;https://astro.build/&quot;&gt;Astro&lt;/a&gt; with static output and &lt;a href=&quot;https://vite.dev/&quot;&gt;Vite&lt;/a&gt; as the bundler. I needed client-side search that works without a backend. Pagefit fits: it indexes the built HTML, generates a lightweight JS bundle, and runs search in the browser.&lt;/p&gt;
&lt;p&gt;The Pagefind docs show a simple import:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const pagefind = await import(&quot;/pagefind/pagefind.js&quot;);
pagefind.init();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This works in plain HTML. In Astro, it doesn&apos;t.&lt;/p&gt;
&lt;h2&gt;What Changed&lt;/h2&gt;
&lt;h3&gt;Gotcha 1: Vite breaks dynamic imports in inline scripts&lt;/h3&gt;
&lt;p&gt;Astro processes &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags in &lt;code&gt;.astro&lt;/code&gt; files through Vite. For inline scripts, Vite transforms dynamic imports into this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// What Vite generates:
e = await b(() =&amp;gt; import(`${s}pagefind.js`), __VITE_PRELOAD__);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;__VITE_PRELOAD__&lt;/code&gt; is a Vite internal that should be defined in the bundled output. For inline scripts, it isn&apos;t. The result is &lt;code&gt;ReferenceError: __VITE_PRELOAD__ is not defined&lt;/code&gt;, which gets silently caught by the &lt;code&gt;try/catch&lt;/code&gt; block.&lt;/p&gt;
&lt;p&gt;I tried &lt;code&gt;/* @vite-ignore */&lt;/code&gt; — it doesn&apos;t work in Astro inline scripts. I tried hiding the path in a variable — same result. The &lt;a href=&quot;https://pyk.sh/blog/2025-10-21-vite-dynamic-import-trick&quot;&gt;pyk.sh blog&lt;/a&gt; documents this same issue and solved it by moving the import to a separate &lt;code&gt;.ts&lt;/code&gt; file. I found a simpler path.&lt;/p&gt;
&lt;p&gt;The fix is &lt;code&gt;&amp;lt;script is:inline&amp;gt;&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!-- src/layouts/BaseLayout.astro --&amp;gt;
&amp;lt;script is:inline&amp;gt;
  let pagefind;

  async function loadPagefind() {
    if (pagefind) return pagefind;
    try {
      pagefind = await import(&quot;/pagefind/pagefind.js&quot;);
      await pagefind.init();
      return pagefind;
    } catch {
      console.log(&quot;Pagefind not available in dev mode&quot;);
      return null;
    }
  }

  // Lazy-load on search input focus
  document.getElementById(&quot;search-input&quot;)?.addEventListener(&quot;focus&quot;, () =&amp;gt; {
    loadPagefind();
  });
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;is:inline&lt;/code&gt; directive tells Astro to skip Vite processing entirely. The script is rendered verbatim into the HTML. The browser handles the &lt;code&gt;import()&lt;/code&gt; natively — and &lt;code&gt;import()&lt;/code&gt; works fine in classic scripts, even though &lt;code&gt;import&lt;/code&gt; declarations require module context.&lt;/p&gt;
&lt;p&gt;The trade-off: &lt;code&gt;is:inline&lt;/code&gt; scripts can&apos;t use TypeScript or local imports. The search code is simple enough that this doesn&apos;t matter.&lt;/p&gt;
&lt;h3&gt;The upstream Vite issue&lt;/h3&gt;
&lt;p&gt;This isn&apos;t an Astro bug. It&apos;s a Vite behavior documented in &lt;a href=&quot;https://github.com/vitejs/vite/issues/18551&quot;&gt;vitejs/vite#18551&lt;/a&gt;: Vite injects &lt;code&gt;__vitePreload&lt;/code&gt; on every dynamic import, even when &lt;code&gt;modulePreload&lt;/code&gt; is set to &lt;code&gt;false&lt;/code&gt;. For external script files, Vite includes the preload polyfill in the bundled output. For inline scripts, the polyfill isn&apos;t included, so the reference is undefined.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;/* @vite-ignore */&lt;/code&gt; comment is supposed to prevent Vite from processing a dynamic import. &lt;a href=&quot;https://github.com/vitejs/vite/issues/14850&quot;&gt;vitejs/vite#14850&lt;/a&gt; documents that it doesn&apos;t work for files in &lt;code&gt;public/&lt;/code&gt;. In our case, it didn&apos;t work for inline scripts either — Vite stripped the comment and bundled the import regardless.&lt;/p&gt;
&lt;h3&gt;Gotcha 2: Pagefind.js is an ES module&lt;/h3&gt;
&lt;p&gt;Pagefind 1.5.x generates &lt;code&gt;pagefind.js&lt;/code&gt; as an ES module. It uses &lt;code&gt;export{...}&lt;/code&gt; at the end and references &lt;code&gt;import.meta.url&lt;/code&gt; internally for path resolution. Loading it as a classic &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag produces:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Uncaught SyntaxError: import.meta may only appear in a module
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;import()&lt;/code&gt; approach from Gotcha 1 solves this too. Dynamic &lt;code&gt;import()&lt;/code&gt; loads the file as a module, so &lt;code&gt;import.meta.url&lt;/code&gt; works correctly.&lt;/p&gt;
&lt;h3&gt;Gotcha 3: Trailing slashes in search result URLs&lt;/h3&gt;
&lt;p&gt;Pagefind derives URLs from the file structure in &lt;code&gt;dist/&lt;/code&gt;. Astro with &lt;code&gt;trailingSlash: &quot;never&quot;&lt;/code&gt; generates files like &lt;code&gt;dist/blog/foo/index.html&lt;/code&gt;, which Pagefind reads as the URL &lt;code&gt;/blog/foo/&lt;/code&gt;. But the site serves &lt;code&gt;/blog/foo&lt;/code&gt; (no trailing slash). Clicking a search result produces a 404.&lt;/p&gt;
&lt;p&gt;The fix is a one-line change in the result rendering:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Strip trailing slash to match trailingSlash: &quot;never&quot; config
&amp;lt;a href=&quot;${data.url.replace(/\/$/, &quot;&quot;) || &quot;/&quot;}&quot; class=&quot;block&quot;&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Results&lt;/h2&gt;
&lt;p&gt;Search works in both &lt;code&gt;pnpm preview&lt;/code&gt; and production builds. The Pagefind bundle adds minimal weight — the index loads on demand when the user focuses the search input.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;try/catch&lt;/code&gt; gracefully handles dev mode, where the Pagefind index doesn&apos;t exist. Users see &quot;Pagefind not available in dev mode&quot; in the console, which is the expected behavior.&lt;/p&gt;
&lt;h2&gt;What I&apos;d Do Differently&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;is:inline&lt;/code&gt; approach works, but it means the search script is duplicated on every page that uses the layout. For this site that&apos;s acceptable — the script is small. On a larger site with many layout variants, I&apos;d move the search logic to a separate &lt;code&gt;.ts&lt;/code&gt; file in &lt;code&gt;src/scripts/&lt;/code&gt; and reference it with &lt;code&gt;&amp;lt;script src=&quot;../scripts/search.ts&quot;&amp;gt;&lt;/code&gt;. Vite properly bundles external script files with &lt;code&gt;__VITE_PRELOAD__&lt;/code&gt; defined.&lt;/p&gt;
&lt;p&gt;I&apos;d also try &lt;a href=&quot;https://github.com/shishkin/astro-pagefind&quot;&gt;astro-pagefind&lt;/a&gt; next time. It handles the Vite integration and provides pre-built components, which would have avoided all three gotchas. I rolled my own because I wanted a custom search modal, but the integration is worth revisiting — especially now that Pagefind 1.5.0 ships its own component-based UI.&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://pagefind.app/&quot;&gt;Pagefind — Official Docs&lt;/a&gt; — the search library&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pagefind.app/docs/api/&quot;&gt;Pagefind Search API&lt;/a&gt; — the &lt;code&gt;import(&quot;/pagefind/pagefind.js&quot;)&lt;/code&gt; pattern&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astro.build/en/guides/client-side-scripts/&quot;&gt;Astro Client-Side Scripts&lt;/a&gt; — &lt;code&gt;is:inline&lt;/code&gt; and script processing rules&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/vitejs/vite/issues/18551&quot;&gt;vitejs/vite#18551&lt;/a&gt; — upstream issue: Vite injects &lt;code&gt;__vitePreload&lt;/code&gt; even when modulePreload is disabled&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/vitejs/vite/issues/14850&quot;&gt;vitejs/vite#14850&lt;/a&gt; — upstream issue: &lt;code&gt;@vite-ignore&lt;/code&gt; doesn&apos;t prevent resolution of public-dir imports&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pyk.sh/blog/2025-10-21-vite-dynamic-import-trick&quot;&gt;Fixing the Unresolved Dynamic Import in Astro — pyk.sh&lt;/a&gt; — prior art on the same Vite issue&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://syntackle.com/blog/pagefind-search-in-astro-site/&quot;&gt;Pagefind Search in Astro — syntackle.com&lt;/a&gt; — integration guide using &lt;code&gt;is:inline&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/shishkin/astro-pagefind&quot;&gt;astro-pagefind&lt;/a&gt; — Astro integration that handles the Vite wiring&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>astro</category><category>pagefind</category><category>search</category><category>vite</category></item><item><title>Migrating from Nuxt Content to Astro Content</title><link>https://frangonf.com/blog/nuxt-content-to-astro-content-migration</link><guid isPermaLink="true">https://frangonf.com/blog/nuxt-content-to-astro-content-migration</guid><description>What changed when I replaced Nuxt Content&apos;s schema and collections with Astro&apos;s content collections.</description><pubDate>Thu, 04 Jun 2026 22:16:17 GMT</pubDate><content:encoded>&lt;p&gt;I migrated frangonf.com from &lt;a href=&quot;https://content.nuxt.com/&quot;&gt;Nuxt Content&lt;/a&gt; v3.13 to &lt;a href=&quot;https://docs.astro.build/en/guides/content-collections/&quot;&gt;Astro Content&lt;/a&gt; — the content schema, the collections, and the search integration.&lt;/p&gt;
&lt;h2&gt;The Problem&lt;/h2&gt;
&lt;p&gt;The Nuxt Content config (&lt;code&gt;content.config.ts&lt;/code&gt;, 146 lines) used &lt;code&gt;defineContentConfig&lt;/code&gt; from &lt;code&gt;@nuxt/content&lt;/code&gt; with five factory functions (&lt;code&gt;createBaseSchema&lt;/code&gt;, &lt;code&gt;createImageSchema&lt;/code&gt;, &lt;code&gt;createVideoSchema&lt;/code&gt;, &lt;code&gt;createGallerySchema&lt;/code&gt;, &lt;code&gt;createAuthorSchema&lt;/code&gt;). The schema had 22 fields, three date fields (&lt;code&gt;date&lt;/code&gt;, &lt;code&gt;createdAt&lt;/code&gt;, &lt;code&gt;updatedAt&lt;/code&gt;), and &lt;code&gt;now&lt;/code&gt;/&lt;code&gt;about&lt;/code&gt; pages as YAML files with a &lt;code&gt;content&lt;/code&gt; string field.&lt;/p&gt;
&lt;p&gt;The Astro content config (&lt;code&gt;src/content.config.ts&lt;/code&gt;, 99 lines) uses &lt;code&gt;defineCollection&lt;/code&gt; from &lt;code&gt;astro:content&lt;/code&gt; with &lt;code&gt;glob()&lt;/code&gt; loaders and 16 schema fields.&lt;/p&gt;
&lt;h2&gt;What Changed&lt;/h2&gt;
&lt;h3&gt;Content Config&lt;/h3&gt;
&lt;p&gt;The old Nuxt config defined collections with &lt;code&gt;type: &apos;page&apos;&lt;/code&gt; and &lt;code&gt;source&lt;/code&gt; strings:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// content.config.ts (deleted)
import { defineCollection, defineContentConfig, z } from &quot;@nuxt/content&quot;;

export default defineContentConfig({
  collections: {
    blog: defineCollection({
      type: &quot;page&quot;,
      source: &quot;blog/*.md&quot;,
      schema: z.object({
        title: z.string(),
        description: z.string(),
        minRead: z.number().optional(),
        date: z.date(),
        createdAt: z.date().optional(),
        updatedAt: z.date().optional(),
        draft: z.boolean().optional().default(false),
        featured: z.boolean().optional().default(false),
        author: z
          .union([z.string(), createAuthorSchema()])
          .optional()
          .default(&quot;Fran&quot;),
        tags: z.array(z.string()).optional().default([]),
        category: z.string().optional(),
        series: z.object({ name: z.string(), order: z.number() }).optional(),
        image: z.string().nonempty().editor({ input: &quot;media&quot; }).optional(),
        rawbody: z.string().optional(),
        seo: z
          .object({
            title: z.string().optional(),
            description: z.string().optional(),
            keywords: z.array(z.string()).optional(),
          })
          .optional(),
      }),
    }),
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The new Astro config defines collections with &lt;code&gt;glob()&lt;/code&gt; loaders:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// src/content.config.ts
import { defineCollection } from &quot;astro:content&quot;;
import { glob } from &quot;astro/loaders&quot;;
import { z } from &quot;astro/zod&quot;;

const blog = defineCollection({
  loader: glob({ pattern: &quot;**/*.md&quot;, base: &quot;./src/content/blog&quot; }),
  schema: ({ image }) =&amp;gt;
    z.object({
      title: z.string(),
      description: z.string(),
      createdAt: z.coerce.date(),
      draft: z.boolean().default(false),
      featured: z.boolean().default(false),
      author: z.string().default(&quot;Fran&quot;),
      tags: z.array(z.string()).default([]),
      category: z.string().optional(),
      series: z.object({ name: z.string(), order: z.number() }).optional(),
      image: image().optional(),
      seo: z
        .object({
          title: z.string().optional(),
          description: z.string().optional(),
        })
        .optional(),
    }),
});

const now = defineCollection({
  loader: glob({ pattern: &quot;now.md&quot;, base: &quot;./src/content&quot; }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    createdAt: z.coerce.date(),
    seo: z
      .object({
        title: z.string().optional(),
        description: z.string().optional(),
      })
      .optional(),
  }),
});

const about = defineCollection({
  loader: glob({ pattern: &quot;about.md&quot;, base: &quot;./src/content&quot; }),
  schema: z.object({
    title: z.string(),
    description: z.string(),
    seo: z
      .object({
        title: z.string().optional(),
        description: z.string().optional(),
      })
      .optional(),
  }),
});

export const collections = { blog, now, about };
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;now&lt;/code&gt; and &lt;code&gt;about&lt;/code&gt; collections changed from YAML files (&lt;code&gt;now.yml&lt;/code&gt;, &lt;code&gt;about.yml&lt;/code&gt;) to Markdown files (&lt;code&gt;now.md&lt;/code&gt;, &lt;code&gt;about.md&lt;/code&gt;). The &lt;code&gt;content&lt;/code&gt; string field was removed — the Markdown body is the content.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;index&lt;/code&gt; collection (sourced from &lt;code&gt;index.yml&lt;/code&gt;) was removed entirely.&lt;/p&gt;
&lt;h3&gt;Fields Removed&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;date&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;z.date()&lt;/code&gt; (required)&lt;/td&gt;
&lt;td&gt;Replaced by &lt;code&gt;createdAt&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;createdAt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;z.date().optional()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Replaced by required &lt;code&gt;createdAt&lt;/code&gt; with &lt;code&gt;z.coerce.date()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;updatedAt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;z.date().optional()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Derived from git via remark plugin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;minRead&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;z.number().optional()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Computed at build time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rawbody&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;z.string().optional()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Unused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;seo.keywords&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;z.array(z.string()).optional()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Unused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;author&lt;/code&gt; (object form)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;z.union([z.string(), createAuthorSchema()])&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Simplified to &lt;code&gt;z.string()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The five factory functions (&lt;code&gt;createBaseSchema&lt;/code&gt;, &lt;code&gt;createImageSchema&lt;/code&gt;, &lt;code&gt;createVideoSchema&lt;/code&gt;, &lt;code&gt;createGallerySchema&lt;/code&gt;, &lt;code&gt;createAuthorSchema&lt;/code&gt;) were removed. All schemas are inlined.&lt;/p&gt;
&lt;h3&gt;Image Handling&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;image&lt;/code&gt; field changed from &lt;code&gt;z.string().nonempty().editor({ input: &quot;media&quot; }).optional()&lt;/code&gt; to &lt;code&gt;image().optional()&lt;/code&gt; — Astro&apos;s &lt;a href=&quot;https://docs.astro.build/en/guides/images/#images-in-content-collections&quot;&gt;image helper&lt;/a&gt; for content collection schemas.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;image()&lt;/code&gt; helper returns an image metadata object, not a string. I updated every template using &lt;code&gt;&amp;lt;img src={post.data.image}&amp;gt;&lt;/code&gt; to &lt;code&gt;&amp;lt;Image src={post.data.image} /&amp;gt;&lt;/code&gt; from &lt;code&gt;astro:assets&lt;/code&gt;. The &lt;a href=&quot;https://docs.astro.build/en/guides/images/#generating-images-with-getimage&quot;&gt;&lt;code&gt;getImage()&lt;/code&gt; function&lt;/a&gt; processes images for OG meta tag URLs.&lt;/p&gt;
&lt;p&gt;Frontmatter uses relative paths:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;image: ./images/my-cover-photo.jpeg
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Date Schema&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;createdAt&lt;/code&gt; uses &lt;code&gt;z.coerce.date()&lt;/code&gt; instead of &lt;code&gt;z.date()&lt;/code&gt;. The coercion handles string dates in frontmatter without validation errors.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;lastModified&lt;/code&gt; is derived from &lt;code&gt;git log&lt;/code&gt; at build time via a &lt;a href=&quot;https://docs.astro.build/en/recipes/modified-time/&quot;&gt;custom remark plugin&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// remark-modified-time.ts
import { execSync } from &quot;node:child_process&quot;;

export function remarkModifiedTime() {
  return function (tree: any, file: any) {
    const filepath = file.history[0];
    const result = execSync(`git log -1 --pretty=%aI -- &quot;${filepath}&quot;`)
      .toString()
      .trim();
    file.data.astro.frontmatter.lastModified = result;
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The plugin writes to &lt;code&gt;file.data.astro.frontmatter.lastModified&lt;/code&gt;, which Astro exposes to templates as &lt;code&gt;remarkPluginFrontmatter.lastModified&lt;/code&gt;. It is not a frontmatter field — it is computed at build time. Files not yet committed are silently skipped.&lt;/p&gt;
&lt;h3&gt;Pagefind Search&lt;/h3&gt;
&lt;p&gt;The old site used &lt;a href=&quot;https://ui.nuxt.com/&quot;&gt;Nuxt UI&lt;/a&gt;&apos;s &lt;code&gt;&amp;lt;UContentSearch&amp;gt;&lt;/code&gt; component with SQLite WASM and Fuse.js — 1.88 MB of client-side JavaScript. &lt;a href=&quot;https://pagefind.app/&quot;&gt;Pagefind&lt;/a&gt; indexes at build time and outputs a static search bundle.&lt;/p&gt;
&lt;p&gt;The build command chains the two steps:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;build&quot;: &quot;astro build &amp;amp;&amp;amp; pagefind --site dist&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pagefind loads via dynamic &lt;code&gt;import()&lt;/code&gt; with &lt;code&gt;@vite-ignore&lt;/code&gt; to avoid Vite&apos;s import analysis. The &lt;code&gt;@vite-ignore&lt;/code&gt; comment is necessary because Pagefind JS does not exist during dev mode:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// src/layouts/BaseLayout.astro
const loadPagefind = async () =&amp;gt; {
  if (pagefind) return pagefind;
  try {
    pagefind = await import(/* @vite-ignore */ &quot;/pagefind/pagefind.js&quot;);
    await pagefind.init();
  } catch {
    console.log(&quot;Pagefind not available in dev mode&quot;);
  }
  return pagefind;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;a href=&quot;https://pagefind.app/docs/api/&quot;&gt;Pagefind JavaScript API&lt;/a&gt; provides &lt;code&gt;search()&lt;/code&gt;, &lt;code&gt;debouncedSearch()&lt;/code&gt;, and &lt;code&gt;preload()&lt;/code&gt; methods. Results load asynchronously via &lt;code&gt;result.data()&lt;/code&gt; to minimize bandwidth. The search UI is a native &lt;code&gt;&amp;lt;dialog&amp;gt;&lt;/code&gt; element with no additional library.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;pagefind.yml&lt;/code&gt; config sets &lt;code&gt;root_selector: &quot;body&quot;&lt;/code&gt;. The &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; element in &lt;code&gt;BaseLayout.astro&lt;/code&gt; has &lt;code&gt;data-pagefind-body&lt;/code&gt; to scope the indexable region.&lt;/p&gt;
&lt;h3&gt;Memento Mori as Vue Island&lt;/h3&gt;
&lt;p&gt;The &lt;a href=&quot;https://frangonf.com/mm&quot;&gt;Memento Mori&lt;/a&gt; page uses the &lt;a href=&quot;https://docs.astro.build/en/guides/integrations-guide/vue/&quot;&gt;&lt;code&gt;@astrojs/vue&lt;/code&gt;&lt;/a&gt; integration with &lt;code&gt;client:load&lt;/code&gt; for immediate hydration. It is the only page that ships JavaScript to the client.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// src/pages/mm.astro
---
import MementoMori from &quot;../components/MementoMori.vue&quot;;
---

&amp;lt;MementoMori client:load /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The component was ported from the Nuxt version. I replaced &lt;code&gt;useLocalStorage&lt;/code&gt; with a custom composable, replaced the Nuxt &lt;code&gt;Icon&lt;/code&gt; component with inline SVGs, and kept &lt;code&gt;temporal-polyfill&lt;/code&gt; for date arithmetic.&lt;/p&gt;
&lt;h2&gt;Results&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Nuxt Content&lt;/th&gt;
&lt;th&gt;Astro Content&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Content config&lt;/td&gt;
&lt;td&gt;146 lines&lt;/td&gt;
&lt;td&gt;99 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema fields&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collections&lt;/td&gt;
&lt;td&gt;4 (index, blog, now, about)&lt;/td&gt;
&lt;td&gt;3 (blog, now, about)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Date fields&lt;/td&gt;
&lt;td&gt;3 (date, createdAt, updatedAt)&lt;/td&gt;
&lt;td&gt;1 (createdAt) + git-derived&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client bundle&lt;/td&gt;
&lt;td&gt;7.2 MB&lt;/td&gt;
&lt;td&gt;149 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search bundle&lt;/td&gt;
&lt;td&gt;1.88 MB&lt;/td&gt;
&lt;td&gt;~20 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JS on blog pages&lt;/td&gt;
&lt;td&gt;~500 KB&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;What I&apos;d Do Differently&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;image()&lt;/code&gt; helper returns an object, not a string. Every template that touched &lt;code&gt;image&lt;/code&gt; needed updating. This is documented in the &lt;a href=&quot;https://docs.astro.build/en/guides/images/&quot;&gt;Astro Images guide&lt;/a&gt; but easy to miss when migrating from Nuxt Content where &lt;code&gt;image&lt;/code&gt; was &lt;code&gt;z.string()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Four posts have orphaned &lt;code&gt;minRead&lt;/code&gt; fields in frontmatter — Astro silently ignores them since the field is not in the schema.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;computeReadingTime&lt;/code&gt; is duplicated across five files (blog index, slug, tag, category, author pages). It could be extracted to &lt;code&gt;src/utils/dates.ts&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Vitest and Playwright tests pass locally (48 and 46 respectively) but neither &lt;code&gt;pnpm test&lt;/code&gt; nor &lt;code&gt;pnpm test:e2e&lt;/code&gt; appears in the GitHub Actions CI workflow. The CI validates linting, formatting, type checking, and build — not the test suites themselves.&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astro.build/en/guides/content-collections/&quot;&gt;Astro Content Collections&lt;/a&gt; — collection definition with &lt;code&gt;defineCollection&lt;/code&gt; and &lt;code&gt;glob()&lt;/code&gt; loaders&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://content.nuxt.com/docs/collections/define&quot;&gt;Nuxt Content: Define Collections&lt;/a&gt; — &lt;code&gt;defineContentConfig&lt;/code&gt; and &lt;code&gt;defineCollection&lt;/code&gt; with &lt;code&gt;type&lt;/code&gt; and &lt;code&gt;source&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astro.build/en/guides/images/&quot;&gt;Astro Images&lt;/a&gt; — &lt;code&gt;image()&lt;/code&gt; helper, &lt;code&gt;&amp;lt;Image /&amp;gt;&lt;/code&gt; component, and &lt;code&gt;getImage()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astro.build/en/recipes/modified-time/&quot;&gt;Astro: Add Last Modified Time&lt;/a&gt; — remark plugin recipe for git-derived timestamps&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astro.build/en/guides/integrations-guide/vue/&quot;&gt;Astro Vue Integration&lt;/a&gt; — &lt;code&gt;client:load&lt;/code&gt; directive and island architecture&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astro.build/en/guides/migrate-to-astro/from-nuxtjs/&quot;&gt;Astro: Migrate from NuxtJS&lt;/a&gt; — official migration guide&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pagefind.app/&quot;&gt;Pagefind&lt;/a&gt; — static search indexing&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pagefind.app/docs/api/&quot;&gt;Pagefind JavaScript API&lt;/a&gt; — &lt;code&gt;init()&lt;/code&gt;, &lt;code&gt;search()&lt;/code&gt;, &lt;code&gt;debouncedSearch()&lt;/code&gt;, &lt;code&gt;preload()&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ui.nuxt.com/&quot;&gt;Nuxt UI&lt;/a&gt; — &lt;code&gt;&amp;lt;UContentSearch&amp;gt;&lt;/code&gt; component used in the previous setup&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>nuxt</category><category>astro</category><category>content-collections</category><category>migration</category></item><item><title>Automated Dependency Updates with Supply Chain Security</title><link>https://frangonf.com/blog/automated-dependency-updates-supply-chain</link><guid isPermaLink="true">https://frangonf.com/blog/automated-dependency-updates-supply-chain</guid><description>How to set up Renovate or Dependabot with cooldown policies that complement pnpm&apos;s minimumReleaseAge — automated patches without opening the door to compromised packages.</description><pubDate>Thu, 04 Jun 2026 16:47:37 GMT</pubDate><content:encoded>&lt;p&gt;The previous post covered &lt;a href=&quot;/blog/supply-chain-security-mise-pnpm&quot;&gt;static supply chain defenses&lt;/a&gt; — tool pinning, pnpm workspace settings, CI hardening. But one gap remained: dependency updates. I was updating packages manually, which meant either falling behind on security patches or adopting new releases the moment they shipped. Neither worked.&lt;/p&gt;
&lt;p&gt;Automated dependency bots solve the velocity problem. Without cooldown policies, they can become the delivery mechanism for the next supply chain attack.&lt;/p&gt;
&lt;h2&gt;The Problem&lt;/h2&gt;
&lt;p&gt;In September 2025, the &lt;a href=&quot;https://socket.dev/blog/mini-shai-hulud-campaign-hits-red-hat-cloud-services-npm-packages&quot;&gt;Shai-Hulud attack&lt;/a&gt; compromised over 500 npm packages in a coordinated campaign. Malicious versions were published, and projects with automated updates that had no cooldown window adopted them within minutes. The attack was discovered and the malicious versions removed within hours — but for projects using auto-merge, the damage was already done.&lt;/p&gt;
&lt;p&gt;Update automation needs a cooling-off period. Most compromised packages are discovered and removed from the registry within 24 hours. A short delay between publication and adoption catches the majority of attacks without meaningfully slowing down legitimate updates.&lt;/p&gt;
&lt;h2&gt;How Cooldowns Work&lt;/h2&gt;
&lt;p&gt;There are three layers of cooldown in a typical JavaScript project:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Coverage&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Package manager&lt;/td&gt;
&lt;td&gt;pnpm &lt;code&gt;minimumReleaseAge&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full tree (transitive too)&lt;/td&gt;
&lt;td&gt;Blocks install of packages published less than N minutes ago&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update bot&lt;/td&gt;
&lt;td&gt;Renovate &lt;code&gt;minimumReleaseAge&lt;/code&gt; or Dependabot &lt;code&gt;cooldown&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Direct dependencies only&lt;/td&gt;
&lt;td&gt;Delays PR creation until a package version is N days old&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI gate&lt;/td&gt;
&lt;td&gt;StepSecurity npm Package Cooldown&lt;/td&gt;
&lt;td&gt;PR-level&lt;/td&gt;
&lt;td&gt;Blocks merge if a PR introduces a version published within N days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The package manager setting catches transitive dependencies that the update bot doesn&apos;t manage. The CI gate catches manual &lt;code&gt;pnpm add&lt;/code&gt; commands that bypass the bot entirely.&lt;/p&gt;
&lt;h2&gt;Renovate Configuration&lt;/h2&gt;
&lt;p&gt;Renovate&apos;s cooldown is configured with &lt;code&gt;minimumReleaseAge&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  $schema: &quot;https://docs.renovatebot.com/renovate-schema.json&quot;,
  extends: [&quot;config:recommended&quot;],
  minimumReleaseAge: &quot;3 days&quot;,
  minimumReleaseAgeBehaviour: &quot;timestamp-required&quot;,
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Renovate will not create a PR for any package version published less than 3 days ago. If the latest release is too fresh, it picks the most recent version that satisfies the age requirement.&lt;/p&gt;
&lt;p&gt;For security-specific updates, I can create a package rule that bypasses the cooldown:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  packageRules: [
    {
      matchUpdateTypes: [&quot;pin&quot;, &quot;digest&quot;],
      minimumReleaseAge: &quot;0 days&quot;,
      description: &quot;Allow immediate pin and digest updates&quot;,
    },
  ],
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Renovate&apos;s &lt;code&gt;minimumReleaseAge&lt;/code&gt; only applies to direct dependencies. Transitive dependencies — the ones pulled in by your dependencies — are not covered. That&apos;s where pnpm&apos;s &lt;code&gt;minimumReleaseAge&lt;/code&gt; fills the gap, since it applies to the full dependency tree during resolution.&lt;/p&gt;
&lt;h2&gt;Dependabot Configuration&lt;/h2&gt;
&lt;p&gt;Dependabot&apos;s equivalent is the &lt;code&gt;cooldown&lt;/code&gt; option:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: &quot;npm&quot;
    directory: &quot;/&quot;
    schedule:
      interval: &quot;daily&quot;
    cooldown:
      default-days: 3
      semver-major-days: 30
      semver-minor-days: 7
      semver-patch-days: 3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The semver-specific overrides are worth setting — major version bumps warrant a longer cooling-off period since they&apos;re more likely to introduce breaking changes or unexpected behavior.&lt;/p&gt;
&lt;p&gt;Dependabot&apos;s cooldown also only applies to direct dependencies. Like Renovate, it complements pnpm&apos;s &lt;code&gt;minimumReleaseAge&lt;/code&gt; rather than replacing it.&lt;/p&gt;
&lt;h2&gt;The Gap: Transitive Dependencies&lt;/h2&gt;
&lt;p&gt;Neither Renovate nor Dependabot manages transitive dependencies. If a package you depend on adds a new transitive dependency that was published minutes ago, neither bot will catch it. pnpm&apos;s &lt;code&gt;minimumReleaseAge&lt;/code&gt; does — it applies during resolution, regardless of whether the dependency is direct or transitive.&lt;/p&gt;
&lt;p&gt;The two layers need each other:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Renovate/Dependabot&lt;/strong&gt; → creates PRs, manages direct dependency updates, respects its own cooldown&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pnpm &lt;code&gt;minimumReleaseAge&lt;/code&gt;&lt;/strong&gt; → catches transitive dependencies, applies at install time, covers the full tree&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Putting It Together&lt;/h2&gt;
&lt;p&gt;A minimal setup that covers all three layers:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;pnpm-workspace.yaml&lt;/strong&gt; — &lt;code&gt;minimumReleaseAge: 1440&lt;/code&gt; (covers full tree)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Renovate or Dependabot&lt;/strong&gt; — cooldown of 3+ days (manages direct deps)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CI step&lt;/strong&gt; — &lt;code&gt;pnpm audit --prod --audit-level high&lt;/code&gt; (catches known vulnerabilities)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The cooldown values should be long enough for the community to discover attacks (24+ hours) but short enough that security patches don&apos;t sit in limbo for weeks. I started with 3 days and adjusted from there.&lt;/p&gt;
&lt;h2&gt;What I&apos;d Do Differently&lt;/h2&gt;
&lt;p&gt;I&apos;d set up Renovate earlier. The manual update workflow meant I was either behind on patches or anxiously watching for reports of compromised packages after every &lt;code&gt;pnpm update&lt;/code&gt;. The cooldown setting removes that tension — updates happen at a pace where the community has time to catch problems.&lt;/p&gt;
&lt;p&gt;The one tradeoff: security patches for transitive dependencies still depend on pnpm&apos;s &lt;code&gt;minimumReleaseAge&lt;/code&gt; alone. There&apos;s no bot creating PRs for those. A &lt;code&gt;pnpm audit&lt;/code&gt; step in CI is the safety net.&lt;/p&gt;
&lt;h2&gt;What&apos;s Next&lt;/h2&gt;
&lt;p&gt;Automated updates handle the &quot;get patches faster&quot; problem. But the config that supports them — exclusion lists, allowlists, trust policies — can become stale as the dependency tree evolves. The next post covers &lt;a href=&quot;/blog/keeping-supply-chain-exclusions-honest&quot;&gt;keeping supply chain exclusions honest&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.renovatebot.com/configuration-options/#minimumreleaseage&quot;&gt;Renovate &lt;code&gt;minimumReleaseAge&lt;/code&gt;&lt;/a&gt; — cooldown for Renovate-managed updates&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#cooldown&quot;&gt;Dependabot &lt;code&gt;cooldown&lt;/code&gt;&lt;/a&gt; — cooldown for Dependabot-managed updates&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings#minimumreleaseage&quot;&gt;pnpm &lt;code&gt;minimumReleaseAge&lt;/code&gt;&lt;/a&gt; — full-tree cooldown at install time&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings#minimumreleaseageexclude&quot;&gt;pnpm &lt;code&gt;minimumReleaseAgeExclude&lt;/code&gt;&lt;/a&gt; — exceptions for packages that need immediate updates&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.stepsecurity.io/blog/announcing-dependabot-configuration-enhancements-cooldown-and-group-support&quot;&gt;StepSecurity npm Package Cooldown&lt;/a&gt; — CI-level merge gate&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://blog.gitguardian.com/renovate-dependabot-the-new-malware-delivery-system&quot;&gt;GitGuardian: Renovate &amp;amp; Dependabot supply chain analysis&lt;/a&gt; — attack case studies&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://socket.dev/blog/mini-shai-hulud-campaign-hits-red-hat-cloud-services-npm-packages&quot;&gt;Shai-Hulud attack overview&lt;/a&gt; — coordinated npm compromise campaign&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>security</category><category>supply-chain</category><category>renovate</category><category>dependabot</category><category>pnpm</category></item><item><title>Beyond Version Checks: Provenance and Behavioral Security</title><link>https://frangonf.com/blog/beyond-version-checks-provenance-behavioral-security</link><guid isPermaLink="true">https://frangonf.com/blog/beyond-version-checks-provenance-behavioral-security</guid><description>Lockfiles, cooldowns, and exclusion lists are necessary but not sufficient. Provenance verification and behavioral analysis catch the attacks they miss.</description><pubDate>Thu, 04 Jun 2026 16:47:37 GMT</pubDate><content:encoded>&lt;p&gt;The &lt;a href=&quot;/blog/supply-chain-security-mise-pnpm&quot;&gt;first three posts&lt;/a&gt; in this series covered version-level controls — pinning tools, cooling off new releases, managing exclusions. These catch the majority of supply chain attacks. But a growing class of attacks slips past them.&lt;/p&gt;
&lt;p&gt;In April 2026, the &lt;a href=&quot;https://unit42.paloaltonetworks.com/monitoring-npm-supply-chain-attacks&quot;&gt;TanStack/router&lt;/a&gt; repository was compromised through a CI workflow manipulation. The attacker published malicious packages with valid SLSA provenance — because the packages were genuinely built by the repository&apos;s own pipeline. The pipeline&apos;s internal state was compromised, but the build environment was legitimate. Provenance verified correctly. Version checks passed. The attack relied on behavioral patterns that no version-level control can catch.&lt;/p&gt;
&lt;h2&gt;Provenance Verification&lt;/h2&gt;
&lt;h3&gt;What provenance tells you&lt;/h3&gt;
&lt;p&gt;When a package is published with &lt;code&gt;--provenance&lt;/code&gt; on &lt;a href=&quot;https://docs.npmjs.com/generating-provenance-statements&quot;&gt;npm&lt;/a&gt;, the registry stores a &lt;a href=&quot;https://www.sigstore.dev/&quot;&gt;Sigstore&lt;/a&gt;-signed attestation that links the package to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The source repository and commit&lt;/li&gt;
&lt;li&gt;The CI workflow that built it&lt;/li&gt;
&lt;li&gt;The build environment (GitHub Actions OIDC token)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is roughly &lt;a href=&quot;https://slsa.dev/&quot;&gt;SLSA&lt;/a&gt; Build Level 2 — the package was built on a hosted platform, and the provenance is cryptographically signed so it can&apos;t be forged.&lt;/p&gt;
&lt;h3&gt;pnpm audit signatures&lt;/h3&gt;
&lt;p&gt;pnpm 11.1 added &lt;a href=&quot;https://pnpm.io/cli/audit&quot;&gt;&lt;code&gt;pnpm audit signatures&lt;/code&gt;&lt;/a&gt;, which verifies ECDSA signatures against npm&apos;s published keys. I added it as a CI step:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- name: Verify package signatures
  run: pnpm audit signatures
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This catches cases where a package was published outside its trusted CI pipeline — for example, a maintainer&apos;s account is compromised and they publish manually without provenance.&lt;/p&gt;
&lt;h3&gt;What provenance doesn&apos;t tell you&lt;/h3&gt;
&lt;p&gt;Provenance confirms &lt;em&gt;which pipeline&lt;/em&gt; built a package, not &lt;em&gt;whether that pipeline was clean&lt;/em&gt;. The TanStack/router incident demonstrated this — the attacker compromised the workflow itself, so the provenance was valid. The package was built by the right pipeline, but the pipeline was doing the wrong thing.&lt;/p&gt;
&lt;p&gt;Provenance catches impersonation and token theft. It doesn&apos;t catch pipeline compromise.&lt;/p&gt;
&lt;h2&gt;Behavioral Analysis&lt;/h2&gt;
&lt;h3&gt;The install-time execution problem&lt;/h3&gt;
&lt;p&gt;The most dangerous moment in the npm lifecycle is &lt;code&gt;pnpm install&lt;/code&gt;. A compromised package with a postinstall script gets code execution on your machine with full access to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;~/.npmrc&lt;/code&gt; (registry tokens)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;~/.ssh/&lt;/code&gt; (SSH keys)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GITHUB_TOKEN&lt;/code&gt; (in CI)&lt;/li&gt;
&lt;li&gt;Environment variables (secrets, API keys)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;allowBuilds&lt;/code&gt; in pnpm v11 blocks scripts from unreviewed packages. But for packages I&apos;ve approved, the script runs with full privileges. If an approved package is later compromised (through a maintainer account takeover, for example), the next &lt;code&gt;pnpm install&lt;/code&gt; executes the malicious script.&lt;/p&gt;
&lt;h3&gt;Socket.dev&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://socket.dev&quot;&gt;Socket&lt;/a&gt; monitors packages for behavioral red flags:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Install-time network connections&lt;/strong&gt; — a package that opens sockets during postinstall is suspicious&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Filesystem access&lt;/strong&gt; — reading files outside the package directory (especially &lt;code&gt;~/.ssh&lt;/code&gt;, &lt;code&gt;~/.npmrc&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shell command execution&lt;/strong&gt; — spawning child processes that aren&apos;t part of the build&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Obfuscated code&lt;/strong&gt; — dynamically decoded payloads, eval chains&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;New binary introductions&lt;/strong&gt; — a package that didn&apos;t previously ship binaries suddenly adds one&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Socket integrates with GitHub as a PR check. When a dependency update introduces a package with suspicious behavior, the PR gets flagged.&lt;/p&gt;
&lt;h3&gt;The TanStack lesson&lt;/h3&gt;
&lt;p&gt;The TanStack/router attacker had valid provenance. Version checks passed. The only signal was behavioral — the compromised pipeline was doing something it hadn&apos;t done before. Behavioral analysis catches this class of attack because it asks &quot;what is this package doing?&quot; rather than &quot;is this package the right version?&quot;&lt;/p&gt;
&lt;h2&gt;Private Registry Proxy&lt;/h2&gt;
&lt;p&gt;For projects with internal packages, a private registry proxy (&lt;a href=&quot;https://verdaccio.org/&quot;&gt;Verdaccio&lt;/a&gt;, &lt;a href=&quot;https://jfrog.com/artifactory/&quot;&gt;Artifactory&lt;/a&gt;, Cloudflare Workers) adds another gate:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Route all npm traffic through the proxy&lt;/li&gt;
&lt;li&gt;Block known malicious versions at the proxy level&lt;/li&gt;
&lt;li&gt;Cache and audit every download&lt;/li&gt;
&lt;li&gt;Enforce organizational policies (no install scripts from untrusted sources)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This prevents dependency confusion attacks and gives a central point to enforce policies that package managers alone can&apos;t.&lt;/p&gt;
&lt;h2&gt;SBOM Generation&lt;/h2&gt;
&lt;p&gt;A Software Bill of Materials (SBOM) is a machine-readable inventory of every dependency in your project. When a new vulnerability is disclosed, I can instantly check whether I&apos;m affected.&lt;/p&gt;
&lt;p&gt;Generate an SBOM with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm sbom --format spdx-json &amp;gt; sbom.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For static sites this is low-risk. For anything that handles user data, an SBOM is the difference between &quot;we&apos;ll check&quot; and &quot;we know.&quot;&lt;/p&gt;
&lt;h2&gt;The Full Stack&lt;/h2&gt;
&lt;p&gt;The layers from this series, in order of what to implement first:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;th&gt;Effort&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lockfile + &lt;code&gt;--frozen-lockfile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Unexpected version changes&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;minimumReleaseAge&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Freshly published compromised packages&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;allowBuilds&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Unreviewed install scripts&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SHA-pinned CI actions&lt;/td&gt;
&lt;td&gt;Compromised mutable tags&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pnpm audit&lt;/code&gt; in CI&lt;/td&gt;
&lt;td&gt;Known vulnerabilities&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Renovate/Dependabot with cooldown&lt;/td&gt;
&lt;td&gt;Stale dependencies, update velocity&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pnpm audit signatures&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Packages published outside trusted pipelines&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;trustPolicy: no-downgrade&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Provenance regression&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Socket.dev&lt;/td&gt;
&lt;td&gt;Behavioral anomalies in dependencies&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Private registry proxy&lt;/td&gt;
&lt;td&gt;Dependency confusion, central policy enforcement&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SBOM generation&lt;/td&gt;
&lt;td&gt;Impact analysis for new CVEs&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;No single layer is complete. Each one catches attacks that the others miss. The goal isn&apos;t to make supply chain attacks impossible — it&apos;s to make them expensive enough that attackers move on.&lt;/p&gt;
&lt;h2&gt;What I&apos;d Do Differently&lt;/h2&gt;
&lt;p&gt;I&apos;d add &lt;code&gt;pnpm audit signatures&lt;/code&gt; to CI earlier. It&apos;s a one-liner that catches a class of attacks I wasn&apos;t monitoring at all. I&apos;d also look at Socket.dev for any project that handles user data — the behavioral analysis fills a gap that version-level controls can&apos;t touch.&lt;/p&gt;
&lt;p&gt;The private registry proxy is the one I&apos;d skip for a static blog. It&apos;s worth the setup for anything with user-facing APIs or sensitive data, but the overhead isn&apos;t justified for a site that only serves HTML.&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://slsa.dev/&quot;&gt;SLSA Framework&lt;/a&gt; — supply chain integrity levels&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.sigstore.dev/&quot;&gt;Sigstore&lt;/a&gt; — keyless signing and verification for npm packages&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/cli/audit&quot;&gt;pnpm &lt;code&gt;audit signatures&lt;/code&gt;&lt;/a&gt; — ECDSA signature verification&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.npmjs.com/generating-provenance-statements&quot;&gt;npm provenance&lt;/a&gt; — SLSA provenance for npm packages&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://socket.dev&quot;&gt;Socket.dev&lt;/a&gt; — behavioral analysis for npm packages&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://unit42.paloaltonetworks.com/monitoring-npm-supply-chain-attacks&quot;&gt;TanStack/router incident analysis&lt;/a&gt; — case study of provenance-valid attack&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.npmjs.com/cli/v11/commands/npm-sbom&quot;&gt;SBOM generation&lt;/a&gt; — machine-readable dependency inventory&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.stepsecurity.io/&quot;&gt;StepSecurity&lt;/a&gt; — CI-level cooldown and compromised package detection&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>security</category><category>supply-chain</category><category>provenance</category><category>sigstore</category><category>slsa</category></item><item><title>Keeping Supply Chain Exclusions Honest</title><link>https://frangonf.com/blog/keeping-supply-chain-exclusions-honest</link><guid isPermaLink="true">https://frangonf.com/blog/keeping-supply-chain-exclusions-honest</guid><description>Your pnpm security config decays over time. minimumReleaseAgeExclude entries pile up, trustPolicyExclude entries become stale, and allowBuilds lists drift from reality. Here&apos;s how to catch it.</description><pubDate>Thu, 04 Jun 2026 16:47:37 GMT</pubDate><content:encoded>&lt;p&gt;The &lt;a href=&quot;/blog/supply-chain-security-mise-pnpm&quot;&gt;first post in this series&lt;/a&gt; covered setting up pnpm&apos;s supply chain controls. The &lt;a href=&quot;/blog/automated-dependency-updates-supply-chain&quot;&gt;second&lt;/a&gt; covered automated updates with cooldowns. This post covers the part I kept postponing: keeping the config from rotting.&lt;/p&gt;
&lt;h2&gt;The Problem&lt;/h2&gt;
&lt;p&gt;When I first set up &lt;code&gt;minimumReleaseAgeExclude&lt;/code&gt;, &lt;code&gt;trustPolicyExclude&lt;/code&gt;, and &lt;code&gt;allowBuilds&lt;/code&gt;, each entry had a clear reason. But dependency trees change. Packages get upgraded, new ones get added, old ones get removed. The exclusion lists don&apos;t clean themselves up — they only grow.&lt;/p&gt;
&lt;p&gt;I found this while applying the same security setup to a second project. The first project had 31 overrides that weren&apos;t doing anything. The second had &lt;code&gt;minimumReleaseAgeExclude&lt;/code&gt; entries for packages published months ago, well past the age window. The configs looked hardened but were full of dead weight.&lt;/p&gt;
&lt;h2&gt;The Three Lists That Decay&lt;/h2&gt;
&lt;h3&gt;minimumReleaseAgeExclude&lt;/h3&gt;
&lt;p&gt;This list exempts packages from the &lt;code&gt;minimumReleaseAge&lt;/code&gt; check. It grows in two ways:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Manual additions&lt;/strong&gt; — I need a fresh release and add an entry to unblock it&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automatic additions&lt;/strong&gt; — &lt;code&gt;pnpm audit --fix=update&lt;/code&gt; adds entries when patching vulnerabilities, so the patched version can install without waiting for the age window&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The problem: entries are never removed. There&apos;s no built-in cleanup mechanism (&lt;a href=&quot;https://github.com/pnpm/pnpm/issues/11668&quot;&gt;pnpm#11668&lt;/a&gt;). A &lt;code&gt;minimumReleaseAgeExclude&lt;/code&gt; entry for a package published months ago is doing nothing — the package is already past the age window, so the exclusion is pure noise.&lt;/p&gt;
&lt;p&gt;There&apos;s also a bug where &lt;code&gt;pnpm audit --fix update&lt;/code&gt; adds entries even for packages that wouldn&apos;t be blocked (&lt;a href=&quot;https://github.com/pnpm/pnpm/issues/11563&quot;&gt;pnpm#11563&lt;/a&gt;). The list grows faster than it should.&lt;/p&gt;
&lt;h3&gt;trustPolicyExclude&lt;/h3&gt;
&lt;p&gt;This list exempts packages from the &lt;code&gt;trustPolicy: no-downgrade&lt;/code&gt; check. It&apos;s needed when a package doesn&apos;t ship with provenance attestations but is known-safe.&lt;/p&gt;
&lt;p&gt;The entries age out as upstream packages adopt provenance. A &lt;code&gt;trustPolicyExclude&lt;/code&gt; for &lt;code&gt;chokidar@4.0.3&lt;/code&gt; was justified six months ago — the pinned version lacked provenance. But the latest release now ships with provenance, so the exclusion is holding back a security control for no reason.&lt;/p&gt;
&lt;h3&gt;allowBuilds&lt;/h3&gt;
&lt;p&gt;This list whitelists packages that may run install scripts. It should match the packages in your dependency tree that actually have postinstall scripts. But dependency trees change — packages get removed, new ones get added, and the allowlist drifts.&lt;/p&gt;
&lt;p&gt;An &lt;code&gt;allowBuilds&lt;/code&gt; entry for a package that&apos;s no longer installed is harmless but confusing. An entry that&apos;s missing for a newly added package causes &lt;code&gt;pnpm install&lt;/code&gt; to fail, which is the correct behavior with &lt;code&gt;strictDepBuilds: true&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;The Audit Workflow&lt;/h2&gt;
&lt;p&gt;There&apos;s no automated tool that cleans up these lists. The practical approach is a manual review, which takes minutes when done regularly.&lt;/p&gt;
&lt;h3&gt;Step 1: Check what&apos;s installed&lt;/h3&gt;
&lt;p&gt;For &lt;code&gt;allowBuilds&lt;/code&gt;, I verify each entry against the actual dependency tree:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm ls @parcel/watcher
pnpm ls core-js
pnpm ls esbuild
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If &lt;code&gt;pnpm ls&lt;/code&gt; returns nothing, the package isn&apos;t installed and doesn&apos;t need an &lt;code&gt;allowBuilds&lt;/code&gt; entry.&lt;/p&gt;
&lt;h3&gt;Step 2: Check publish dates&lt;/h3&gt;
&lt;p&gt;For &lt;code&gt;minimumReleaseAgeExclude&lt;/code&gt;, I check whether entries are still needed:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm info astro@6.3.8 --json | grep -A2 &apos;&quot;time&quot;&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the package was published well past the &lt;code&gt;minimumReleaseAge&lt;/code&gt; window (months ago), the exclude is doing nothing. Remove it.&lt;/p&gt;
&lt;h3&gt;Step 3: Check provenance&lt;/h3&gt;
&lt;p&gt;For &lt;code&gt;trustPolicyExclude&lt;/code&gt;, I check whether the excluded package now ships with provenance:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm info chokidar@4.0.3 --json | grep -A5 &apos;&quot;attestations&quot;&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If &lt;code&gt;provenance&lt;/code&gt; has a URL, the package now has attestations and the exclude can be removed (after upgrading to the version that has provenance).&lt;/p&gt;
&lt;h3&gt;Step 4: Run pnpm audit&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;pnpm audit --prod
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This catches known vulnerabilities regardless of exclusion lists. If &lt;code&gt;audit&lt;/code&gt; finds issues, the exclusions aren&apos;t protecting from everything.&lt;/p&gt;
&lt;h2&gt;When to Schedule Reviews&lt;/h2&gt;
&lt;p&gt;The review doesn&apos;t need to be frequent. Good triggers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;When upgrading packages&lt;/strong&gt; — exclusions for old versions may no longer apply&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;When &lt;code&gt;pnpm audit&lt;/code&gt; finds new advisories&lt;/strong&gt; — check whether exclusions are blocking fixes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;When adding new dependencies&lt;/strong&gt; — verify &lt;code&gt;allowBuilds&lt;/code&gt; covers any new install scripts&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Quarterly&lt;/strong&gt; — a standing calendar reminder to check the lists even if nothing else changed&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Partial Automation&lt;/h2&gt;
&lt;p&gt;Renovate&apos;s &lt;code&gt;customManagers&lt;/code&gt; can detect version strings in &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt; and create PRs to update them. This keeps exclusion versions current but can&apos;t decide whether an entry should be deleted. It&apos;s a useful supplement, not a replacement for manual review.&lt;/p&gt;
&lt;p&gt;The pnpm team is considering a &lt;code&gt;pnpm cleanup&lt;/code&gt; command that would auto-prune stale entries (&lt;a href=&quot;https://github.com/pnpm/pnpm/issues/11668&quot;&gt;pnpm#11668&lt;/a&gt;). Until that lands, the workflow is manual.&lt;/p&gt;
&lt;h2&gt;What I&apos;d Do Differently&lt;/h2&gt;
&lt;p&gt;I&apos;d run this review quarterly from the start instead of discovering the staleness when applying the config to a second project. The exclusion lists were doing real work when I added them — I just never went back to check whether they still were.&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/pnpm/pnpm/issues/11668&quot;&gt;pnpm#11668&lt;/a&gt; — feature request for &lt;code&gt;pnpm cleanup&lt;/code&gt; to auto-prune stale entries&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/pnpm/pnpm/issues/11563&quot;&gt;pnpm#11563&lt;/a&gt; — bug where &lt;code&gt;pnpm audit --fix update&lt;/code&gt; adds unnecessary entries&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings#minimumreleaseageexclude&quot;&gt;pnpm &lt;code&gt;minimumReleaseAgeExclude&lt;/code&gt;&lt;/a&gt; — exemption list for age-based blocking&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings#trustpolicyexclude&quot;&gt;pnpm &lt;code&gt;trustPolicyExclude&lt;/code&gt;&lt;/a&gt; — exemption list for trust downgrade checks&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings#allowbuilds&quot;&gt;pnpm &lt;code&gt;allowBuilds&lt;/code&gt;&lt;/a&gt; — install script allowlist&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.renovatebot.com/modules/manager/regex/&quot;&gt;Renovate &lt;code&gt;customManagers&lt;/code&gt;&lt;/a&gt; — partial automation for version detection&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>security</category><category>supply-chain</category><category>pnpm</category><category>maintenance</category></item><item><title>Creating a Tone Skill for AI-Assisted Blog Posts</title><link>https://frangonf.com/blog/clanker-post-skill</link><guid isPermaLink="true">https://frangonf.com/blog/clanker-post-skill</guid><description>How I turned implicit writing patterns into a reusable agent skill for consistent Clanker-authored content.</description><pubDate>Thu, 04 Jun 2026 13:20:45 GMT</pubDate><content:encoded>&lt;p&gt;I had blog posts written by Clanker but no formal guidelines. The voice was consistent because I manually prompted for it every time. I wanted a skill that future sessions could load to maintain that voice without guessing.&lt;/p&gt;
&lt;h2&gt;The Problem&lt;/h2&gt;
&lt;p&gt;Each post followed a similar pattern — problem-first structure, honest tone, inline attribution — but nothing documented &lt;em&gt;why&lt;/em&gt;. A new session without context could drift into marketing language or over-explain things. The voice existed in the posts but not as instructions.&lt;/p&gt;
&lt;h2&gt;What Changed&lt;/h2&gt;
&lt;p&gt;I reviewed existing Clanker posts, extracted the recurring patterns, and wrote them into a skill file at &lt;code&gt;.agents/skills/clanker-post/SKILL.md&lt;/code&gt; following the &lt;a href=&quot;https://opencode.ai/docs/skills/&quot;&gt;opencode Agent Skills spec&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The skill covers:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Voice&lt;/strong&gt; — first person, active voice, no AI disclaimers&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tone&lt;/strong&gt; — stabilized, concise, humble, honest, informative&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Structure&lt;/strong&gt; — Problem → What Changed → Results (when relevant) → References (when relevant)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Attribution&lt;/strong&gt; — inline source credits, References section when sources are used&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Checklist&lt;/strong&gt; — 10-item pre-publish verification&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The hardest part was making Results and References contextual. I initially wrote &quot;always include&quot; — then realized test posts and proofs of concept don&apos;t need them. &quot;When relevant&quot; with clear skip criteria fixed that.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// .agents/skills/clanker-post/SKILL.md
---
name: clanker-post
description: Write AI-assisted blog posts for frangonf.com in a stabilized,
concise, humble, honest, and informative tone.
---
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The skill loads on demand via &lt;code&gt;skill({ name: &quot;clanker-post&quot; })&lt;/code&gt; — no config changes, no global skills modified.&lt;/p&gt;
&lt;h2&gt;What I&apos;d Do Differently&lt;/h2&gt;
&lt;p&gt;I wrote the skill before reviewing the posts. Extract patterns first, then formalize would have been faster. The &quot;What to Avoid&quot; table was the easiest part — filler phrases I&apos;d already been avoiding, now explicit for future sessions to check against.&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://opencode.ai/docs/skills/&quot;&gt;opencode Agent Skills&lt;/a&gt; — skill file format and discovery paths&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>agents</category><category>skills</category><category>writing</category><category>documentation</category></item><item><title>Supply Chain Security with mise and pnpm</title><link>https://frangonf.com/blog/supply-chain-security-mise-pnpm</link><guid isPermaLink="true">https://frangonf.com/blog/supply-chain-security-mise-pnpm</guid><description>Trying to survive in the JS minefield</description><pubDate>Thu, 04 Jun 2026 13:06:18 GMT</pubDate><content:encoded>&lt;p&gt;Supply chain attacks have been escalating for months — compromised npm packages, tainted GitHub Actions, backdoored tool releases. If you&apos;re maintaining a JavaScript project, the question isn&apos;t whether to harden your supply chain, but how much. I applied these controls to my projects, and the setup carries over to any Node.js/pnpm project.&lt;/p&gt;
&lt;h2&gt;The Problem&lt;/h2&gt;
&lt;p&gt;Supply chain attacks target the tools and dependencies you trust. A compromised npm package, a backdoored GitHub Action, or a tampered tool binary can give an attacker access to your build output, your secrets, or your users.&lt;/p&gt;
&lt;p&gt;The projects had lockfiles, HTTPS registry connections, and some pnpm v10 workspace settings — but nothing systematic. No cooling-off period for tool releases. No checksum verification for tool installs. CI workflows using mutable tags instead of pinned commits. The goal was to close those gaps across the full stack: tool installation, dependency management, CI pipelines, and git hooks.&lt;/p&gt;
&lt;h2&gt;What Changed&lt;/h2&gt;
&lt;p&gt;The hardening covers five areas: tool installation, dependency management, npm configuration, CI pipelines, and git hooks. Most of these controls existed in pnpm v10 — the v11 migration consolidated them and added new defaults.&lt;/p&gt;
&lt;h3&gt;Tool pinning with mise&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://mise.jdx.dev/&quot;&gt;mise&lt;/a&gt; manages runtime versions across Node.js, pnpm, and developer tools. Every version is pinned to an exact release in &lt;code&gt;mise.toml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# mise.toml
[settings]
minimum_release_age = &quot;7d&quot;

[tools]
node = &quot;26.1.0&quot;
pnpm = &quot;11.1.2&quot;
hk = &quot;1.46.0&quot;
&quot;github:zizmorcore/zizmor&quot; = &quot;v1.25.2&quot;
actionlint = &quot;1.7.12&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;a href=&quot;https://mise.jdx.dev/configuration/settings.html#minimum_release_age&quot;&gt;&lt;code&gt;minimum_release_age&lt;/code&gt;&lt;/a&gt; setting is the key addition. It refuses to install any tool release published less than seven days ago. If a tool maintainer&apos;s account is compromised and a backdoored version ships, you have a week to find out before your machine touches it.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://mise.jdx.dev/dev-tools/mise-lock.html&quot;&gt;&lt;code&gt;mise.lock&lt;/code&gt;&lt;/a&gt; file pins SHA-256 checksums for every tool binary on every platform. It also records &lt;a href=&quot;https://mise.jdx.dev/dev-tools/mise-lock.html#provenance-and-security&quot;&gt;provenance attestations&lt;/a&gt; for tools sourced from GitHub — cryptographic proof that the binary came from the expected release, not a tampered artifact.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# mise.lock (excerpt)
[[tools.pnpm]]
version = &quot;11.1.2&quot;
backend = &quot;aqua:pnpm/pnpm&quot;

[tools.pnpm.&quot;platforms.macos-arm64&quot;]
checksum = &quot;sha256:2f46bcb7ac3c693af72e06e68467b3a9127cbf2a24b778382bc8beaff8463aee&quot;
url = &quot;https://github.com/pnpm/pnpm/releases/download/v11.1.2/pnpm-darwin-arm64.tar.gz&quot;
provenance = &quot;github-attestations&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;pnpm workspace security&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://pnpm.io/settings&quot;&gt;pnpm&apos;s workspace settings&lt;/a&gt; ship a set of supply chain controls that go well beyond what &lt;code&gt;.npmrc&lt;/code&gt; alone can do. These are the ones worth enabling.&lt;/p&gt;
&lt;h4&gt;Blocking fresh packages&lt;/h4&gt;
&lt;p&gt;The &lt;a href=&quot;https://pnpm.io/settings#minimumreleaseage&quot;&gt;&lt;code&gt;minimumReleaseAge&lt;/code&gt;&lt;/a&gt; setting (in minutes) rejects any npm dependency published less than N minutes ago. 1440 (one day) is a reasonable default:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# pnpm-workspace.yaml
minimumReleaseAge: 1440
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the npm equivalent of mise&apos;s &lt;code&gt;minimum_release_age&lt;/code&gt;. Most compromised packages are discovered and removed from the registry within hours. A 24-hour cooling-off period catches the majority of them without breaking normal workflow. Packages that genuinely need to be installed fresh get listed under &lt;a href=&quot;https://pnpm.io/settings#minimumreleaseageexclude&quot;&gt;&lt;code&gt;minimumReleaseAgeExclude&lt;/code&gt;&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;minimumReleaseAgeExclude:
  - nuxt@4.4.7
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Trust policy&lt;/h4&gt;
&lt;p&gt;The &lt;a href=&quot;https://pnpm.io/settings#trustpolicy&quot;&gt;&lt;code&gt;trustPolicy&lt;/code&gt;&lt;/a&gt; setting prevents installing a version of a package that has weaker trust evidence than a previous release. Set to &lt;code&gt;no-downgrade&lt;/code&gt;, pnpm will refuse to install if a package that previously shipped with provenance suddenly stops:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;trustPolicy: no-downgrade
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Packages that don&apos;t yet support provenance but are known-safe get excluded:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;trustPolicyExclude:
  - chokidar@4.0.3
  - semver
  - tinyclip@0.1.13
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These exclusions are stopgaps. The goal is to remove them as upstream packages adopt provenance.&lt;/p&gt;
&lt;h4&gt;Blocking exotic sub-dependencies&lt;/h4&gt;
&lt;p&gt;The &lt;a href=&quot;https://pnpm.io/settings#blockexoticsubdeps&quot;&gt;&lt;code&gt;blockExoticSubdeps&lt;/code&gt;&lt;/a&gt; setting ensures that only your direct dependencies can come from git repos or tarball URLs. Transitive dependencies — the ones you didn&apos;t choose and aren&apos;t reviewing — must resolve from a trusted registry:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;blockExoticSubdeps: true
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Controlling install scripts&lt;/h4&gt;
&lt;p&gt;Install scripts (postinstall, preinstall) are the most dangerous part of the npm ecosystem. A compromised package with a postinstall script gets code execution on your machine during &lt;code&gt;pnpm install&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;pnpm v11 uses &lt;a href=&quot;https://pnpm.io/settings#allowbuilds&quot;&gt;&lt;code&gt;allowBuilds&lt;/code&gt;&lt;/a&gt; — a map of package names to &lt;code&gt;true&lt;/code&gt; or &lt;code&gt;false&lt;/code&gt; — to whitelist exactly which packages may run scripts:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# pnpm-workspace.yaml (pnpm v11)
allowBuilds:
  &quot;@parcel/watcher&quot;: true
  better-sqlite3: true
  esbuild: true
  sharp: true
  unrs-resolver: true
  vue-demi: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every package not listed is blocked by default.&lt;/p&gt;
&lt;p&gt;If you&apos;re upgrading from pnpm v10, the old &lt;a href=&quot;https://pnpm.io/10.x/settings#onlybuiltdependencies&quot;&gt;&lt;code&gt;onlyBuiltDependencies&lt;/code&gt;&lt;/a&gt; (a list of allowed packages) and &lt;a href=&quot;https://pnpm.io/10.x/settings#ignoredbuiltdependencies&quot;&gt;&lt;code&gt;ignoredBuiltDependencies&lt;/code&gt;&lt;/a&gt; (a blocklist) have been consolidated into &lt;code&gt;allowBuilds&lt;/code&gt; and removed. The &lt;code&gt;managePackageManagerVersions&lt;/code&gt; and &lt;code&gt;packageManagerStrictVersion&lt;/code&gt; settings are also gone, replaced by &lt;a href=&quot;https://pnpm.io/settings#pmOnFail&quot;&gt;&lt;code&gt;pmOnFail&lt;/code&gt;&lt;/a&gt;. Running &lt;code&gt;pnpx codemod run pnpm-v10-to-v11&lt;/code&gt; handles the migration.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://pnpm.io/settings#strictdepbuilds&quot;&gt;&lt;code&gt;strictDepBuilds&lt;/code&gt;&lt;/a&gt; setting (default: &lt;code&gt;true&lt;/code&gt;) makes this enforced — any package with an unreviewed build script causes the install to fail, not just warn.&lt;/p&gt;
&lt;h4&gt;Additional hardening&lt;/h4&gt;
&lt;p&gt;A few more settings worth enabling:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;strictPeerDependencies: true
strictStorePkgContentCheck: true
preferFrozenLockfile: true
saveExact: true
engineStrict: true
pmOnFail: error
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href=&quot;https://pnpm.io/settings#strictpeerdependencies&quot;&gt;&lt;code&gt;strictPeerDependencies&lt;/code&gt;&lt;/a&gt; fails the install on missing or invalid peer dependencies. &lt;a href=&quot;https://pnpm.io/settings#strictstorepkgcontentcheck&quot;&gt;&lt;code&gt;strictStorePkgContentCheck&lt;/code&gt;&lt;/a&gt; validates package names and versions against store contents, catching registry anomalies. &lt;a href=&quot;https://pnpm.io/settings#preferfrozenlockfile&quot;&gt;&lt;code&gt;preferFrozenLockfile&lt;/code&gt;&lt;/a&gt; skips dependency resolution when the lockfile already satisfies &lt;code&gt;package.json&lt;/code&gt;, preventing unnecessary lockfile mutations.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://pnpm.io/settings#saveexact&quot;&gt;&lt;code&gt;saveExact&lt;/code&gt;&lt;/a&gt; pins exact versions when adding new dependencies (no &lt;code&gt;^&lt;/code&gt; or &lt;code&gt;~&lt;/code&gt; prefixes). &lt;a href=&quot;https://pnpm.io/settings#enginestrict&quot;&gt;&lt;code&gt;engineStrict&lt;/code&gt;&lt;/a&gt; fails installs when a dependency targets an incompatible Node.js or pnpm version. &lt;a href=&quot;https://pnpm.io/settings#pmOnFail&quot;&gt;&lt;code&gt;pmOnFail&lt;/code&gt;&lt;/a&gt; (set to &lt;code&gt;&quot;error&quot;&lt;/code&gt;) enforces the exact pnpm version from the &lt;code&gt;packageManager&lt;/code&gt; field — replacing the deprecated &lt;code&gt;managePackageManagerVersions&lt;/code&gt; and &lt;code&gt;packageManagerStrictVersion&lt;/code&gt; settings from pnpm v10.&lt;/p&gt;
&lt;h3&gt;npm configuration&lt;/h3&gt;
&lt;p&gt;In pnpm v11, &lt;a href=&quot;https://pnpm.io/npmrc&quot;&gt;&lt;code&gt;.npmrc&lt;/code&gt; is auth/registry only&lt;/a&gt;. All other settings belong in &lt;a href=&quot;https://pnpm.io/settings&quot;&gt;&lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;&lt;/a&gt;. This is a change from pnpm v10, where &lt;code&gt;.npmrc&lt;/code&gt; was the primary config file. Here&apos;s where each setting lives:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;&lt;code&gt;.npmrc&lt;/code&gt; (auth only)&lt;/th&gt;
&lt;th&gt;&lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;_authToken&lt;/code&gt;, &lt;code&gt;_auth&lt;/code&gt;, certificates&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;registry&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;strict-ssl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;save-exact&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;engine-strict&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;strict-peer-dependencies&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;audit&lt;/code&gt;, &lt;code&gt;audit-level&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;verify-store-integrity&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fetch-retries&lt;/code&gt;, &lt;code&gt;fetch-retry-*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;After migrating to pnpm v11, simple pnpm projects can remove &lt;code&gt;.npmrc&lt;/code&gt; entirely — all project policy lives in &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;. Projects that don&apos;t use private registries or custom auth tokens have no reason to keep the file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# pnpm-workspace.yaml (pnpm v11)
saveExact: true
engineStrict: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;a href=&quot;https://nodejs.org/api/packages.html#packagemanager&quot;&gt;&lt;code&gt;packageManager&lt;/code&gt;&lt;/a&gt; field in &lt;code&gt;package.json&lt;/code&gt; reinforces version pinning through Corepack:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;packageManager&quot;: &quot;pnpm@11.1.2&quot;,
  &quot;engines&quot;: {
    &quot;node&quot;: &quot;&amp;gt;=26.1.0 &amp;lt;27&quot;,
    &quot;pnpm&quot;: &quot;&amp;gt;=11.1.2 &amp;lt;12&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Combined with &lt;code&gt;engineStrict&lt;/code&gt;, there&apos;s no way for a contributor to accidentally run a different version.&lt;/p&gt;
&lt;h3&gt;CI hardening&lt;/h3&gt;
&lt;p&gt;The GitHub Actions workflows got three changes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SHA-pinned actions.&lt;/strong&gt; Every &lt;code&gt;uses:&lt;/code&gt; reference now points to a full commit hash, not a mutable tag. A compromised tag (e.g., &lt;code&gt;actions/checkout@v6&lt;/code&gt; being replaced with a backdoored &lt;code&gt;v6&lt;/code&gt;) can&apos;t affect the workflow:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3
  with:
    persist-credentials: false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The version comment is for readability. The SHA is what the runner resolves. GitHub&apos;s &lt;a href=&quot;https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions&quot;&gt;security hardening guide&lt;/a&gt; recommends this as the primary defense against compromised third-party actions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;persist-credentials: false&lt;/code&gt;.&lt;/strong&gt; By default, &lt;code&gt;actions/checkout&lt;/code&gt; writes the auth token to the local git config. Setting &lt;code&gt;persist-credentials: false&lt;/code&gt; (&lt;a href=&quot;https://github.com/actions/checkout&quot;&gt;documented in the checkout action&lt;/a&gt;) prevents that token from lingering in the runner environment.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Minimal permissions.&lt;/strong&gt; The top-level &lt;code&gt;permissions&lt;/code&gt; key locks down the &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; to read-only. Individual jobs declare only what they need:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;permissions: {} # deny-all at top level

jobs:
  ci:
    permissions:
      contents: read
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This follows the &lt;a href=&quot;https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions&quot;&gt;least-privilege principle&lt;/a&gt; — if a step is compromised, it can only do what its job&apos;s permissions allow.&lt;/p&gt;
&lt;p&gt;Adding &lt;code&gt;pnpm audit&lt;/code&gt; as a dedicated CI step catches known vulnerabilities before they ship:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- name: Audit production dependencies
  run: pnpm audit --prod --audit-level high
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;a href=&quot;https://pnpm.io/cli/audit&quot;&gt;&lt;code&gt;pnpm audit&lt;/code&gt;&lt;/a&gt; command checks the dependency tree against the npm advisory database. Running it in CI means vulnerable dependencies block the merge.&lt;/p&gt;
&lt;h3&gt;Git hooks with hk&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://hk.jdx.dev/&quot;&gt;hk&lt;/a&gt; runs linting, formatting, and type checks as pre-commit hooks. The &lt;a href=&quot;https://hk.jdx.dev/configuration.html&quot;&gt;configuration&lt;/a&gt; pins hk itself to an exact release via its &lt;code&gt;amends&lt;/code&gt; directive:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# hk.pkl
amends &quot;package://github.com/jdx/hk/releases/download/v1.46.0/hk@1.46.0#/Config.pkl&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Hooks route through mise-managed tools, so the same pinned versions run locally and in CI. The pre-commit hook enforces formatting, linting, and type checking before any code reaches the repository.&lt;/p&gt;
&lt;p&gt;The layers above block the most common supply chain attack vectors with minimal friction. But static config alone isn&apos;t enough — the next post covers &lt;a href=&quot;/blog/automated-dependency-updates-supply-chain&quot;&gt;automated dependency updates with supply chain security&lt;/a&gt;, and a third covers &lt;a href=&quot;/blog/keeping-supply-chain-exclusions-honest&quot;&gt;keeping exclusions honest&lt;/a&gt; as your dependency tree evolves.&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://mise.jdx.dev/&quot;&gt;mise&lt;/a&gt; — tool version manager, used for pinning Node.js, pnpm, and dev tools&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mise.jdx.dev/configuration.html&quot;&gt;mise configuration&lt;/a&gt; — mise.toml settings reference&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mise.jdx.dev/dev-tools/mise-lock.html&quot;&gt;mise lockfile&lt;/a&gt; — checksum and provenance lockfile documentation&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://mise.jdx.dev/configuration/settings.html#minimum_release_age&quot;&gt;mise &lt;code&gt;minimum_release_age&lt;/code&gt;&lt;/a&gt; — cooling-off period for tool releases&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings&quot;&gt;pnpm settings&lt;/a&gt; — pnpm-workspace.yaml configuration reference&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings#minimumreleaseage&quot;&gt;pnpm &lt;code&gt;minimumReleaseAge&lt;/code&gt;&lt;/a&gt; — cooling-off period for npm packages&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings#trustpolicy&quot;&gt;pnpm &lt;code&gt;trustPolicy&lt;/code&gt;&lt;/a&gt; — trust downgrade prevention&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings#blockexoticsubdeps&quot;&gt;pnpm &lt;code&gt;blockExoticSubdeps&lt;/code&gt;&lt;/a&gt; — blocking transitive exotic sources&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings#allowbuilds&quot;&gt;pnpm &lt;code&gt;allowBuilds&lt;/code&gt;&lt;/a&gt; — install script allowlist (replaces v10&apos;s &lt;code&gt;onlyBuiltDependencies&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings#strictdepbuilds&quot;&gt;pnpm &lt;code&gt;strictDepBuilds&lt;/code&gt;&lt;/a&gt; — enforce build script review&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings#verifystoreintegrity&quot;&gt;pnpm &lt;code&gt;verifyStoreIntegrity&lt;/code&gt;&lt;/a&gt; — content-addressable store verification&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/cli/install#--frozen-lockfile&quot;&gt;pnpm &lt;code&gt;--frozen-lockfile&lt;/code&gt;&lt;/a&gt; — lockfile immutability during install&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/cli/audit&quot;&gt;pnpm &lt;code&gt;audit&lt;/code&gt;&lt;/a&gt; — vulnerability checking against npm advisory database&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/npmrc&quot;&gt;pnpm &lt;code&gt;.npmrc&lt;/code&gt;&lt;/a&gt; — auth and registry settings only (v11)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings#saveexact&quot;&gt;pnpm &lt;code&gt;saveExact&lt;/code&gt;&lt;/a&gt; — exact version pinning for new dependencies&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings#enginestrict&quot;&gt;pnpm &lt;code&gt;engineStrict&lt;/code&gt;&lt;/a&gt; — engine compatibility enforcement&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://nodejs.org/api/packages.html#packagemanager&quot;&gt;Corepack &lt;code&gt;packageManager&lt;/code&gt;&lt;/a&gt; — package manager version enforcement&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions&quot;&gt;GitHub Actions security hardening&lt;/a&gt; — SHA pinning, third-party action safety&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions&quot;&gt;GitHub Actions &lt;code&gt;permissions&lt;/code&gt;&lt;/a&gt; — least-privilege token scoping&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/actions/checkout&quot;&gt;actions/checkout&lt;/a&gt; — &lt;code&gt;persist-credentials&lt;/code&gt; documentation&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://hk.jdx.dev/&quot;&gt;hk&lt;/a&gt; — git hooks manager&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://hk.jdx.dev/configuration.html&quot;&gt;hk configuration&lt;/a&gt; — hk.pkl configuration reference&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings#allowbuilds&quot;&gt;pnpm v10 to v11 migration&lt;/a&gt; — &lt;code&gt;allowBuilds&lt;/code&gt; migration from &lt;code&gt;onlyBuiltDependencies&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings#strictpeerdependencies&quot;&gt;pnpm &lt;code&gt;strictPeerDependencies&lt;/code&gt;&lt;/a&gt; — enforce peer dependency validity&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings#strictstorepkgcontentcheck&quot;&gt;pnpm &lt;code&gt;strictStorePkgContentCheck&lt;/code&gt;&lt;/a&gt; — validate package contents against store&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings#preferfrozenlockfile&quot;&gt;pnpm &lt;code&gt;preferFrozenLockfile&lt;/code&gt;&lt;/a&gt; — skip resolution when lockfile is satisfied&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pnpm.io/settings#pmOnFail&quot;&gt;pnpm &lt;code&gt;pmOnFail&lt;/code&gt;&lt;/a&gt; — package manager version enforcement (replaces deprecated settings)&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>security</category><category>supply-chain</category><category>mise</category><category>pnpm</category><category>astro</category></item><item><title>SEO Audit of Two Astro Projects</title><link>https://frangonf.com/blog/seo-audit-astro-projects</link><guid isPermaLink="true">https://frangonf.com/blog/seo-audit-astro-projects</guid><description>What I found when I reviewed the Open Graph and structured data setup across bsnews and frangonf.com, and what I changed.</description><pubDate>Thu, 04 Jun 2026 11:52:09 GMT</pubDate><content:encoded>&lt;p&gt;I reviewed the SEO setup in two Astro projects — a satirical news site (&lt;a href=&quot;https://bsnews.fyi&quot;&gt;bsnews.fyi&lt;/a&gt;) and this blog. Both had basic meta tags in place, but social sharing was broken or incomplete in ways I hadn&apos;t noticed. Here&apos;s what I found and fixed.&lt;/p&gt;
&lt;h2&gt;What Was Missing&lt;/h2&gt;
&lt;p&gt;The audit turned up issues in both projects. Some were things I thought I&apos;d handled, some were just never on my radar.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;bsnews:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;No &lt;code&gt;twitter:card&lt;/code&gt; meta tag — Twitter/X wouldn&apos;t render image preview cards&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;og:image&lt;/code&gt; fallback — pages without a frontmatter &lt;code&gt;image&lt;/code&gt; field got no OG image at all&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;og:image:width&lt;/code&gt; or &lt;code&gt;og:image:height&lt;/code&gt; — platforms may fail to render the image&lt;/li&gt;
&lt;li&gt;&lt;code&gt;og:type&lt;/code&gt; was always &lt;code&gt;&quot;website&quot;&lt;/code&gt;, even on article pages&lt;/li&gt;
&lt;li&gt;Link card pages had an &lt;code&gt;image&lt;/code&gt; field in their schema but never passed it to the layout&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;article:published_time&lt;/code&gt; or &lt;code&gt;article:modified_time&lt;/code&gt; meta tags&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;frangonf.com:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;robots.txt&lt;/code&gt; was empty — no sitemap pointer for crawlers&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;og:site_name&lt;/code&gt; or &lt;code&gt;og:locale&lt;/code&gt; tags&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;og:image:width&lt;/code&gt; or &lt;code&gt;og:image:height&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;No JSON-LD structured data on blog posts&lt;/li&gt;
&lt;li&gt;Dead &lt;code&gt;seo.keywords&lt;/code&gt; field in the content schema that was never emitted&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Nothing catastrophic, but the kind of gaps that mean social previews look wrong and search engines miss context they could use.&lt;/p&gt;
&lt;h2&gt;Open Graph and Twitter Cards&lt;/h2&gt;
&lt;p&gt;The minimal OG setup that actually works for a blog:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;meta property=&quot;og:type&quot; content=&quot;article&quot; /&amp;gt;
&amp;lt;meta property=&quot;og:title&quot; content=&quot;{title}&quot; /&amp;gt;
&amp;lt;meta property=&quot;og:description&quot; content=&quot;{description}&quot; /&amp;gt;
&amp;lt;meta property=&quot;og:url&quot; content=&quot;{canonicalUrl}&quot; /&amp;gt;
&amp;lt;meta property=&quot;og:image&quot; content=&quot;{ogImageUrl}&quot; /&amp;gt;
&amp;lt;meta property=&quot;og:image:width&quot; content=&quot;1200&quot; /&amp;gt;
&amp;lt;meta property=&quot;og:image:height&quot; content=&quot;630&quot; /&amp;gt;
&amp;lt;meta property=&quot;og:locale&quot; content=&quot;en_US&quot; /&amp;gt;
&amp;lt;meta property=&quot;og:site_name&quot; content=&quot;Site Name&quot; /&amp;gt;

&amp;lt;meta name=&quot;twitter:card&quot; content=&quot;summary_large_image&quot; /&amp;gt;
&amp;lt;meta name=&quot;twitter:title&quot; content=&quot;{title}&quot; /&amp;gt;
&amp;lt;meta name=&quot;twitter:description&quot; content=&quot;{description}&quot; /&amp;gt;
&amp;lt;meta name=&quot;twitter:image&quot; content=&quot;{ogImageUrl}&quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;og:image:width&lt;/code&gt; and &lt;code&gt;og:image:height&lt;/code&gt; tags are easy to skip, but without them some platforms don&apos;t render the image reliably. The recommended dimensions are 1200x630.&lt;/p&gt;
&lt;p&gt;Twitter falls back to OG tags automatically, but explicit &lt;code&gt;twitter:*&lt;/code&gt; tags ensure consistent rendering. I learned this the hard way — bsnews had no &lt;code&gt;twitter:card&lt;/code&gt; at all, so shared links showed as plain text previews.&lt;/p&gt;
&lt;h2&gt;The OG Image Fallback Problem&lt;/h2&gt;
&lt;p&gt;The bigger issue was that pages without a frontmatter &lt;code&gt;image&lt;/code&gt; field produced no &lt;code&gt;og:image&lt;/code&gt; tag at all. On bsnews, link cards and most listing pages had no image in social previews.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; A branded fallback image (&lt;code&gt;og-default.png&lt;/code&gt;, 1200x630) and a layout change so &lt;code&gt;og:image&lt;/code&gt; always emits:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const ogImage = image
  ? new URL(image, Astro.site).href
  : new URL(&quot;/og-default.png&quot;, Astro.site).href;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I generated the fallback with Python — no ImageMagick on the machine, so I wrote raw PNG bytes with &lt;code&gt;struct&lt;/code&gt; and &lt;code&gt;zlib&lt;/code&gt;. It&apos;s a dark background with colored accent stripes using the bsnews palette. Not pretty, but it works.&lt;/p&gt;
&lt;h2&gt;Structured Data (JSON-LD)&lt;/h2&gt;
&lt;p&gt;I added &lt;code&gt;BlogPosting&lt;/code&gt; JSON-LD to every blog post on frangonf.com. For bsnews, the article pages already had &lt;code&gt;NewsArticle&lt;/code&gt; schema, but link cards were getting &lt;code&gt;WebSite&lt;/code&gt; — which doesn&apos;t make sense for individual content items.&lt;/p&gt;
&lt;p&gt;The minimal &lt;code&gt;BlogPosting&lt;/code&gt; schema:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;@context&quot;: &quot;https://schema.org&quot;,
  &quot;@type&quot;: &quot;BlogPosting&quot;,
  &quot;headline&quot;: &quot;Post Title&quot;,
  &quot;description&quot;: &quot;Post description&quot;,
  &quot;datePublished&quot;: &quot;2026-06-04T00:00:00Z&quot;,
  &quot;author&quot;: {
    &quot;@type&quot;: &quot;Person&quot;,
    &quot;name&quot;: &quot;Fran&quot;
  },
  &quot;publisher&quot;: {
    &quot;@type&quot;: &quot;Organization&quot;,
    &quot;name&quot;: &quot;Site Name&quot;,
    &quot;logo&quot;: {
      &quot;@type&quot;: &quot;ImageObject&quot;,
      &quot;url&quot;: &quot;https://site.com/favicon.svg&quot;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In Astro, this goes in a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag with &lt;code&gt;set:html&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;script is:inline type=&quot;application/ld+json&quot; set:html={JSON.stringify({
  &quot;@context&quot;: &quot;https://schema.org&quot;,
  &quot;@type&quot;: &quot;BlogPosting&quot;,
  headline: post.data.title,
  description: pageDescription,
  datePublished: toIsoDateTime(post.data.date),
  author: { &quot;@type&quot;: &quot;Person&quot;, name: post.data.author ?? &quot;Fran&quot; },
  publisher: {
    &quot;@type&quot;: &quot;Organization&quot;,
    name: &quot;Fran Gonzalez&quot;,
    logo: { &quot;@type&quot;: &quot;ImageObject&quot;, url: new URL(&quot;/favicon.svg&quot;, Astro.site).href },
  },
})} /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;set:html&lt;/code&gt; approach avoids XSS issues from string interpolation. I found this pattern in a few blog posts while researching — the raw &lt;code&gt;JSON.stringify&lt;/code&gt; inside &lt;code&gt;set:html&lt;/code&gt; is the recommended way.&lt;/p&gt;
&lt;h2&gt;Article Metadata&lt;/h2&gt;
&lt;p&gt;Both projects had &lt;code&gt;og:type&lt;/code&gt; hardcoded to &lt;code&gt;&quot;website&quot;&lt;/code&gt;. For blog posts, it should be &lt;code&gt;&quot;article&quot;&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Added &lt;code&gt;ogType&lt;/code&gt; prop to the layout, defaulting to &lt;code&gt;&quot;website&quot;&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;interface Props {
  ogType?: string;
  publishedTime?: string;
  modifiedTime?: string;
}

const { ogType = &quot;website&quot;, publishedTime, modifiedTime } = Astro.props;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then each blog post page passes the right values:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;BaseLayout
  ogType=&quot;article&quot;
  publishedTime={toIsoDateTime(post.data.date)}
  modifiedTime={post.data.updatedAt ? toIsoDateTime(post.data.updatedAt) : undefined}
&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;robots.txt&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;robots.txt&lt;/code&gt; on frangonf.com was empty — just a blank line. It should point crawlers to the sitemap:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;User-agent: *
Allow: /

Sitemap: https://frangonf.com/sitemap-index.xml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I also added &lt;code&gt;noindex&lt;/code&gt; to both 404 pages via a &lt;code&gt;robots&lt;/code&gt; prop on the layout. Error pages shouldn&apos;t be indexed.&lt;/p&gt;
&lt;h2&gt;What I Removed&lt;/h2&gt;
&lt;p&gt;The blog content schema had a &lt;code&gt;seo.keywords&lt;/code&gt; field that was never used anywhere — not in templates, not in meta tags. I removed it. Dead schema fields are noise that make the codebase harder to reason about.&lt;/p&gt;
&lt;h2&gt;Results&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;bsnews:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;twitter:card&lt;/code&gt;, &lt;code&gt;twitter:title&lt;/code&gt;, &lt;code&gt;twitter:description&lt;/code&gt;, &lt;code&gt;twitter:image&lt;/code&gt; — now present on all pages&lt;/li&gt;
&lt;li&gt;&lt;code&gt;og:image&lt;/code&gt; — now always emits (fallback for pages without images)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;og:image:width&lt;/code&gt;, &lt;code&gt;og:image:height&lt;/code&gt;, &lt;code&gt;og:image:alt&lt;/code&gt; — added&lt;/li&gt;
&lt;li&gt;&lt;code&gt;og:type&lt;/code&gt; — set to &lt;code&gt;&quot;article&quot;&lt;/code&gt; on article and link card pages&lt;/li&gt;
&lt;li&gt;&lt;code&gt;article:published_time&lt;/code&gt;, &lt;code&gt;article:modified_time&lt;/code&gt; — added to article pages&lt;/li&gt;
&lt;li&gt;Link cards — now pass &lt;code&gt;image&lt;/code&gt; and get &lt;code&gt;Article&lt;/code&gt; JSON-LD instead of &lt;code&gt;WebSite&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;404 page — &lt;code&gt;noindex, nofollow&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;frangonf.com:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;robots.txt&lt;/code&gt; — populated with sitemap directive&lt;/li&gt;
&lt;li&gt;&lt;code&gt;og:site_name&lt;/code&gt;, &lt;code&gt;og:locale&lt;/code&gt;, &lt;code&gt;og:image:width&lt;/code&gt;, &lt;code&gt;og:image:height&lt;/code&gt; — added&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BlogPosting&lt;/code&gt; JSON-LD — added to every blog post&lt;/li&gt;
&lt;li&gt;404 page — &lt;code&gt;noindex, nofollow&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Dead &lt;code&gt;seo.keywords&lt;/code&gt; schema field — removed&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Both projects had &lt;code&gt;@astrojs/sitemap&lt;/code&gt; configured correctly already, so no changes there. Canonical URLs were also in place on both projects.&lt;/p&gt;
&lt;h2&gt;What I&apos;d Do Differently&lt;/h2&gt;
&lt;p&gt;A dedicated &lt;code&gt;SEO.astro&lt;/code&gt; component would be cleaner than putting all the meta tags directly in the layout. Right now both projects have the tags inlined in &lt;code&gt;BaseLayout.astro&lt;/code&gt;, which works but gets harder to maintain as you add more meta properties.&lt;/p&gt;
&lt;p&gt;I also skipped dynamic OG image generation (using something like &lt;code&gt;@vercel/og&lt;/code&gt; or &lt;code&gt;astro-og-image&lt;/code&gt;). For a blog with a small number of posts, the frontmatter &lt;code&gt;image&lt;/code&gt; field plus a fallback is fine. But for a site that publishes frequently, generating images from post titles would be worth the build time.&lt;/p&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://ogp.me/&quot;&gt;Open Graph Protocol&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.x.com/en/docs/twitter-for-websites/cards/overview/abouts-cards&quot;&gt;Twitter Card Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://schema.org/BlogPosting&quot;&gt;Schema.org BlogPosting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astro.build/en/guides/integrations-guide/sitemap/&quot;&gt;Astro Sitemap Integration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://search.google.com/test/rich-results&quot;&gt;Google Rich Results Test&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;SEO isn&apos;t glamorous, but broken social previews are the kind of thing nobody tells you about until you share a link and it looks wrong.&lt;/p&gt;
</content:encoded><category>astro</category><category>seo</category><category>open-graph</category><category>social-sharing</category></item><item><title>Image Optimization in Astro</title><link>https://frangonf.com/blog/astro-image-optimization</link><guid isPermaLink="true">https://frangonf.com/blog/astro-image-optimization</guid><description>Replacing raw img tags with Astro&apos;s built-in Image component for automatic optimization, responsive srcsets, and format conversion.</description><pubDate>Thu, 04 Jun 2026 11:27:30 GMT</pubDate><content:encoded>&lt;h2&gt;The Problem&lt;/h2&gt;
&lt;p&gt;Images were served from &lt;code&gt;public/images/&lt;/code&gt; with raw HTML &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tags. No optimization, no responsive sizing, no modern format conversion. A 47kB JPEG shipped as-is to every device.&lt;/p&gt;
&lt;h2&gt;What Changed&lt;/h2&gt;
&lt;p&gt;Astro provides built-in image optimization through the &lt;code&gt;&amp;lt;Image /&amp;gt;&lt;/code&gt; component and the &lt;code&gt;image()&lt;/code&gt; content schema helper. No external plugins required — just &lt;code&gt;sharp&lt;/code&gt; as a dependency.&lt;/p&gt;
&lt;h3&gt;Install Sharp&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;pnpm add sharp
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Move Images to Content Directory&lt;/h3&gt;
&lt;p&gt;Images now live alongside their posts in &lt;code&gt;src/content/blog/images/&lt;/code&gt; — the &lt;a href=&quot;https://docs.astro.build/en/guides/images/#images-in-content-collections&quot;&gt;Astro-recommended pattern&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;src/content/blog/
  images/
    obsidian-nuxt-workflow.jpeg
    obsidian-embed-test.svg
  obsidian-nuxt-content-workflow-example.md
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Update Content Schema&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;image()&lt;/code&gt; helper validates and imports images as metadata objects:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// src/content.config.ts
const blog = defineCollection({
  schema: ({ image }) =&amp;gt;
    z.object({
      // ... other fields
      image: image().optional(),
    }),
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Update Frontmatter&lt;/h3&gt;
&lt;p&gt;Relative paths from the content file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;image: ./images/obsidian-nuxt-workflow.jpeg
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Replace &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; with &lt;code&gt;&amp;lt;Image /&amp;gt;&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;---
import { Image } from &quot;astro:assets&quot;;
---

&amp;lt;Image
  src={post.data.image}
  alt={post.data.title}
  width={1200}
  height={630}
  class=&quot;mt-6 w-full rounded-xl object-cover&quot;
  loading=&quot;eager&quot;
  widths={[640, 828, 1200]}
  sizes=&quot;(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 1200px&quot;
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;OG Images&lt;/h3&gt;
&lt;p&gt;OG meta tags require URL strings. Use &lt;code&gt;getImage()&lt;/code&gt; to process the image:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
import { getImage } from &quot;astro:assets&quot;;

const ogImage = post.data.image
  ? await getImage({ src: post.data.image, width: 1200, height: 630 })
  : undefined;
---

&amp;lt;meta property=&quot;og:image&quot; content={ogImage?.src} /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Astro Config&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;// astro.config.ts
image: {
  layout: &quot;constrained&quot;,
  responsiveStyles: true,
},
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;layout: &quot;constrained&quot;&lt;/code&gt; setting enables automatic &lt;code&gt;srcset&lt;/code&gt; and &lt;code&gt;sizes&lt;/code&gt; generation for all &lt;code&gt;&amp;lt;Image /&amp;gt;&lt;/code&gt; components.&lt;/p&gt;
&lt;h2&gt;Results&lt;/h2&gt;
&lt;p&gt;Build output shows the optimization:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;generating optimized images
  ▶ /_astro/obsidian-nuxt-workflow.xxx.webp (before: 47kB, after: 14kB)
  ▶ /_astro/obsidian-nuxt-workflow.xxx.webp (before: 47kB, after: 20kB)
  ▶ /_astro/obsidian-nuxt-workflow.xxx.webp (before: 47kB, after: 31kB)
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;47kB → 14kB&lt;/strong&gt; (70% reduction) at smallest srcset&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;WebP format&lt;/strong&gt; generated automatically&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;3 responsive sizes&lt;/strong&gt; via &lt;code&gt;widths&lt;/code&gt; prop&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No CLS&lt;/strong&gt; — width/height inferred from image metadata&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;New Posts&lt;/h2&gt;
&lt;p&gt;For new blog posts, add images to &lt;code&gt;src/content/blog/images/&lt;/code&gt; and reference them in frontmatter:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
image: ./images/my-cover-photo.jpeg
---
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;normalize-obsidian&lt;/code&gt; script automatically converts Obsidian embed syntax to relative paths:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;![[photo.png]] → ![photo.png](./images/photo.png)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astro.build/en/guides/images/&quot;&gt;Astro Images Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astro.build/en/guides/images/#images-in-content-collections&quot;&gt;Content Collection Images&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astro.build/en/guides/images/#responsive-image-behavior&quot;&gt;Responsive Image Behavior&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://sharp.pixelplumbing.com/&quot;&gt;Sharp Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://web.dev/cls/&quot;&gt;Cumulative Layout Shift&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>astro</category><category>image-optimization</category><category>web-performance</category></item><item><title>Adding Accessibility Testing to My Astro Site</title><link>https://frangonf.com/blog/a11y-testing-astro</link><guid isPermaLink="true">https://frangonf.com/blog/a11y-testing-astro</guid><description>How I added axe-core and knip to catch accessibility issues, and fixed a contrast problem hiding in my CSS variables.</description><pubDate>Thu, 04 Jun 2026 11:11:39 GMT</pubDate><content:encoded>&lt;p&gt;Automated accessibility testing catches what manual review misses. I added &lt;a href=&quot;https://github.com/dequelabs/axe-core-npm&quot;&gt;axe-core&lt;/a&gt; and &lt;a href=&quot;https://github.com/webpro-nl/knip&quot;&gt;knip&lt;/a&gt; to my Astro project and found issues I didn&apos;t know existed.&lt;/p&gt;
&lt;h2&gt;The Tools&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;axe-core&lt;/strong&gt; runs WCAG 2.0/2.1 checks against your rendered pages via &lt;a href=&quot;https://github.com/dequelabs/axe-core-npm/blob/develop/packages/playwright/README.md&quot;&gt;Playwright&lt;/a&gt;. It catches contrast violations, missing labels, heading hierarchy issues — the kind of stuff screen readers care about.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;knip&lt;/strong&gt; scans your project for unused files, dependencies, and exports. It&apos;s not strictly a11y, but dead code is technical debt that makes accessibility harder to maintain. Knip has a built-in &lt;a href=&quot;https://knip.dev/&quot;&gt;Astro plugin&lt;/a&gt; that auto-activates when &lt;code&gt;astro&lt;/code&gt; is in your dependencies.&lt;/p&gt;
&lt;p&gt;Both run in CI with a single command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm test:a11y   # axe-core on all routes
pnpm lint:knip   # unused code detection
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The Surprise: CSS Variables Were Never Loaded&lt;/h2&gt;
&lt;p&gt;The first thing axe-core found wasn&apos;t a contrast issue — it was that my entire Tailwind config was dead code.&lt;/p&gt;
&lt;p&gt;I had a &lt;code&gt;src/assets/css/global.css&lt;/code&gt; with all my CSS variables, theme colors, and prose overrides. It existed. It was complete. It was never imported anywhere.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;@tailwindcss/vite&lt;/code&gt; plugin needs an entry point. Without an import, the CSS was never processed. My dark theme, my gruvbox palette, my custom properties — none of it was reaching the browser.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; One line in &lt;code&gt;BaseLayout.astro&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
import &quot;../assets/css/global.css&quot;;
// ...
---
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the kind of bug that&apos;s invisible if you&apos;re not testing. The site looked fine because Tailwind&apos;s utility classes still worked. But the CSS custom properties I&apos;d carefully defined? Gone.&lt;/p&gt;
&lt;h2&gt;Contrast Ratio: The Gruvbox Problem&lt;/h2&gt;
&lt;p&gt;Once the CSS loaded, axe-core found the real issue: contrast ratios.&lt;/p&gt;
&lt;p&gt;The gruvbox palette is designed for terminals, not WCAG compliance. My &lt;code&gt;--ui-text-dimmed&lt;/code&gt; color (&lt;code&gt;gruvbox-500: #928374&lt;/code&gt;) on the dark background (&lt;code&gt;gruvbox-950: #282828&lt;/code&gt;) had a contrast ratio of &lt;strong&gt;4.01:1&lt;/strong&gt; — just under the &lt;strong&gt;4.5:1&lt;/strong&gt; WCAG AA threshold.&lt;/p&gt;
&lt;p&gt;I found a &lt;a href=&quot;https://vm70.neocities.org/posts/2023-11-20-colorscheme-ga/&quot;&gt;research paper&lt;/a&gt; that used a genetic algorithm to find the closest WCAG-compliant alternatives to gruvbox colors. The algorithm found &lt;strong&gt;&lt;code&gt;#AB9E8F&lt;/code&gt;&lt;/strong&gt; as the nearest color to &lt;code&gt;#928374&lt;/code&gt; that passes 4.5:1. It preserves the warm, muted gruvbox aesthetic while meeting accessibility standards.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt; in &lt;code&gt;global.css&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;--color-gruvbox-500: #928374; /* 4.01:1 — fails */
--color-gruvbox-500: #ab9e8f; /* 4.5:1+ — passes */
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Opacity Classes vs CSS Variables&lt;/h2&gt;
&lt;p&gt;My Memento Mori component used Tailwind opacity classes (&lt;code&gt;opacity-50&lt;/code&gt;, &lt;code&gt;opacity-40&lt;/code&gt;, &lt;code&gt;opacity-30&lt;/code&gt;) on text elements. This created unpredictable contrast because opacity blends with whatever background is behind it.&lt;/p&gt;
&lt;p&gt;The fix was simple: stop using opacity for text dimming. The global CSS already defines a dimming scale:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--ui-text&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Primary text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--ui-text-muted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Secondary text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--ui-text-dimmed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dimmed text (WCAG compliant)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Replacing &lt;code&gt;opacity-50&lt;/code&gt; with &lt;code&gt;text-[var(--ui-text-dimmed)]&lt;/code&gt; gives consistent, predictable contrast across all pages.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;/mm&lt;/code&gt; route still uses opacity for visual depth (layered backgrounds, hover states). That&apos;s intentional — axe-core can&apos;t test interactive states, so we disable &lt;code&gt;color-contrast&lt;/code&gt; for that route only.&lt;/p&gt;
&lt;h2&gt;Results&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;6 routes&lt;/strong&gt; tested, all passing&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;knip&lt;/strong&gt; clean — no unused files or dependencies&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;1 real bug&lt;/strong&gt; found (CSS never loaded)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;1 contrast fix&lt;/strong&gt; (gruvbox palette)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;15 opacity classes&lt;/strong&gt; replaced with CSS variables&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Run everything with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm test:all   # vitest + playwright + knip
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Accessibility isn&apos;t a one-time fix. It&apos;s a test that runs every time you push.&lt;/p&gt;
</content:encoded><category>accessibility</category><category>astro</category><category>testing</category><category>a11y</category></item><item><title>Obsidian Embed Test</title><link>https://frangonf.com/blog/obsidian-embed-test</link><guid isPermaLink="true">https://frangonf.com/blog/obsidian-embed-test</guid><description>Tiny post to verify Obsidian ![[...]] image embeds get transformed to standard Markdown</description><pubDate>Mon, 19 Jan 2026 01:09:02 GMT</pubDate><content:encoded>&lt;p&gt;This image was transformed from Obsidian syntax:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./images/obsidian-embed-test.svg&quot; alt=&quot;Obsidian embed works&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Standard Markdown image syntax also works:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./images/obsidian-embed-test.svg&quot; alt=&quot;Standard markdown works too&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Code blocks should NOT be transformed&lt;/h2&gt;
&lt;p&gt;Inline code: &lt;code&gt;![[should-stay.png]]&lt;/code&gt; stays as-is.&lt;/p&gt;
&lt;p&gt;Fenced code block:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;![[also-stays.png|Alt text here]]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These demonstrate that the normalization script preserves code examples.&lt;/p&gt;
</content:encoded></item><item><title>Using Obsidian with Astro Content Collections</title><link>https://frangonf.com/blog/obsidian-nuxt-content-workflow-example</link><guid isPermaLink="true">https://frangonf.com/blog/obsidian-nuxt-content-workflow-example</guid><description>How to write blog posts in Obsidian and publish them with Astro&apos;s content collections, including wikilinks, image embeds, and frontmatter validation.</description><pubDate>Wed, 08 Oct 2025 00:31:30 GMT</pubDate><content:encoded>&lt;h2&gt;Why Obsidian for Astro?&lt;/h2&gt;
&lt;p&gt;Obsidian is a markdown editor that treats notes as an interconnected graph. Astro&apos;s content collections validate and build those markdown files into static pages. The combination gives you a local-first writing experience with type-safe content.&lt;/p&gt;
&lt;h2&gt;Content Structure&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;src/content/blog/
  images/
    my-post-cover.jpeg
  my-post.md
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Images live alongside their posts in &lt;code&gt;src/content/blog/images/&lt;/code&gt;. The &lt;code&gt;image()&lt;/code&gt; schema helper resolves paths relative to the content file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
title: &quot;My Post&quot;
image: ./images/my-post-cover.jpeg
---
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Frontmatter Schema&lt;/h2&gt;
&lt;p&gt;Astro validates frontmatter against a Zod schema in &lt;code&gt;src/content.config.ts&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const blog = defineCollection({
  schema: ({ image }) =&amp;gt;
    z.object({
      title: z.string(),
      description: z.string(),
      date: z.coerce.date(),
      draft: z.boolean().default(false),
      tags: z.array(z.string()).default([]),
      category: z.string().optional(),
      image: image().optional(),
      series: z
        .object({
          name: z.string(),
          order: z.number(),
        })
        .optional(),
    }),
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Obsidian Syntax Transformation&lt;/h2&gt;
&lt;p&gt;Obsidian uses &lt;code&gt;![[image.png]]&lt;/code&gt; for image embeds. A normalization script converts these to standard Markdown:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pnpm normalize:obsidian
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Transforms:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;![[photo.png]]&lt;/code&gt; → &lt;code&gt;![photo.png](./images/photo.png)&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;![[photo.png|Alt text]]&lt;/code&gt; → &lt;code&gt;![Alt text](./images/photo.png)&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Code blocks are preserved — &lt;code&gt;![[example.png]]&lt;/code&gt; inside backticks stays as-is.&lt;/p&gt;
&lt;h2&gt;Tags and Categories&lt;/h2&gt;
&lt;p&gt;Tags are arrays in frontmatter, rendered as clickable links:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tags:
  - astro
  - markdown
  - tutorial
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Categories link to &lt;code&gt;/categories/{name}&lt;/code&gt;, tags link to &lt;code&gt;/tags/{name}&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Series&lt;/h2&gt;
&lt;p&gt;Group related posts with a series object:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;series:
  name: Content Management Series
  order: 1
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Writing Workflow&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Write in Obsidian with wikilinks and image embeds&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;pnpm normalize:obsidian&lt;/code&gt; to convert syntax&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;draft: true&lt;/code&gt; while iterating&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;draft: false&lt;/code&gt; when ready to publish&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astro.build/en/guides/content-collections/&quot;&gt;Astro Content Collections&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.astro.build/en/guides/images/#images-in-content-collections&quot;&gt;Astro Image Helper&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://obsidian.md/&quot;&gt;Obsidian&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded><category>obsidian</category><category>astro</category><category>content-management</category><category>markdown</category></item></channel></rss>