# Prompt: full code audit of an Astro + Sanity site

Reusable template for running a technical QA pass on any Astro project with a
headless CMS. Meant to be pasted into Claude Code (or any agent with repo and
terminal access) at the end of a migration or before a launch.

**How to use it:** copy everything below the line, replace the `<>` values in
the context block, and paste. Nothing else needs changing.

It is written from a real audit of 216 pages that surfaced 22 problems neither
TypeScript nor the build detected. Every check on the list corresponds to
something that was actually wrong on a site that looked finished — this is not
a theoretical list of best practices.

---

## PROJECT CONTEXT

- Repo: `<absolute path>`
- Framework: Astro `<version>`, output `<static|server|hybrid>`
- CMS: `<Sanity | other>`, dataset `<name>`
- Languages: `<en/es | single>`. Routing strategy: `<'/es' prefix | domains | none>`
- Target hosting: `<Cloudflare Pages | Vercel | Netlify | other>`
- Third-party services the site loads: `<image CDN, video, forms, embeds, analytics>`
- Commands: build `<pnpm build>`, typecheck `<pnpm check>`, dev `<pnpm dev>`

## GOAL

Audit the code across seven areas — bugs, accessibility, performance, SEO,
security, duplication, and technical debt — and deliver a concrete report:
what you found, with what evidence, and what you propose.

**Do not apply anything yet.** Report first; I will decide the scope.

## WORKING RULES

1. **Measure, do not assume.** Every finding needs a number, the command that
   produced it, or a `file:line`. "There might be a performance issue" is not a
   finding; "the build takes 6m36s at 2% CPU because the shell fires 1,400
   identical queries" is.
2. **A green build proves nothing.** The most expensive bugs are valid code
   producing wrong output. Audit the generated HTML, not just the source.
3. **Separate by severity.** Distinguish what is broken now, what degrades
   metrics, and what is technical debt. Do not merge them into one list.
4. **No content changes without approval.** If a fix means touching visible text
   — copy, titles, meta descriptions — propose it in a before/after table and
   wait for sign-off. Code can be fixed directly once the scope is approved.
5. **If there is a design system, use it.** Do not invent typography, color, or
   spacing variables. Find the closest existing token; if there genuinely is
   none, say so before creating one.
6. **Report what you decided NOT to do, and why.** A documented pending item
   with its reasoning is worth more than a rushed fix.

---

## PHASE 0 — Inventory (before forming opinions)

```bash
find src -type f | sort            # actual structure
wc -l src/**/*.astro | sort -n     # where the weight is
cat astro.config.mjs package.json
```

Note: page count, dynamic routes, components, and what is generated at build
time versus resolved on the client.

Run the typecheck and the build **with timing**, and save `dist/` as a baseline
to compare against later:

```bash
time pnpm build
cp -R dist /tmp/baseline
```

---

## AREA 1 — Bugs the compiler cannot see

### 1.1 Broken links and assets

`href` and `src` are strings: nothing validates them. Walk `dist/` and check
every internal destination against the pages and files actually generated.

```python
# for each .html in dist: extract href="/..." and src="/..."
# check against the set of generated routes + the set of files
# report destination + how many pages reference it
```

> In the real audit this found the favicon pointing at a nonexistent file. It
> was in the base layout: **404 across all 215 pages**.

### 1.2 Heading hierarchy

```bash
# count <h1> per page in dist — should be exactly 1
```

If content comes from a CMS with migrated rich text, check **which block styles
the renderer maps**. Any unmapped style falls through to the default renderer:
it comes out without design system classes and, if it is an `h1`, competes with
the page title.

> Twelve articles had up to **seven `<h1>`**, one of them duplicating the title.
> Plus 17 `h4` and 2 tables rendering with browser-default typography.

### 1.3 Duplicated sources of truth

Look for dictionaries, route maps, or config lists that exist in more than one
file. Pay particular attention to any carrying a comment like "this must mirror
X": **that comment is the evidence they have already drifted, or are about to.**

> An EN→ES route map lived in both `astro.config.mjs` and `lib/sanity.ts`, with
> the comment in place. When the blog was added only one was updated: **92 of
> 214 sitemap URLs shipped with no `hreflang`.**

### 1.4 CMS content injected without escaping

Review every `set:html`. Values interpolated into attributes (`alt`, `href`,
`title`) must be escaped: `&`, `"`, `<`, `>`.

If inline SVGs are processed by code, verify internal references are not broken
(`fill="url(#gradient)"`, `clip-path`). A renamed or stripped `id` leaves the
element black or invisible, with no error.

---

## AREA 2 — Performance

### 2.1 CMS query amplification

**The highest-return check on a static site.** Count how many times the CMS is
queried per rendered page:

```bash
grep -rn "await get\|sanity.fetch" src/components src/layouts
```

Anything in the layout, navbar, footer, or shared sections runs **once per
page**. On a 200-page site, six shell queries are 1,200 requests returning the
same thing.

The unmistakable signal is a slow build at low CPU: it is not compiling, it is
waiting.

```bash
time pnpm build   # single-digit %cpu means network
```

The fix, for parameterless queries:

```ts
function once<T>(fn: () => Promise<T>): () => Promise<T> {
  let cached: Promise<T> | undefined;
  return () => (cached ??= fn());   // the PROMISE, not the result
}
```

Caching the promise rather than the value lets two parallel renders share the
in-flight request. The cache lives in the module: every build starts clean, so
there is no risk of serving stale content.

> Real result: **6m36s → 1m38s**. From ~1,850 ms to 4 ms per page.

### 2.2 Images without dimensions

```bash
# count <img> in dist; how many have both width AND height
```

Without dimensions there is layout shift on every image. For local files,
measure them. For CMS images, many encode the size in the asset identifier —
Sanity does: `image-<hash>-1440x833-jpg` — so it can be derived with no extra
query:

```ts
const m = ref?.match(/-(\d+)x(\d+)-[a-z]+$/i);
// height = requested_width * (h / w)
```

### 2.3 Asset weight and format

```bash
du -sh public/* | sort -h
# any JPG/PNG left where the rest of the site already uses AVIF/WebP?
```

Convert at the same resolution: the savings come from the codec, and nothing
can end up looking different.

### 2.4 Unreferenced assets

Compare files in `public/` against those referenced in `src/`, CSS, and JS.
Visual-builder exports leave behind responsive variants that are never used if
the markup has no `srcset`.

> 48 orphaned files, **2.55 MB**. `public/images` went from 7.9 to 3.2 MB.

### 2.5 Third parties in `<head>`

For every external script: is the version pinned? does it block rendering? does
it have SRI?

Pay special attention to **core + plugin pairs on different versions**, and to
URLs with no version (`unpkg.com/<package>` always serves the latest: it can
change in production without anyone touching the repo).

> The site had GSAP core 3.15.0 with its ScrollTrigger plugin at 3.14.2 — a
> combination the library itself does not support — plus one unpinned package,
> all three blocking first render.

---

## AREA 3 — Accessibility

### 3.1 Controls that are not controls

```bash
grep -rn "addEventListener(\"click\"" public/js src
```

For every click handler: is the element a `<button>` or `<a>`? If it is a
`<div>` or `<span>`, it cannot be used with a keyboard and screen readers will
not announce it.

It needs `role="button"`, `tabindex="0"`, `Enter` and `Space` handling, and — if
it opens or closes something — `aria-expanded` + `aria-controls`.

> The FAQ accordions were bare `<div>` elements on **eight pages**, including
> the home page. The same project already had the correct pattern in its
> megamenu: solved once and never replicated. **Always check whether the correct
> pattern already exists in the repo before writing it again.**

### 3.2 Visible focus

If you add controls with `tabindex`, they need their own `:focus-visible`: the
browser default outline is invisible on dark backgrounds.

### 3.3 Labels and grouping

- `<img>` without `alt`
- `<label>` without `for`, or labeling a group instead of a control
  (a checkbox group takes `<fieldset>` + `<legend>`)
- Form fields with no accessible name

---

## AREA 4 — SEO

```bash
# over dist: per page, length of <title> and meta description,
# canonical, hreflang, and <h1> count
```

- **`<title>` over 60 characters** and **description over 160**: Google
  truncates. Check that the cut does not swallow the term the page competes for.
- **Descriptions falling back to another field.** If the template does
  `metaDescription ?? heroDescription`, pages with no dedicated meta drag in
  long page copy. Verify against the HTML, not the CMS.
- **Sitemap**: does every URL have its language pair? is the canonical unique?
- **Visible text versus SEO text**: if the CMS has a separate SEO title field,
  use it — never shorten the title people read for a search-engine reason.

> 80 titles and 85 descriptions were over the limit. Services had no dedicated
> meta and fell back to the hero description, which is page copy.

---

## AREA 5 — Security

### 5.1 Secrets

```bash
git ls-files | grep -iE "\.env$|\.env\."      # only .example should show up
git grep -lIE "sk[-_]|api[_-]?key\s*="        # review false positives
```

Also verify which variables end up in the client bundle: only the ones the
framework marks as public, and none of those genuinely sensitive.

### 5.2 Headers

`X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options`,
`Permissions-Policy`. These are response headers: they change neither the HTML
nor the rendering.

**CSP: ship it in `Report-Only` first.** If the site loads from several origins
and the list is incomplete, resources fail **silently** — the image does not
appear, the form does not submit — and you hear about it from a client. Two
weeks of real traffic, review the report, and only then switch to blocking mode.

**HSTS: do not enable it before the final domain.** Once a browser caches the
header it forces HTTPS for the whole `max-age` and **cannot be reverted from the
server**. Leave it commented out with the reasoning written down.

### 5.3 Paid assets served from a CDN

If there is video, audio, or downloads on a CDN that bills for bandwidth, check
whether they are open:

```bash
curl -sI "<asset url>"      # 200 with no Referer or token?
```

**A CSP does not protect this**: it only applies inside your pages and cannot
stop someone using the URL elsewhere.

What does protect it, in order of effort:

1. **Referrer allowlist** plus blocking direct access. Immediate and free.
   Details that cost time if you do not know them: matching is usually by
   **exact hostname including the port**, and `localhost` is not accepted as a
   valid referrer — so enabling it **breaks local development** until you point
   a real hostname at `127.0.0.1` and add it to the list.
2. **Monthly bandwidth cap.** This is the real spending ceiling — billing
   auto-recharge is the opposite: it tops up balance, it does not limit.
   Calculate it against the most expensive rate among your markets. Pair it with
   per-IP limits, which stop an abuser without taking the service down for
   everyone.
3. **Token-signed URLs.** The strong protection. On a static site you have to
   decide where signing happens: at build time with a long expiry, or on demand
   in an edge function.

Before optimizing weight "to save money", **measure how much it is**. CDNs
typically send `cache-control` measured in weeks, so local development downloads
each file once, not once per reload.

---

## AREA 6 — Duplication

### 6.1 Mirrored pages per language

```bash
# per pair: total lines and how many differ
diff <(sed 's/[[:space:]]*$//' EN) <(sed 's/[[:space:]]*$//' ES) | grep -c "^<"
```

If 80% is identical, these are not two pages: one is a copy. **Every fix has to
be made twice, and forgetting one leaves a language behind.**

The fix is a component taking `locale` plus two minimal routes. Prioritize by
generated instances: a detail template producing 30 pages returns more than a
hub producing one.

> 13 pairs, **8,422 lines across 26 files**, ~3,400 of pure duplication.

### 6.2 Hand-repeated markup

```bash
grep -rc "<suspicious-class" src | grep -v ":0"
```

Decorative elements copied with inline styles are the classic case.

> 125 star `<div>` elements with their own `rgba()` and pixel values, spread
> across 11 files. Twelve unique elements repeated ten times over.

### 6.3 Design system tokens written by hand

```bash
# CSS variables USED minus variables DEFINED = orphans
```

Also look for literal values that already exist as a token.

> `var(--color--menta, #3bbfad)` where that variable is **defined in no file at
> all**: the fallback always wins. It is a hardcoded color in disguise, and the
> disguise is the problem — a loose hex gets noticed in review, this does not.

---

## AREA 7 — The verification method

**This is the part with the highest return, and the one almost nobody does.**

If a refactor should not change the result, prove it: save `dist/` before,
rebuild, and compare. Not the raw files — formatting shifts for irrelevant
reasons — but **the visible text with tags stripped, plus every `href`/`src`**,
normalizing upfront the differences that are intentional.

```bash
for f in $(cd dist && find . -name "*.html"); do
  diff <(normalize "/tmp/baseline/$f") <(normalize "dist/$f") || echo "DIFFERS: $f"
done
```

The target is **0 pages with differences**. Any other result is a finding, not
noise to ignore.

> On an 8,000-line refactor with `astro check` at 0 errors and the build
> generating all 216 pages, this found four bugs:
>
> 1. **Spanish links pointing at English routes.** The extractor compared text
>    nodes; an `href` inside a JS expression is not one.
> 2. **A component turned into a string.** `<CtaSection />` stored as text and
>    injected with `set:html`: the browser sees an unknown element and the
>    section **disappears from the page** with no error at all.
> 3. **An overwritten meta description.** Two strings starting the same way
>    generated the same dictionary key and the second clobbered the first.
> 4. **Nine pages with untranslated frontmatter.** Alignment compared line
>    counts, and the English file's comments were longer: when they did not
>    match, it discarded and kept the English.
>
> It also surfaced two bugs that **already existed before the refactor** and
> that nobody had seen.

**When you find a bug like this, fix the failure mode too.** The specific case,
and the validation that stops it from recurring silently:

```python
if skeleton_en != skeleton_es:
    sys.exit("ERROR: an uncaptured difference remains")
```

A tool that never breaks and sometimes emits the wrong thing is worse than one
that aborts. **Failing loudly beats working quietly.**

---

## REPORT FORMAT

Deliver this, in this order:

1. **Overall verdict.** What is fine and should not be touched. With numbers.
2. **Findings table by severity**: broken now, degrading metrics, technical debt.
3. **Per finding**: what it is, the evidence (command, number, or `file:line`),
   the concrete impact, and the proposed fix.
4. **Content changes**, separately and in a before/after table. Do not apply them
   without approval.
5. **What you recommend NOT doing yet**, with the reasoning. Especially CSP,
   HSTS, and anything depending on real traffic.
6. **A phased plan**, ordered by impact over effort, so it can be reviewed and
   merged in parts rather than all at once.

When applying: one phase per commit, with the reasoning in the message — not
just the what — and `pnpm check`, `pnpm build`, and the `dist/` comparison all
green before each one.
