Files
blog/CLAUDE.md
Gabriel Vidal a946203283 feat(content): require description + cover in the blog frontmatter schema
Every post gets a non-empty description (used by search, RSS and
og:description) and a sane cover image, enforced by the zod schema so a
future post missing either fails npm run build. All 27 existing posts
already had both fields — verified with a negative test (temporarily
stripping cover from one post reproduces the build failure).

Ticks the 'Frontmatter completeness' wishlist item in GOAL.md and
releases the goal-keeper claim.
2026-08-10 03:29:21 +02:00

8.1 KiB

CLAUDE.md — blog

Guidance for Claude Code working in this repo. See README.md for the human-facing version, and GOAL.md for where the site is going — read it before proposing features.

What this is

My personal website / blog, live at https://blog.dev.gabvdl.xyz. Astro 2 (based on the astro-ink theme): markdown/MDX posts, client-side Lunr search, tags, RSS, sitemap. Static build → dist/, served by zipgo on raspy2.

origin is the self-hosted Gitea (git.gabvdl.xyz/gabrielvidal/blog) — that is canonical. github is a secondary mirror of the old GabrielVidal1/gabrielvidal1.github.io repo; nothing depends on it. There is no CI: nothing publishes on push, only npm run deploy.

The look: "blueprint"

One design, no switcher. The site is a cyanotype engineering drawing — white line-work on print blue, graph-paper ground, registration ticks on cards, FIG. numbering, a drawing title block for a footer. It lives in two files, and the split is load-bearing:

  • src/styles/base.cssstructure only. Every rule reads design tokens (--bg, --accent, --font-display, …); nothing hard-codes a colour. This is also where all the responsive work is.
  • src/styles/blueprint.cssidentity only: it fills those tokens in and adds the drawing flourishes.

Two gotchas if you touch them:

  • The token block is html:root, not html. base.css declares its fallbacks on :root, and a pseudo-class outranks a type selector regardless of order — a bare html {} silently loses and the whole site renders in the fallback white/pink palette.
  • Design rules keep the html prefix so a blueprint.css rule always outweighs the single-class structural rule it refines.

Animation islands (src/components/islands/)

Text and lists animate with @gabvdl/ui's progressive family. They are React islands, and the pattern in islands/hydrated.ts is what keeps that safe:

  • useHydrated() flips in a layout effect, so the server (and the hydration render) emit the finished content — real headings, a full card list — and the animation only takes over on the client, before first paint. The HTML a crawler or a JS-less reader receives is complete, and nothing flashes.
  • TypedText additionally keeps the finished string in the flow as a hidden ghost and paints the typewriter over it (.typed/.typed__ghost/.typed__live in base.css). Without that, a tagline growing from one line to four shoves the page down while it types.

Import islands by the same specifier everywheresrc/components/islands/X.tsx, with the extension. Mixing a relative (./islands/X) and an aliased import of the same island makes Astro 2's client build fail at generate time with Cannot find the built path for ….

ArticleExtras (loaded on every post) works off the rendered DOM, not the frontmatter: it wires every article image into the full-screen viewer, adds a "play the figures as a story" control to any post with more than one image, and portals a copy button into every code block. So posts get all of this for free — there is nothing to add to a markdown file.

@gabvdl/ui is pinned to a version that only exists on the local Verdaccio (registry.lab / 127.0.0.1:4873, see the homelab CLAUDE.md); public npm stops at 0.19.0. npm install therefore needs the registry up — npm run build does not, it just uses node_modules.

Anything pointing off-site opens in a new tab and carries the glyph. The rule lives in one place per surface:

  • components (nav, hero, footer, link buttons) — class="external-link" plus target/rel, decided by isExternal() in src/utils/links.ts;
  • posts and pages — the rehypeExternalLinks pass in astro.config.mjs, at build time so it survives with JS off.

That pass handles three shapes, and all three occur in this repo: markdown link syntax (a hast element), raw <a> HTML inside a .mdx file (already an MDX JSX node by then), and raw <a> HTML inside a .md file (still an unparsed raw string). Adding a case only to the first is the easy mistake — most of the older posts write raw HTML.

Writing a post

One file: src/content/blog/<YYYY-MM-DD>-<Title>.md (or .mdx). Frontmatter is validated by the zod schema in src/content/config.tstitle (≤100 chars), tags (required array), description and cover (both required, non-empty — a missing one fails the build), plus optional techs / date / project / links. Drafts are plain markdown in src/drafts/.

Images go in public/assets/posts/<PostName>/ and are referenced with raw HTML:

<img src="/assets/posts/<PostName>/shot.jpeg" alt="…" class="w-full rounded-lg shadow-lg">

⚠️ AI-generated posts MUST carry the disclaimer

Any post you generate gets an AI-disclosure callout as the first thing in the body, above the opening paragraph. This is not optional and not something to ask about — if you wrote the post, the callout ships with it. Only a post I wrote myself may omit it.

Paste this block verbatim at the top of the post body (adjust only the model name and, if the sourcing genuinely differs, the second sentence):

<aside class="not-prose my-8 flex items-start gap-4 rounded-lg border border-theme-primary/30 bg-theme-primary/5 p-5 dark:border-theme-dark-primary/40 dark:bg-theme-dark-primary/10">
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="mt-0.5 h-7 w-7 shrink-0 text-theme-primary dark:text-theme-dark-primary">
    <path d="M12 2v4"/>
    <rect x="3" y="6" width="18" height="14" rx="3"/>
    <circle cx="8.5" cy="12" r="1.4" fill="currentColor" stroke="none"/>
    <circle cx="15.5" cy="12" r="1.4" fill="currentColor" stroke="none"/>
    <path d="M9 16.5h6"/>
    <path d="M1.5 11v4M22.5 11v4"/>
  </svg>
  <div class="text-sm leading-relaxed">
    <p class="font-bold text-theme-primary dark:text-theme-dark-primary">AI-generated article</p>
    <p class="mt-1 text-gray-700 dark:text-gray-300">
      This post was written by <strong>Claude Opus 5</strong>, working from my own
      projects, tests, benchmarks, memories and past conversations. The
      measurements, code and screenshots come from my hardware and my repositories —
      the writing is the model's.
    </p>
  </div>
</aside>

2026-08-06-Trellis2-Strix-Halo.md is the reference example.

Alongside the callout: don't invent numbers. Benchmarks, timings and code snippets in a generated post must come from a real run or a real file — measure or read it, don't estimate and round.

Gotchas

  • Tailwind scans markdown. tailwind.config.cjs includes .md/.mdx in content on purpose. Before that, a class used only in a post was emitted only by luck (if some .astro file happened to use it too), so callouts and image classes silently rendered unstyled. Don't narrow that glob.
  • Dark mode is class-based. Every custom colour needs a dark: counterpart — use the theme tokens (theme-primary / theme-dark-primary), not raw palette colours, so the site's theme switcher keeps working.
  • The search index is a postbuild step (scripts/search/prepare-index.js), which runs after Astro copies public/dist/. It writes public/ and mirrors into dist/; that mirror is load-bearing. Without it every deploy ships a search index one post behind. scripts/deploy.sh refuses to deploy if dist/search-index.json is missing.
  • npm run deploy skips og-screenshot unlike the scaffolded homelab projects — this site has a curated public/og-image.png and a screenshot of the index would be strictly worse.

Verify before deploying

npm run build                    # must pass; check the post renders
npx astro preview                # or serve dist/ and look at the page
npm run deploy                   # build + zipgo deploy to raspy2

For a generated post, actually look at the rendered page (screenshot it) — check the callout, the images, tables and code blocks in both light and dark mode before deploying.