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.
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.css— structure 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.css— identity only: it fills those tokens in and adds the drawing flourishes.
Two gotchas if you touch them:
- The token block is
html:root, nothtml.base.cssdeclares its fallbacks on:root, and a pseudo-class outranks a type selector regardless of order — a barehtml {}silently loses and the whole site renders in the fallback white/pink palette. - Design rules keep the
htmlprefix so ablueprint.cssrule 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.TypedTextadditionally keeps the finished string in the flow as a hidden ghost and paints the typewriter over it (.typed/.typed__ghost/.typed__livein 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 everywhere — src/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.
External links
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"plustarget/rel, decided byisExternal()insrc/utils/links.ts; - posts and pages — the
rehypeExternalLinkspass inastro.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.ts — title (≤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.cjsincludes.md/.mdxincontenton purpose. Before that, a class used only in a post was emitted only by luck (if some.astrofile 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 adark: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
postbuildstep (scripts/search/prepare-index.js), which runs after Astro copiespublic/→dist/. It writespublic/and mirrors intodist/; that mirror is load-bearing. Without it every deploy ships a search index one post behind.scripts/deploy.shrefuses to deploy ifdist/search-index.jsonis missing. npm run deployskips og-screenshot unlike the scaffolded homelab projects — this site has a curatedpublic/og-image.pngand 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.