| name | astro-static-sites |
| description | Build, review, and extend Astro static sites — config, integrations, SEO, deployment to GitHub Pages. |
| version | 1.20.0 |
| tags | ["astro","static-site","github-pages","seo","deployment","css"] |
| tool_agnostic | true |
| authors | ["Anders Hybertz"] |
| tested_on | [] |
Astro static sites
Use when working on an Astro project: reviewing, extending, debugging, or setting up common integrations like sitemap, OG tags, and GitHub Pages deployment.
Typical project shape
src/layouts/Layout.astro — shared page shell (head, nav, footer). All SEO meta goes here.
src/pages/*.astro — route-based pages. Pages own their content; the layout owns the shell.
src/styles/global.css — design tokens and shared components.
public/ — static assets copied verbatim; CNAME lives here for GitHub Pages custom domains.
astro.config.mjs — must set site: for OG URLs, canonical links, and sitemap to resolve correctly.
.github/workflows/deploy.yaml — build + upload-pages-artifact + deploy-pages pattern.
Resource hints (Layout.astro)
For any third-party font or asset host, pair dns-prefetch with preconnect. The dns-prefetch acts as a fallback for browsers without preconnect support and costs nothing:
<link rel="dns-prefetch" href="//fonts.bunny.net" />
<link rel="preconnect" href="https://fonts.bunny.net" crossorigin />
The crossorigin attribute is required on preconnect for any CORS resource (fonts, cross-origin scripts, cross-origin API calls). Without it, the browser performs the preconnect but then opens a second connection when the resource actually fires — defeating the purpose entirely. This fails silently; no console error, no warning.
Order: place dns-prefetch before preconnect. Place both before the font stylesheet <link>.
SEO baseline (Layout.astro)
Every Layout.astro should carry:
<meta name="description"> — already standard
<link rel="canonical"> — derive from new URL(Astro.url.pathname, Astro.site)
- Open Graph:
og:type, og:url, og:title, og:description, og:image, og:locale, og:site_name
- Twitter/X Card:
twitter:card (summary_large_image), twitter:title, twitter:description, twitter:image
ogImage prop on Layout with a sensible default (e.g. /og-default.png); individual pages can override
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> — place before font preconnects. Text-based favicons (<text> element in SVG) collapse unreadably at 16px tab size; use drawn SVG polyline or path geometry instead.
- Skip link — add a visually-hidden skip link before
<header> and a matching id="main-content" on <main>. Low cost, real keyboard usability win, and a signal to accessibility auditors that the site was built intentionally. Show-on-focus pattern is a single CSS rule.
- JSON-LD structured data — a
<script type="application/ld+json"> block in <head> with Person + Organization schema is appropriate for any solo consultancy site. Low cost, improves rich result eligibility, and works well with the llms.txt strategy. Use a @graph array so both types are in one script tag.
og:locale — must be a valid OGP locale (language_TERRITORY). en_GB for English content from Denmark. en_DK is not a valid OGP value — it passes the build silently but is wrong.
See templates/layout-head.astro for a ready-to-copy head block.
Sitemap
Install: npm install @astrojs/sitemap
Wire into config:
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://example.com',
integrations: [sitemap()],
});
Outputs sitemap-index.xml and sitemap-0.xml into dist/ on every build. No further config needed for a simple static site. Never commit these files — they are regenerated automatically and belong only in dist/.
astro.config.mjs baseline
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://yourdomain.com',
output: 'static',
trailingSlash: 'always',
integrations: [sitemap()],
});
trailingSlash: 'always' keeps URLs consistent with GitHub Pages behaviour and avoids redirect chains.
GitHub Pages deployment pattern
permissions:
contents: read
id-token: write
pages: write
jobs:
build:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm run build
- uses: actions/upload-pages-artifact@v3
with: { path: ./dist }
deploy:
needs: build
environment: { name: github-pages, url: '${{ steps.deployment.outputs.page_url }}' }
steps:
- uses: actions/deploy-pages@v4
id: deployment
concurrency.cancel-in-progress: false on the pages group prevents a mid-flight deploy from being cancelled by a fast follow-up push.
Node.js 24 Actions runner: to suppress the Node 20 deprecation warning from the Actions runner itself, add this to both build and deploy jobs:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
Both jobs need it independently — the runner machinery is per-job, not inherited from a top-level env block. This is an official GitHub opt-in.
Common review signals
- Missing
site: in astro.config.mjs → OG URLs and sitemap will be broken or absent
- Page-local
<style> blocks that duplicate patterns already in global.css → maintenance split
- Inline
style="" attributes used more than once → belong in classes
- After any CSS cleanup pass, run
grep -rn 'style="' src/pages/ src/layouts/ — any hit that is not a dynamic binding (style={...}) is a candidate for extraction. Target state: zero inline style="" attributes. Every spacing, size, and color decision should live in a named CSS class. Decision rule: >1 page or shared layout concern → global utility; single page recurring element → page-local semantic class; true one-off with a non-obvious value → keep but add a comment.
- No OG/Twitter meta → unfurled links on LinkedIn/Slack show nothing
- Dead CSS in global.css from superseded layouts → run the detection pattern below
- Hardcoded color values in page-local
<style> blocks — scan for rgba(, #[0-9a-fA-F] and replace with CSS custom properties
- Class names that survive a redesign but describe the old context — e.g.
.repo-name repurposed for service area card titles after GitHub section was removed. The class still renders, so it won't appear dead, but it carries wrong semantics and can mislead font/style audits. When auditing, check class names against what they actually render, not just whether they're referenced.
- Hugo migration artifacts left in repo → see pitfalls section
CSS consolidation — full audit workflow
When a site has accumulated CSS drift across page-local <style> blocks, inline styles, and a shared stylesheet, work through these phases in order. Skipping phases causes regressions or misses the real source of drift.
Phase 1 — Read everything before touching anything
Map the full surface before making a single change:
- The shared stylesheet (global.css or equivalent) — read top to bottom, note every section
- The shared layout component — nav, footer, document head
- Every page component — both script/frontmatter and template
What to note: class names defined only in page-local style blocks, style="" attributes in markup, hardcoded values (px, hex, rgba) that duplicate design tokens, and class names that appear in the stylesheet but may not appear in any markup.
Do not patch during this pass. The full picture changes what is worth fixing and what order to fix it in.
Phase 2 — Remove dead CSS first
A dead rule is a selector that no longer appears in any markup file. Removing dead rules before consolidating prevents you from accidentally folding dead code into a shared base class.
grep -r 'class-name' src/ | grep -v 'global.css'
Dead CSS accumulates predictably after redesigns: layout sections replaced but their styles left behind, features removed but their classes not cleaned up, experimental components that never shipped. Check for the inverse too — classes present in markup but defined nowhere. A missing style is silent on light backgrounds and invisible on dark ones.
Phase 3 — Extract inline styles to named classes
grep -rn 'style="' src/pages/ src/layouts/
Every static style="" attribute (not a dynamic binding like style={expr}) belongs in a named class. The pattern that accumulates: layout utilities applied inline on individual elements — flex containers, spacing overrides, heading size adjustments — that are identical or near-identical across multiple pages.
Target state: zero static style="" attributes. Every spacing, size, and colour decision lives in a named CSS class with a name that describes its purpose.
Phase 4 — Replace hardcoded values with design tokens
Scan for raw values that duplicate tokens already defined in :root:
grep -n 'border-radius: [0-9]' src/styles/global.css src/pages/*.astro src/layouts/*.astro
grep -n 'rgba\|#[0-9a-fA-F]\{3,6\}' src/pages/*.astro src/layouts/*.astro
Common offenders: hardcoded border-radius values that match or derive from a radius token, hardcoded colour values in page-local blocks that duplicate a text, border, or surface token.
One trap: overflow: hidden + border-radius in wrapper/clip elements. These are not visually card-shaped so they get missed in a card-focused audit. Extend the radius scan to any selector containing overflow: hidden.
Phase 5 — Consolidate duplicate chrome into shared base classes
With dead rules removed and tokens in place, duplicate chrome becomes legible. The typical pattern: several visually identical component types — cards, list items, grid cells — each defining the same background, border, radius, shadow, and transition independently. A single design change (hover shadow, border colour) requires finding and updating every copy.
Fix: identify the shared base and extract it to a single multi-selector rule in the shared stylesheet. Page-local classes then hold only the differential layout. See the card chrome pattern and label/caps pattern sections below for the specific consolidation approach.
Phase 6 — Commit each logical change separately
One consolidation pass per commit. Dead CSS removal is one commit. Inline style extraction is another. Token replacement is another. This makes regressions trivially isolatable — a visual break points to exactly one change, not a bundled diff of 200 lines.
After each commit: npm run build must be clean, npm run lint must be 0 errors, and a visual spot-check against a preview server catches any unstyled elements. Dark-background sections are the most likely failure point — unstyled text and links are invisible there.
Phase 7 — Closing quality check
Run a CSS quality tool (e.g. Project Wallace) against the deployed or preview URL after consolidation is complete. Scores are directional — most findings on a token-driven static site are expected and not actionable. Assess each finding individually. See the CSS quality audit section for interpretation.
CSS consolidation — card chrome pattern
Astro sites accumulate duplicate card chrome across page-local <style> blocks. The typical set: .card, .bento-card, .strength-item, .award-item, .svc-card, .featured-card — all sharing the same background: var(--surface); border: 1px solid var(--border); border-radius: ...; padding: ...; box-shadow: ...; transition: .... Changing hover shadow or border-radius means finding six definitions.
Fix: define a shared selector rule in global.css for the classes that are always identical:
.card,
.bento-card,
.strength-item,
.award-item {
background: var(--surface);
border: 1px solid var(--border);
border-radius: calc(var(--radius) * 1.5);
box-shadow: var(--shadow-sm);
transition: box-shadow 0.2s ease, border-color 0.2s ease;
}
.card:hover,
.bento-card:hover,
.strength-item:hover,
.award-item:hover {
box-shadow: var(--shadow);
border-color: #d4d4d8;
}
Page-local classes that live inside Astro's scoped <style> blocks cannot participate in global selectors. Two approaches depending on whether the element is conceptually a card:
Option A — element IS a card: add the global card class directly to the markup element alongside the page-local class. Strip background/border/radius/shadow/transition from the local <style>. The local class then holds only differential layout (padding overrides, gap, flex direction, etc.).
<!-- before -->
<div class="repo-card repo-card-static">
<!-- after — local style block shrinks to layout-only -->
<div class="card repo-card repo-card-static">
Option B — element is NOT a card (e.g. a wrapper with overflow: hidden): keep chrome local, but use the design token (calc(var(--radius) * 1.5)) — never hardcode 12px or any value that duplicates a token.
Prefer Option A whenever the element and the shared .card class are semantically equivalent — it reduces local CSS and keeps token usage consistent automatically.
Rule: never hardcode border-radius: 12px (or any value that is already a design token). A raw px value survives a token rename; the token does not.
Hardcoded radius hides in container/clip wrappers too — not just card-shaped elements. Selectors like .contact-links { overflow: hidden; border-radius: 12px } or .modal-inner { border-radius: 8px } are easy to miss in a card-focused audit. Extend the audit scan to any selector containing overflow: hidden or border-radius:
Quick dead CSS audit before and after consolidation:
grep -r 'class-name' src/ | grep -v 'global.css'
CSS consolidation — label/caps pattern
Uppercase label styles (font-size: 0.6875rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase) accumulate across page-local <style> blocks with invisible drift — typically in letter-spacing (0.08em vs 0.1em) or font-weight (600 vs 700). These render nearly identically but are not the same expression.
Fix: define two base utility classes in global.css:
.label-caps {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.label-caps-muted {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-3);
}
Add the base class to the markup element alongside the existing page-local class:
<span class="contact-link-label label-caps-muted">Email</span>
<p class="t-category label-caps-muted">{t.category}</p>
<p class="contact-process-label label-caps">How it usually starts</p>
Reduce the page-local rule to override-only declarations — just color, margin-bottom, min-width, or whatever genuinely differs. Strip all declarations that are now covered by the base class.
Align any related global rules (e.g. .section-label, .award-item .award-year) to use the same canonical values — weight 700, tracking 0.1em, size 0.6875rem — to keep section-label consistent with the utility classes.
Pitfall: drift hides in letter-spacing and font-weight. 0.08em vs 0.1em and 600 vs 700 are invisible at a glance but inconsistent in output. Always check these two properties when auditing label-like classes.
Pitfall: search for CSS class definitions that are never used in markup before and after the refactor. A class like .signal-label may be defined in a <style> block but not present on any element — delete it rather than consolidating it.
grep -n "class=" src/pages/testimonials.astro | grep -oP 'class="[^"]+"' | \
grep -oP '"[^"]+"' | tr ' ' '\n' | sort -u
Dead CSS detection
When reviewing global.css, extract class names and check which ones are actually referenced in src/:
grep -r 'class-name' /path/to/src/ 2>/dev/null | grep -v 'global.css'
Common dead CSS groups after a redesign:
- Old service layout:
.service-entry, .service-left, .service-num, .service-name, .service-body, .service-tags
- Old contact layout:
.contact-grid, .contact-item, .contact-label, .contact-value
- Old page intro:
.page-intro
Also check for undeclared classes — classes used in pages but absent from global.css and any local <style> block.
Page transitions and scroll animations
Page fade (CSS only)
@keyframes page-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
::view-transition-old(root) { animation: 150ms ease-out both page-fade-in reverse; }
::view-transition-new(root) { animation: 200ms ease-out both page-fade-in; }
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root) { animation: none; }
}
150ms out / 200ms in. No JS needed for the page fade — Astro's <ViewTransitions /> handles the lifecycle.
Scroll-triggered fade-up (IntersectionObserver)
Add to the astro:page-load handler in Layout.astro:
function initFadeUp() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const observer = new IntersectionObserver(
(entries) => entries.forEach(entry => {
if (entry.isIntersecting) {
(entry.target as HTMLElement).classList.add('visible');
observer.unobserve(entry.target);
}
}),
{ threshold: 0.08, rootMargin: '0px 0px -40px 0px' }
);
document.querySelectorAll('section').forEach(el => {
if (el.classList.contains('hero')) {
el.classList.add('fade-up', 'visible');
} else {
el.classList.add('fade-up');
observer.observe(el);
}
});
}
CSS (gate behind .js class — see pitfalls):
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.js .fade-up { opacity: 0; }
.js .fade-up.visible { animation: fade-in 420ms cubic-bezier(0.22, 1, 0.36, 1) both; }
Opacity-only. 420ms, natural ease-out. Single pass — never re-triggers on re-entry. Hero excluded (already visible on load).
No Y-axis translate. Even a small translateY(18px) violates the motion policy for this site (calm, no entrance theatrics) and can cause layout jank on mobile during scroll.
Keyframe vs class naming. The @keyframes name should describe what the animation actually does — opacity only → fade-in. The CSS class .fade-up stays in markup (renaming it would require touching every template). They are independent names: the keyframe is an implementation detail, the class is a semantic contract with the HTML.
Animation tone
Keep animation imperceptible to a distracted eye — craft, not spectacle. No slides, stagger cascades, bounce, or spring overshoot. If someone notices the animation before the content, dial it back.
Hugo-to-Astro migration pitfalls
Check for leftover artifacts that Astro never uses:
/static/ directory — Hugo's asset folder; Astro uses /public/
.hugo_build.lock — Hugo lock file
hugo_stats.json — Hugo build stats
config.toml or config.yaml at project root — Hugo site config
None of these break Astro builds (silently ignored), but they confuse future contributors. Safe to delete if nothing in src/ references static/.
Featured card in an auto-fit grid
When one card in an auto-fit minmax() grid has significantly more content than its peers, use grid-column: 1 / -1 to span full width. Keep the inner layout identical to sibling cards (stacked, no inner grid) — a two-column inner layout (e.g. year left / content right) breaks visual consistency with the other cards and looks odd.
.award-item--featured {
grid-column: 1 / -1;
}
Apply the modifier class conditionally in Astro:
<div class={`award-item${a.featured ? ' award-item--featured' : ''}`}>
<div class="award-year">{a.year}</div>
<div class="award-content">
<h4>{a.title}</h4>
<p>{a.issuer}</p>
{a.detail && <p>{a.detail}</p>}
</div>
</div>
Mark the entry with featured: true in the data array. This pattern is useful for any list where one item carries significantly more content — recognition sections, service cards, case studies.
Do not add a max-width to the inner content div — the card's own padding and border already frame the content. A max-width just creates awkward empty space on the right inside a bordered card.
Resource hints
For any third-party font or CDN host in the <head>, always pair dns-prefetch with preconnect, and always add crossorigin to the preconnect for CORS resources (fonts, stylesheets). Without crossorigin, the browser opens a second connection when the actual font request fires — the preconnect does nothing.
<link rel="dns-prefetch" href="//fonts.bunny.net" />
<link rel="preconnect" href="https://fonts.bunny.net" crossorigin />
Decision rules for a small static marketing site:
preconnect — critical third-party origins you know will be hit on every page (fonts, CDN)
dns-prefetch — fallback alongside every preconnect; costs nothing
prefetch — not worth adding on a 5-page site with fast static assets
prerender — Chrome/Edge only, resource-intensive; skip
preload — only if profiling reveals a critical render-path resource being fetched late
Do not add hints speculatively. Each one is a real browser instruction — noise here hurts more than silence.
Resource hints for third-party fonts
A single preconnect with crossorigin is not enough for a font CDN. The first request to the CDN is the CSS stylesheet — not a CORS request — so the browser opens a second non-CORS connection and the crossorigin preconnect sits idle until the font files arrive. Use three hints in this order:
<link rel="dns-prefetch" href="//fonts.bunny.net" />
<link rel="preconnect" href="https://fonts.bunny.net" />
<link rel="preconnect" href="https://fonts.bunny.net" crossorigin />
- dns-prefetch: fallback for browsers without preconnect support
- preconnect without crossorigin: covers the CSS stylesheet request
- preconnect with crossorigin: covers the actual woff2 font file requests
PageSpeed will report "Unused preconnect — check crossorigin attribute" if you only have the crossorigin variant. Est. savings ~300ms LCP.
Font weight audit
Each font weight is a separate woff2 file (~24 KiB each), all chained off a blocking CSS request. Audit actual usage before loading multiple weights. Dropping one unused weight saves ~25 KiB and one round trip. Check what Inter weights are actually applied in global.css before defaulting to 400,500,600,700.
GitHub Pages HTTP response headers
GitHub Pages does not support custom HTTP response headers. Security headers (X-Frame-Options, X-Content-Type-Options, Content-Security-Policy) cannot be set from the repo. The only path is to proxy through Cloudflare (free tier):
- Add domain to Cloudflare, update nameservers at registrar
- Set proxy mode to orange-cloud (proxied) on DNS records
- Add headers via Rules > Transform Rules > Modify Response Header
- Set SSL/TLS encryption mode to Full (not Flexible — Flexible causes redirect loops with GitHub Pages)
Cache TTL on Astro assets (default 10 minutes on GitHub Pages) also improves automatically with Cloudflare.
Font loading
Preconnect for third-party font services
A single preconnect with crossorigin is not enough. The first request to a font CDN is the CSS stylesheet, which is NOT a CORS request — so the browser opens a second connection, and your preconnect sits idle until the font files arrive.
Use two preconnect hints:
<link rel="dns-prefetch" href="//fonts.bunny.net" />
<link rel="preconnect" href="https://fonts.bunny.net" />
<link rel="preconnect" href="https://fonts.bunny.net" crossorigin />
The non-crossorigin hint handles the stylesheet; the crossorigin hint handles the woff2 files. This matches what PageSpeed Insights flags as "Unused preconnect" when only the crossorigin version is present.
Font weight verification
Before shipping a font URL, verify that every font-weight value declared in CSS is actually loaded. Mismatches are silent — the browser falls back to the nearest available weight with no warning.
Common traps with the default Inter + JetBrains Mono setup on fonts.bunny.net:
.wordmark often gets font-weight: 800 but the default URL only loads up to 700. Add 800 to the Inter weight list or change the declaration to 700.
- JetBrains Mono is typically loaded at
500 but CSS rules using var(--font-mono) often declare font-weight: 600. Either load 600 or change the declarations to 500.
Detection: scan all CSS and page files for font-weight values, group by font-family context, and cross-check against the weights in the fonts.bunny.net URL.
Self-hosting fonts
When third-party font latency shows up in PageSpeed (chain: HTML → CSS → woff2), self-hosting eliminates the dependency and collapses the critical path to one hop.
Steps
- Download woff2 files into
public/fonts/ — use the exact URLs from the browser network trace or PageSpeed waterfall.
- Add
@font-face blocks at the top of global.css, one per weight, with font-display: swap and correct unicode-range.
- Remove all
dns-prefetch, preconnect, and third-party <link rel="stylesheet"> font references from Layout.astro.
- Add
<link rel="preload"> hints for the two most critical weights (typically 400 and 600) — these fire before CSS is parsed.
- Remaining weights load on demand as the browser processes the @font-face rules in CSS.
Preload pattern (Layout.astro)
<link rel="preload" href="/fonts/inter-latin-400-normal.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/inter-latin-600-normal.woff2" as="font" type="font/woff2" crossorigin />
@font-face pattern (global.css)
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/inter-latin-400-normal.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
Repeat for each weight. Place @font-face blocks at the very top of global.css, before :root.
Font weight audit before downloading
references/comtech-font-audit.md for a worked example from comtechconsulting.dk.
references/llms-txt-template.md — template for llms.txt on a personal consulting site.
references/ai-summary-css.md — full CSS block for the AI summary section and buttons.
Before choosing which weights to download, scan the codebase for all font-weight declarations:
- Check global.css and all page/layout .astro files
- Note:
font-weight: 800 (or any weight) has no effect if that weight isn't loaded — browser silently falls back to nearest available
- Check that
font-family: var(--font-mono) call sites declare a weight that is actually loaded
font-weight: 400 is rarely declared explicitly but is used implicitly for all body text — always include it
Preconnect fix for third-party fonts (if not self-hosting)
If staying on a third-party font CDN, the crossorigin preconnect must be paired with a non-crossorigin preconnect. The font CSS request is not CORS, but woff2 requests are — without both hints, the browser opens a second connection:
<link rel="dns-prefetch" href="//fonts.bunny.net" />
<link rel="preconnect" href="https://fonts.bunny.net" />
<link rel="preconnect" href="https://fonts.bunny.net" crossorigin />
A single <link rel="preconnect" ... crossorigin /> is not sufficient — the non-CORS CSS request will open a second connection anyway.
AI discovery optimization
Two complementary additions that improve how AI assistants (ChatGPT browsing, Gemini, Perplexity, Bing Copilot) understand and represent a site.
llms.txt
A plain-text file at /public/llms.txt that AI crawlers with retrieval use directly. Structure it as:
# Brand — domain
## Who
One paragraph: who the person/company is, location, positioning.
## What [brand] does
Sub-sections per service area with plain-prose descriptions.
## Industries / Clients
## Selected recognition
## What clients say
2–3 short quotes with attribution.
## Contact
## Site structure
- /path/ — one-line description per page
Keep it factual, scannable, no marketing fluff. This is machine-read first. See references/llms-txt-template.md for a worked example.
Add a pointer in robots.txt (already standard to have sitemap there; llms.txt is self-discoverable via crawl).
AI summary section (deep-link buttons)
A lean section placed between <main> and <footer> in Layout.astro — appears on every page. No backend, no API keys, pure static.
Providers that support pre-populated prompts via query string (as of 2025):
- ChatGPT:
https://chatgpt.com/?q=<encoded prompt>
- Google AI Mode:
https://www.google.com/search?q=<encoded prompt>&udm=50
Both have web search enabled by default. Skip Claude — it does not browse by default, so accuracy suffers. Skip Gemini (gemini.google.com/app?q=) — the query string is silently dropped in practice; the prompt does not pre-populate. Google AI Mode is the clean Google-ecosystem alternative: standard search URL, reliable injection, opens in a browser tab.
Prompt pattern — ask for structure explicitly:
"Based on https://domain.com, give me a structured overview of [Brand]. Use clear headings for: Who [Full Name] is, Services offered, Industries served, Recognition and credentials, and How to get in touch."
Explicit URL gives browsing AIs a direct target. Full name adds a personal search hook. Asking for headings matters — an open-ended prompt produces a prose blob. Naming each section in the prompt produces formatted output with those exact headings in both ChatGPT and Google AI Mode.
Placement: hero CTA button is the primary entry point. A footer row is too easy to miss — visitors who would actually use it are gone before they reach it. The right treatment is a third button in the hero actions row: ChatGPT logo inline SVG + label, same ghost-dark style as secondary CTAs. This keeps it discoverable without making AI the headline feature.
A footer row (quiet secondary row with hairline border-top, muted link text) is optional and worth adding if you want the action on every page. But if you have to choose one, choose the hero button. A standalone <section> between <main> and <footer> looks bolted on — avoid it entirely.
Button label: "Ask AI" over "AI Summary". "Summary" implies a result delivered on the page; the button actually hands the visitor off to ChatGPT. "Ask AI" is verb-led, honest about what it does, and shorter. If the footer row is present alongside the hero button, "Ask AI" also reads more naturally in a footer context than "AI Summary".
<a
href={`https://chatgpt.com/?q=${encodeURIComponent('...')}`}
target="_blank" rel="noopener noreferrer"
class="btn btn-ghost-dark btn-ai-summary"
aria-label="Get an AI summary via ChatGPT"
>
<svg class="btn-ai-icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"
xmlns="http://www.w3.org/2000/svg">
<!-- ChatGPT logo path — see references/ai-summary-css.md -->
</svg>
AI Summary
</a>
CSS for the icon-text button:
.btn-ai-summary { display: inline-flex; align-items: center; gap: 0.4em; opacity: 0.6; }
.btn-ai-summary:hover { opacity: 1; }
.btn-ai-icon { width: 1em; height: 1em; flex-shrink: 0; }
Demoting a tertiary button visually: use opacity alone, not font-size. Reducing font-size on one button in a row breaks vertical alignment and makes the button look broken rather than subordinate. opacity: 0.6 with opacity: 1 on hover achieves the same hierarchy signal without disrupting the row's geometry. The button inherits the same size, padding, and border as its siblings — only the weight of presence differs.
The full ChatGPT SVG path is in references/ai-summary-css.md.
Footer row pattern in Layout.astro (inside <footer>, below the main footer content):
<div class="footer-ai">
<span class="footer-ai-label">Ask an AI about COM<tech></span>
<div class="footer-ai-links">
<a
href={`https://chatgpt.com/?q=${encodeURIComponent('...')}`}
target="_blank" rel="noopener noreferrer"
class="footer-ai-link"
>ChatGPT</a>
<span class="footer-ai-sep" aria-hidden="true">·</span>
<a
href={`https://www.google.com/search?q=${encodeURIComponent('...')}&udm=50`}
target="_blank" rel="noopener noreferrer"
class="footer-ai-link"
>Google AI</a>
</div>
</div>
CSS: small uppercase label left, links right, same muted text color as footer-email. Use var(--dark-text-3) for label and separator, var(--dark-text-2) for links, var(--dark-text) on hover. Border-top to visually separate from main footer row. See references/ai-summary-css.md for the full CSS block.
Content Collections (Content Layer API)
Astro 6 uses only the Content Layer API — the legacy type: 'content' / type: 'data' syntax is removed. Every collection requires a loader.
Config file location
src/content.config.ts ← Astro 6 (at the src/ level)
src/content/config.ts ← Astro 4/5 (inside src/content/) — NO LONGER WORKS
If you have an old src/content/config.ts, move it and update the loader syntax. The build error is explicit.
Minimal collection from a JSON file
import { defineCollection, z } from 'astro:content';
import { file } from 'astro/loaders';
const strengths = defineCollection({
loader: file('./src/data/strengths.json'),
schema: z.object({
title: z.string(),
body: z.string(),
}),
});
export const collections = { strengths };
Required: every item in the JSON array must have an id field. The file() loader uses it as the collection entry ID. A missing id is a build error.
[
{ "id": "architecture-long-view", "title": "...", "body": "..." },
{ "id": "delivery-focus", "title": "...", "body": "..." }
]
Querying in a page
import { getCollection } from 'astro:content';
const strengths = await getCollection('strengths');
Order pitfall: getCollection() returns items in undefined order (typically alphabetical by id). Any collection whose display order matters MUST include an explicit order: z.number() field in the schema and a .sort((a, b) => a.data.order - b.data.order) at the call site. Add this at migration time — never assume the original array order is preserved.
Entries wrap their fields under .data — not top-level:
{strengths.map(s => <h4>{s.data.title}</h4>)}
This is the most common template mistake after migration: accessing s.title instead of s.data.title.
Which data belongs where
| Data type | Approach |
|---|
| Typed structured objects with multiple fields | defineCollection + file() loader |
| Simple flat arrays (strings, enums) | Plain JSON import: import data from '../data/foo.json' |
| Directory of Markdown/MDX files | glob({ pattern: '**/*.md', base: './src/data/blog' }) |
Content Collections pay off when: (a) multiple pages share the same data shape, (b) you want build-time Zod validation to catch data errors, or (c) you want to edit content without touching template markup.
Shared schema across similar collections
When multiple collections share identical field shapes (e.g. two testimonial tiers), define one schema constant and reuse it:
const testimonialSchema = z.object({
category: z.string(),
excerpt: z.string(),
quote: z.string(),
name: z.string(),
title: z.string(),
});
const testimonialsF = defineCollection({ loader: file('./src/data/testimonials-featured.json'), schema: testimonialSchema });
const testimonialsS = defineCollection({ loader: file('./src/data/testimonials-standard.json'), schema: testimonialSchema });
Avoids silent schema drift between the two files and keeps content.config.ts short.
Dead selector sweep after migration
After extracting a page's data to collections, the page-local <style> block becomes a source of orphaned CSS. Classes that existed only to style the removed/refactored markup stay behind — silently — because the build has no way to flag them.
Run a sweep immediately after each page migration:
grep -oP '\.[a-z][a-z0-9_-]+' src/pages/page.astro | sort -u
grep -c 'class-name' src/pages/page.astro
Also check media query overrides — orphaned selectors inside @media blocks are easy to miss because the block still contains live selectors alongside the dead ones. Found in practice: .signal-grid persisted in a responsive @media block in testimonials.astro alongside live .featured-grid after the Content Collections refactor removed the grid that used it.
Dead data check before migrating
Before extracting a frontmatter array to a collection, confirm it is actually used in the template:
grep -n 'array_name' src/pages/page.astro
A defined-but-never-rendered array is dead code — delete it instead of migrating it. This was found in testimonials.astro: a highlights array (3 items) was defined in the frontmatter but had zero usage in the template.
Migration value
Moving inline data arrays from .astro frontmatter to JSON files + collections typically reduces page files by 20–25% of total line count. Examples:
about.astro: 316 → 245 lines (−71) with three collections extracted
testimonials.astro: 289 → 223 lines (−66), including removal of one dead array
ESLint setup for Astro
Packages
npm install -D eslint eslint-plugin-astro eslint-plugin-jsx-a11y @typescript-eslint/parser
eslint-plugin-astro — Astro-aware rules, supports .astro files in flat config
eslint-plugin-jsx-a11y — pulled in transitively and activated via eslintPluginAstro.configs['flat/recommended']; gives a11y coverage inside Astro templates
@typescript-eslint/parser — required if any .astro file uses TypeScript syntax in its frontmatter (interface, as cast, typed props, etc.). Without it, the parser throws on the frontmatter block and reports false-positive parse errors for every such file. Wire it explicitly in the Astro file override.
Flat config (eslint.config.js)
eslint-plugin-astro 1.x (ESLint 9/10 flat config) exports named config arrays directly — not under flat/:
import eslintPluginAstro from 'eslint-plugin-astro';
import tsParser from '@typescript-eslint/parser';
export default [
...eslintPluginAstro.configs.recommended,
...eslintPluginAstro.configs['jsx-a11y-recommended'],
{
files: ['**/*.astro'],
languageOptions: {
parserOptions: {
parser: tsParser,
},
},
},
{
rules: {
'astro/no-set-html-directive': 'warn',
'astro/no-unused-define-vars-in-style': 'error',
'astro/prefer-class-list-directive': 'warn',
'astro/semi': ['warn', 'always'],
},
},
{
ignores: ['dist/**', 'node_modules/**', 'scripts/**'],
},
];
Pitfall: eslintPluginAstro.configs['flat/recommended'] does not exist in 1.x — it throws a silent spread-of-undefined that produces zero rules. Use configs.recommended and configs['jsx-a11y-recommended'] as separate spreads.
package.json script
"lint": "eslint \"src/**/*.astro\" \"src/**/*.ts\" \"src/**/*.js\""
CI — run before build
Add a lint step before the build in the deploy workflow. A lint failure before build prevents a broken deploy:
- name: Lint
run: npm run lint
- name: Build with Astro
run: npm run build
Common findings in Astro files
| Finding | Fix |
|---|
role="list" on <ul> | Remove — <ul> already has an implicit list role; adding it explicitly is redundant and generates a lint warning |
Template string class concatenation class={\foo ${flag ? 'bar' : ''}`}` | Use Astro's idiomatic class:list={['foo', { bar: flag }]} directive |
set:html on a static hardcoded SVG string | Keep — set:html is the correct way to render inline SVG from data arrays. Downgrade to warn and add a comment: {/* set:html safe — SVG is hardcoded static data, not user input */} |
See templates/eslint.config.js for a ready-to-copy config.
Astro major version upgrades
Astro 5 → 6
Use the official upgrade CLI — it handles peer dep resolution correctly:
echo "" | npx @astrojs/upgrade
(The tool is interactive; piping an empty string accepts the default prompt.)
Breaking changes that hit a static marketing site:
| Change | Fix |
|---|
ViewTransitions renamed to ClientRouter | Import and usage site both need updating — check both |
import { ViewTransitions } from 'astro:transitions' | → import { ClientRouter } from 'astro:transitions' |
<ViewTransitions /> in layout | → <ClientRouter /> |
| Content config file moved | src/content/config.ts → src/content.config.ts (project root of src/) |
Legacy type: 'content' / type: 'data' collections removed | Every collection now requires a loader (see Content Collections section below) |
After upgrade, run npm run build immediately. Astro will surface any remaining breaking changes as build errors with actionable messages. The content config error is loud and actionable:
[LegacyContentConfigError] Found legacy content config file in "src/content/config.ts".
Please move this file to "src/content.config.ts" and ensure each collection has a loader defined.
On a plain static site the ViewTransitions rename and the config file move are the two most common breaking changes.
@astrojs/sitemap 3.x is compatible with Astro 6 and upgrades cleanly alongside it via @astrojs/upgrade.
Pitfall: ESLint after Astro upgrade
eslint-plugin-astro and @typescript-eslint/parser peer deps are independent of the Astro version. Upgrade ESLint devDeps separately:
npm install -D eslint@latest
ESLint 9 → 10 is a smooth upgrade for flat config projects — no config changes needed.
Pitfall: eslint-plugin-jsx-a11y peer dep conflict with ESLint 10
eslint-plugin-jsx-a11y declares peer support for ESLint ^3 || ... || ^9 only — it lags behind the actual ESLint major. npm ci will fail with ERESOLVE could not resolve when ESLint 10 is installed. The plugin works fine at runtime; the peer dep declaration is just stale.
Fix: add .npmrc at the repo root:
legacy-peer-deps=true
This tells npm to resolve peer deps the same way npm 6 did, which accepts the plugin. Commit .npmrc — CI needs it too, and forgetting it is exactly what surfaces the error there first.
CSS quality audit (Project Wallace)
After any significant CSS consolidation pass, run:
https://www.projectwallace.com/css-code-quality?url=<domain>&prettify=1
Target: Maintainability 90+, Complexity 95+, Performance 90+. Scores are directional, not literal — each flagged item needs contextual assessment before acting. Most findings on a token-driven static site are expected, not actionable. Full interpretation guide and !important audit pattern in senior-software-development/references/css-dead-rule-audit.md.
Project Wallace finding interpretation
Most findings on a well-structured static site are expected, not actionable. Assess each one before touching code:
| Finding | Typical cause | Action |
|---|
| High declaration duplication (40–55%) | Token-driven CSS — display: flex, gap: 1rem, color: var(--x) appear in many rules | None. Expected at this scale. |
| Large ruleset (20–30 declarations) | :root {} design token block | None. Intentional architecture. |
| Embedded content (100–500 bytes) | Inline SVG or data URI for a single decorative asset | None if it's one asset. Extra HTTP request would cost more. |
| Complex selectors (attribute + pseudo-class) | Hamburger nav: .nav-toggle[aria-expanded="true"] span:nth-child(n) | None. Correct pattern for CSS-driven animation. |
!important present | Old defensive rule fighting a now-removed competing rule | Audit — remove if the universal reset (* { margin: 0 }) already covers it |
!important removal pattern: if * { margin-top: 0 } (or similar universal reset) is already in global.css, any margin-top: 0 !important on a specific element is fighting nothing. Remove it. Verify in browser console:
const sheets = Array.from(document.styleSheets);
const rules = [];
sheets.forEach(s => {
try {
Array.from(s.cssRules).forEach(r => {
if (r.style && r.style.marginTop && r.selectorText)
rules.push(`${r.selectorText}: ${r.style.marginTop}`);
});
} catch(e) {}
});
rules.join('\n');
If no rule is producing a competing margin-top, the !important is dead weight.
Lighthouse performance audit
Run against the local production build, not the dev server — npm run dev skips optimisations that affect real scores.
Workflow
npm run build
npm run preview -- --port 4321 &
curl -s -o /dev/null -w "%{http_code}" http://localhost:4321
CHROME_PATH="/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" \
npx lighthouse http://localhost:4321 \
--output=json \
--output-path=/tmp/lh-output.json \
--chrome-flags="--headless=new --no-sandbox --disable-gpu" \
--only-categories=performance,accessibility,best-practices,seo
macOS: no Chrome installed
Lighthouse requires a Chromium-family browser. On macOS without Chrome, Brave works — but the --chrome-path CLI flag is not honoured reliably. Use the CHROME_PATH environment variable instead:
CHROME_PATH="/Applications/Brave Browser.app/Contents/MacOS/Brave Browser" \
npx lighthouse <url> --chrome-flags="--headless=new --no-sandbox --disable-gpu" ...
--headless=new is required for Brave 148+ (old headless mode was removed in Chromium 112).
Parsing results
Key fields in the JSON output:
import json
data = json.load(open('/tmp/lh-output.json'))
for k, v in data['categories'].items():
print(v['title'], round(v['score'] * 100))
metrics = ['first-contentful-paint', 'largest-contentful-paint',
'total-blocking-time', 'cumulative-layout-shift', 'interactive']
for m in metrics:
a = data['audits'][m]
print(a['title'], a.get('displayValue'), a.get('score'))
for k, a in data['audits'].items():
if a.get('details', {}).get('type') == 'opportunity' and (a.get('score') or 1) < 1:
ms = a.get('details', {}).get('overallSavingsMs', 0)
print(a['title'], f'~{round(ms)}ms')
comtechconsulting.dk baseline (30.05.2026, updated after audit remediation)
Performance 100 · Accessibility 100 · Best Practices 100 · SEO 100
Accessibility checklist (static Astro sites)
Before shipping any page:
-
Skip link: <a class="skip-link" href="#main-content">Skip to content</a> before <header>, with <main id="main-content">. Required WCAG 2.4.1.
-
<html lang="en"> explicit — not browser-guessed.
-
<nav> is the semantic landmark. role="navigation" is redundant — remove it; keep aria-label.
-
og:locale must be a valid OGP locale (en_GB, da_DK). en_DK is not registered.
-
Font preloads must cover every weight used above the fold. Audit via DevTools Network tab filtered to font requests.
-
Custom :focus-visible ring required for polished sites — do not rely on browser defaults. Use outline: 2px solid var(--accent); outline-offset: 3px; scoped to :focus-visible (not :focus) to avoid showing the ring on mouse clicks.
-
Quote/italic consistency: if the site uses upright text for testimonials, any font-style: italic on .hero-quote, .bento-pull, .blockquote-card, or similar pull-quote elements creates inconsistency. Remove italic from all quote elements and keep the style signal in border, padding, or colour instead.
-
FCP 1.5s · LCP 1.7s · TBT 0ms · CLS 0 · TTI 1.7s
-
One diagnostic flag: render-blocking requests (score null — below penalty threshold)
-
Self-hosted fonts with preload eliminated font-chain latency
See references/lighthouse-comtech-2026-05.md for full metric dump.
Visual Review & Critique
The site ships a scripts/critique.js Playwright script that captures every page at desktop (1440×900) and mobile (390×844) viewports — both above-the-fold and full-page — then writes a manifest at screenshots/critique/manifest.json.
Run it:\n\nnpm run critique # full build + capture\nnpm run critique:fast # skip build, use existing dist/\n\n\ncritique:fast serves the existing dist/ — CSS or markup changes made after the last build will NOT be visible in the screenshots. Always run npm run critique (full build) when verifying a CSS or layout change. Use critique:fast only when re-capturing after a build you just ran.
After capture, load each screenshot via vision_analyze and ask for a strict UX/UI critique against the brand's design system. See templates/critique.js for the canonical script.
Pitfall: IntersectionObserver / fade-up animations invisible in headless Playwright
Astro sites commonly use .fade-up animations gated on .js class and triggered via IntersectionObserver. Headless Playwright never scrolls, so the observer never fires — content stays at opacity: 0 and screenshots show blank voids where sections should be.
Fix: Before taking screenshots, run a scroll simulation inside page.evaluate(). Use at least 20 steps and 120ms per step — 10 steps at 80ms is not enough for deep pages with many sections (e.g. about pages with 6+ sections below fold).
await page.evaluate(async () => {
const delay = (ms) => new Promise(r => setTimeout(r, ms));
const scrollHeight = document.body.scrollHeight;
const step = Math.ceil(scrollHeight / 20);
for (let y = 0; y <= scrollHeight; y += step) {
window.scrollTo(0, y);
await delay(120);
}
await delay(300);
window.scrollTo(0, 0);
await delay(500);
});
await page.waitForTimeout(400);
This triggers all IntersectionObserver callbacks, then resets to top so fold captures are correct. The pause at the bottom matters — IntersectionObserver callbacks are async and the 120ms per step alone is not sufficient for sections at the very end of a long page.
Pitfalls
- Page header bleed in flex section containers: if a section uses
display: flex; flex-direction: column; gap: Xrem, the gap also applies between section-label, h1, and lead text. Fix: wrap the header group in a single .page-header div.
- Nav layout shift between pages: if one page has a scrollbar and another doesn't, the page width shifts ~15px. Fix: add
scrollbar-gutter: stable to the html rule in global.css.
- Fade-up flash on page load: if
.fade-up { opacity: 0 } is in plain CSS (not gated), the browser renders sections visible first, then JS hides them, then fades them in — a visible jump. Fix: add <script is:inline>document.documentElement.classList.add('js');</script> in <head> and gate the hidden state behind .js .fade-up.
- Always check whether
astro.config.mjs exists before calling it absent — it is not in the src/ tree.
@astrojs/sitemap requires site: to be set; without it the sitemap URLs are relative and useless.
og:image must be an absolute URL. Use new URL(ogImage, Astro.site) — not string concatenation.
og:site_name is easy to miss. If the brand name contains < or >, use HTML entities (COM<tech>).
- ViewTransitions replaces the head on page navigation; confirm meta tags are in the persistent layout, not page-level slots.
- macOS app URL interception overrides
target="_blank". When a user has a desktop app installed (ChatGPT, Spotify, Slack), macOS registers it as the OS-level URL handler for that domain. Even with target="_blank", clicking a link to chatgpt.com opens the app, not a browser tab. This is OS behaviour — there is nothing to fix in the HTML. Visitors without the app installed get a browser tab as expected. Do not add workarounds; mention it as expected behaviour if the user raises it.
og:locale must be a valid OGP locale. en_DK is not valid — OGP requires language_TERRITORY using ISO 639-1 + ISO 3166-1 alpha-2. For English content published from Denmark, use en_GB. The mistake is silent: no build error, no unfurl warning, just a quietly wrong value. Check this whenever you set up or audit Layout.astro on a non-English-territory site.
- Infinite CSS animations on blurred/transformed elements cause continuous repaints. Aurora blobs, gradient orbs, and any element animated with
animation-iteration-count: infinite and transform or filter: blur() will trigger per-frame main-thread compositing unless the element is promoted to its own layer. Fix: add will-change: transform to the animated element's CSS rule. This moves it to the GPU compositor and eliminates the repaint cost. Only apply to elements that are already animating — speculative will-change on static elements wastes memory.
- Orphaned CSS after markup refactor causes invisible elements on dark backgrounds. When a layout section is moved or replaced, its CSS class names often disappear silently — the markup renders but elements are unstyled. On a dark background (
var(--dark-bg)), unstyled links and text become invisible with no error. After any structural refactor, search global.css for the new class names to confirm styles exist before assuming the feature is working. The symptom is "I don't see the buttons" with a clean build.
- Preview server serves stale
dist/. After patching markup or CSS, restart the preview server — it serves the already-built dist/, not the new source. The symptom is confirming a visual change in the browser while it was built from the prior build. Pattern: kill the server, run npm run build, start a fresh server on a new port. Avoid reusing ports across a session — a zombie process may be bound to the old one silently.
- DOM queries are more reliable than browser vision for correctness checks. The vision tool captures only one viewport height; deep content (e.g. a section at offset 2400px) shows blank. Use
browser_console with document.querySelectorAll('.class').length + window.getComputedStyle(el) to verify that elements exist and have the right computed values. Reserve vision captures for layout-level sanity checks on content that is in the initial viewport.
- patch mode corruption in .astro files:
mode=patch (V4A format) is unreliable in .astro files — the diff tool can leak git diff headers (+++ b/src/pages/about.astro) verbatim into the file content, corrupting both frontmatter data arrays and template markup. This has triggered twice in practice: once editing a JS data array in the frontmatter block, once editing a .map() render section in the template. Recovery is always a mode=replace with a clean old/new pair that includes the corrupted diff-header text. Prevention: always use mode=replace for .astro files. The only exception where mode=patch is safe is small, isolated CSS additions in <style> blocks at the bottom of the file, where context lines are stable and the edit is additive-only.