AI-Native Website Setup Playbook
One-shot Claude Code prompt for deploying a professional Astro 5 + Tailwind CSS + Cloudflare Pages website.
Designed to be pasted into a fresh Claude Code session along with a completed variable sheet. Every code block is copy-paste ready after variable substitution.
Table of Contents
| # | Section | Lines |
|---|---|---|
| 0 | Playbook Introduction | ~190 |
| 1 | Prerequisites: Human Steps | ~200 |
| 2 | Project Scaffolding | ~395 |
| 3 | Core Components + Layouts | ~1,445 |
| 4 | API Routes | ~314 |
| 5 | Content Architecture | ~562 |
| 6 | Security Hardening | ~122 |
| 7 | Testing Framework | ~537 |
| 8 | CI/CD Pipeline | ~199 |
| 9 | Image + Asset Pipeline | ~299 |
| 10 | Deployment + Operations | ~138 |
| 11 | WordPress Migration Path | ~54 |
| 12 | Quality Assurance Process | ~100 |
| 12.5 | Optional: TinaCMS for Client Self-Service | ~83 |
| 13 | Post-Launch Checklist | ~145 |
| A | Appendix A: Full File Reference | ~148 |
| B | Appendix B: Troubleshooting | ~99 |
| Lessons Learned | ~72 | |
| Research Findings Checklist | ~43 |
Section 0: Playbook Introduction
Purpose
This playbook enables a single Claude Code session to build and deploy a complete, professional-grade website with:
- Astro 5 static site generation with server-side API routes
- Tailwind CSS design system with custom tokens
- Cloudflare Pages hosting with Workers for API routes
- Brevo transactional email (contact form + newsletter)
- Cloudflare Turnstile CAPTCHA (privacy-respecting, no cookies)
- Cal.com booking integration (lazy-loaded modal)
- Umami analytics (cookie-free, GDPR-friendly)
- Blog with content collections, RSS, and JSON-LD
- Dual-mode resource pages (web view + PDF export)
- 228+ E2E tests via Playwright
- CI/CD via GitHub Actions (deploy, test, scheduled publish)
- Security hardening (CSP, HSTS, input validation, honeypot, CORS)
How to Use This Playbook
- Complete the Variable Sheet (Section 0.1) with all client-specific values
- Complete the Human Steps (Section 1) to create accounts and gather credentials
- Paste this entire playbook into a fresh Claude Code session
- Tell Claude: “Build the website using this playbook. Here are my variables: [paste completed variable sheet]”
- Claude will execute each section as a checkpoint, building and committing after each phase
Checkpoint System
After each major section, Claude should:
- Run
npm run buildto verify no build errors - Commit all changes with a descriptive message
- Report status before proceeding to the next section
If a build fails, fix the issue before moving on. Never skip a checkpoint.
0.1 Variable Sheet
Copy this block, fill in all values, and provide it alongside the playbook.
# === Required Variables ===
# Company Identity
SITE_NAME= # e.g., "Acme Consulting"
DOMAIN= # e.g., "acmeconsulting.com"
SITE_URL= # e.g., "https://acmeconsulting.com"
TAGLINE= # e.g., "Strategic IT consulting for growing businesses"
LOCATION= # e.g., "Denver, CO"
COMPANY_LEGAL_NAME= # e.g., "Acme Consulting LLC"
# Contact Information
SENDER_EMAIL= # e.g., "hello@acmeconsulting.com"
SENDER_NAME= # e.g., "Acme Consulting"
EMAIL_PREFIX= # e.g., "hello" (part before @)
EMAIL_DOMAIN= # e.g., "acmeconsulting.com"
PHONE_P1= # e.g., "303" (area code)
PHONE_P2= # e.g., "555"
PHONE_P3= # e.g., "1234"
# Booking / Scheduling
BOOKING_URL= # e.g., "https://cal.com/acme/intro"
CAL_LINK= # e.g., "acme/intro" (Cal.com path)
CTA_TEXT= # e.g., "Book a Free Consultation"
# Social Media
LINKEDIN_URL= # e.g., "https://www.linkedin.com/company/acme-consulting/"
TWITTER_HANDLE= # e.g., "acmeconsulting" (without @)
# Brand Colors (hex codes)
BRAND_PRIMARY= # e.g., "#020532" (darkest, nav/footer bg)
BRAND_SECONDARY= # e.g., "#091652" (slightly lighter)
BRAND_ACCENT= # e.g., "#C47A3D" (CTA buttons, highlights)
BRAND_ACCENT_HOVER= # e.g., "#D4945E" (accent hover state)
BRAND_ACCENT_ACTIVE= # e.g., "#A86230" (accent active state)
BRAND_BACKGROUND= # e.g., "#FEF9F1" (page background)
BRAND_WARM_STONE= # e.g., "#F0EBE4" (secondary background)
BRAND_CHARCOAL= # e.g., "#111827" (body text)
BRAND_NAVY_MID= # e.g., "#374273" (mid-tone)
BRAND_PEWTER= # e.g., "#9597A9" (muted text)
# Typography
BRAND_FONT_FAMILY= # e.g., "Inter" (Google Fonts name)
BRAND_FONT_FAMILY_FALLBACK= # e.g., "system-ui, -apple-system, sans-serif"
# === Service Credentials (from Section 1) ===
TURNSTILE_SITE_KEY= # From Cloudflare Turnstile dashboard
TURNSTILE_SECRET_KEY= # From Cloudflare Turnstile dashboard (secret)
BREVO_API_KEY= # From Brevo SMTP & API settings
BREVO_LIST_ID= # From Brevo contact list (usually "2")
CLOUDFLARE_ACCOUNT_ID= # From Cloudflare dashboard URL
CLOUDFLARE_API_TOKEN= # From Cloudflare API Tokens
CLOUDFLARE_PROJECT_NAME= # e.g., "acme-site"
UMAMI_WEBSITE_ID= # From Umami dashboard
GITHUB_REPO= # e.g., "acme/acme-site"
# === Content Variables ===
META_DESCRIPTION= # Default site meta description (160 chars max)
OG_IMAGE_PATH= # e.g., "/images/og-default.png"
# === Service Options (comma-separated for contact form dropdown) ===
SERVICE_OPTIONS= # e.g., "IT Assessment,Cloud Migration,Cybersecurity Review,General Inquiry"
# === Blog Pillars (comma-separated, kebab-case) ===
BLOG_PILLARS= # e.g., "cybersecurity,cloud,data-systems,operations"
# === Team Members (JSON array) ===
TEAM_MEMBERS= # e.g., [{"name":"Jane Doe","role":"Founder & CEO","bio":["Para 1","Para 2"],"image":"jane-doe","linkedIn":"https://linkedin.com/in/janedoe"}]
# === Navigation Structure (JSON) ===
NAV_SOLUTIONS= # e.g., [{"label":"IT Assessment","href":"/#assessment"},{"label":"All Services","href":"/#services"}]
NAV_INDUSTRIES= # e.g., [{"label":"Healthcare","href":"/for/healthcare"},{"label":"Finance","href":"/for/finance"}]
NAV_RESOURCES= # e.g., [{"label":"Blog","href":"/blog"},{"label":"FAQ","href":"/resources/faq"}]
FOOTER_NAV= # e.g., [{"label":"Why Us","href":"/#why"},{"label":"Services","href":"/#services"}]
FOOTER_INDUSTRIES= # e.g., [{"label":"Healthcare","href":"/for/healthcare"}]
# === FAQ Content (JSON array) ===
FAQ_CATEGORIES= # e.g., [{"key":"general","label":"General"},{"key":"pricing","label":"Pricing"}]
FAQ_ITEMS= # e.g., [{"q":"How much does it cost?","a":"<p>It depends on scope...</p>","category":"pricing"}]
# === Legal Disclaimer ===
LEGAL_DISCLAIMER= # Footer disclaimer text
0.2 Variable Reference
| Variable | Used In | Purpose |
|---|---|---|
| Company Identity | ||
{{SITE_NAME}} | constants.ts, BaseLayout, Footer, emails, blog | Brand name everywhere |
{{DOMAIN}} | wrangler.toml, DNS, emails, security.txt | Domain without protocol |
{{SITE_URL}} | astro.config, constants, OG tags, canonical URLs | Full URL with https:// |
{{TAGLINE}} | Footer, package.json | One-line company description |
{{LOCATION}} | Email templates, JSON-LD | City, State for business address |
{{COMPANY_LEGAL_NAME}} | Footer copyright, legal pages | Registered business name |
| Contact | ||
{{SENDER_EMAIL}} | wrangler.toml, API routes | Brevo sender address |
{{SENDER_NAME}} | wrangler.toml, API routes | Brevo sender display name |
{{EMAIL_PREFIX}} | Footer (email obfuscation) | Part before @ (e.g., “hello”) |
{{EMAIL_DOMAIN}} | Footer (email obfuscation) | Part after @ (e.g., “acme.com”) |
{{PHONE_P1}} | Footer (phone obfuscation) | Area code |
{{PHONE_P2}} | Footer (phone obfuscation) | Exchange |
{{PHONE_P3}} | Footer (phone obfuscation) | Line number |
| Booking | ||
{{BOOKING_URL}} | constants.ts, Nav, Footer, CTAs | Cal.com booking link |
{{CAL_LINK}} | Nav, Footer, CTAs, data-cal-link attributes | Cal.com path for embed |
{{CTA_TEXT}} | constants.ts, Nav, Footer, CTAs | Primary CTA button text |
| Social | ||
{{LINKEDIN_URL}} | Footer | Company LinkedIn URL |
{{TWITTER_HANDLE}} | Footer | X/Twitter handle (without @) |
| Brand Colors | ||
{{BRAND_PRIMARY}} | tailwind.config, global.css, email templates | Darkest brand color (nav, footer bg) |
{{BRAND_SECONDARY}} | tailwind.config | Slightly lighter than primary |
{{BRAND_ACCENT}} | tailwind.config, email templates, Cal.com embed | CTA / highlight color |
{{BRAND_ACCENT_HOVER}} | tailwind.config | Button hover state |
{{BRAND_ACCENT_ACTIVE}} | tailwind.config | Button active/press state |
{{BRAND_BACKGROUND}} | tailwind.config, global.css, email templates | Page background color |
{{BRAND_WARM_STONE}} | tailwind.config, email templates | Secondary bg, card borders |
{{BRAND_CHARCOAL}} | tailwind.config, global.css, email templates | Body text color |
{{BRAND_NAVY_MID}} | tailwind.config | Mid-tone accent color |
{{BRAND_PEWTER}} | tailwind.config, email templates | Muted text, captions |
| Typography | ||
{{BRAND_FONT_FAMILY}} | tailwind.config, global.css, email templates | Primary font (e.g., “Inter”) |
{{BRAND_FONT_FAMILY_FALLBACK}} | tailwind.config, global.css | Font stack fallback |
| Infrastructure | ||
{{TURNSTILE_SITE_KEY}} | wrangler.toml, workflows | Turnstile widget key |
{{UMAMI_WEBSITE_ID}} | BaseLayout.astro | Analytics tracking |
{{CLOUDFLARE_PROJECT_NAME}} | wrangler.toml, workflows, PDF script | Cloudflare Pages project |
{{GITHUB_REPO}} | Git setup, workflows | owner/repo (e.g., “acme/acme-site”) |
| Content | ||
{{META_DESCRIPTION}} | BaseLayout default, RSS feed | Default site description (160 chars) |
{{OG_IMAGE_PATH}} | BaseLayout default | Default OG image path |
{{LEGAL_DISCLAIMER}} | Footer | Regulatory/legal disclaimer text |
| Structure (JSON) | ||
{{NAV_SOLUTIONS}} | Nav.astro | Solutions dropdown items |
{{NAV_INDUSTRIES}} | Nav.astro | Industries dropdown items |
{{NAV_RESOURCES}} | Nav.astro | Resources dropdown items |
{{FOOTER_NAV}} | Footer.astro | Footer navigation links |
{{FOOTER_INDUSTRIES}} | Footer.astro | Footer industries links |
{{SERVICE_OPTIONS}} | ContactForm.astro | Contact form service dropdown |
{{BLOG_PILLARS}} | content.config.ts | Blog content pillar categories |
{{FAQ_CATEGORIES}} | FAQ.astro | FAQ category definitions |
{{FAQ_ITEMS}} | FAQ.astro | FAQ question/answer data |
{{TEAM_MEMBERS}} | About page | Team member data for TeamMember cards |
Section 1: Prerequisites (Human Steps)
These steps must be completed by a human before Claude Code begins. Each step produces a credential or configuration value needed for the Variable Sheet.
Phase 1: GitHub Account + Repository
- Create a GitHub account (or use existing)
- Create a new repository:
- Name:
{{CLOUDFLARE_PROJECT_NAME}}(e.g.,acme-site) - Visibility: Private (recommended)
- Do NOT initialize with README, .gitignore, or license
- Name:
- Note the repository URL:
https://github.com/{{GITHUB_REPO}}
Phase 2: Cloudflare Account + Domain Setup
- Create Cloudflare account at https://dash.cloudflare.com/sign-up
- Add your domain:
- Click “Add a site” and enter
{{DOMAIN}} - Select Free plan
- Cloudflare will scan existing DNS records
- Click “Add a site” and enter
- Update nameservers at your registrar:
- Log into your domain registrar (Namecheap, GoDaddy, etc.)
- Replace existing nameservers with the two Cloudflare nameservers shown
- Example:
ava.ns.cloudflare.comandbruce.ns.cloudflare.com - Propagation takes 15 minutes to 48 hours (usually under 1 hour)
- Verify nameserver propagation:
dig NS {{DOMAIN}} +short # Should show Cloudflare nameservers - Note your Cloudflare Account ID:
- Dashboard URL looks like:
https://dash.cloudflare.com/ACCOUNT_ID/... - Copy the hex string after
dash.cloudflare.com/ - This is your
CLOUDFLARE_ACCOUNT_ID
- Dashboard URL looks like:
Phase 3: Cloudflare Pages Project
- In Cloudflare dashboard, go to Workers & Pages > Create
- Select Pages tab > Connect to Git
- Authorize GitHub and select your repository
- Configure build settings:
- Framework preset: Astro
- Build command:
npm run build - Build output directory:
dist - Root directory: (leave empty)
- Node.js version: 22 (set via environment variable
NODE_VERSION=22)
- Click Save and Deploy (first deploy will fail; that’s expected)
- Note the project name; this is your
CLOUDFLARE_PROJECT_NAME
Phase 4: Cloudflare API Token
- Go to https://dash.cloudflare.com/profile/api-tokens
- Click Create Token
- Select Custom token template
- Configure permissions:
- Account > Cloudflare Pages > Edit
- Account > Account Settings > Read
- Account Resources: Include your specific account
- Create token and copy it immediately
- This is your
CLOUDFLARE_API_TOKEN
Phase 5: Cloudflare Turnstile Widget
- Go to https://dash.cloudflare.com/ > Turnstile
- Click Add Widget
- Configuration:
- Widget name:
{{SITE_NAME}} Forms - Domains:
{{DOMAIN}},localhost - Widget Mode: Managed
- Widget name:
- Copy the Site Key =
TURNSTILE_SITE_KEY - Copy the Secret Key =
TURNSTILE_SECRET_KEY
Phase 6: Brevo Email Service
- Create Brevo account at https://www.brevo.com/
- Add and verify your sending domain:
- Go to Settings > Senders, Domains & Dedicated IPs > Domains
- Add
{{DOMAIN}} - Brevo will provide DNS records to add:
- DKIM record: TXT record at
mail._domainkey.{{DOMAIN}} - SPF record: Include Brevo in your SPF (e.g.,
include:sendinblue.com) - Return-path: CNAME record for bounce handling
- DKIM record: TXT record at
- Add these records in Cloudflare DNS dashboard
- Click Verify in Brevo
- Create a sender identity:
- Go to Settings > Senders > Add a Sender
- Name:
{{SENDER_NAME}} - Email:
{{SENDER_EMAIL}} - Verify via email link
- Generate API key:
- Go to Settings > SMTP & API > API Keys
- Click Generate a new API key
- Copy immediately; this is your
BREVO_API_KEY
- Create newsletter contact list:
- Go to Contacts > Lists > Create a List
- Name:
{{SITE_NAME}} Newsletter - Note the List ID (usually
2for the first custom list) - This is your
BREVO_LIST_ID
Phase 7: DNS Security Records
Add these DNS records in the Cloudflare DNS dashboard:
# SPF Record (TXT at root)
Type: TXT
Name: @
Content: v=spf1 include:_spf.google.com include:sendinblue.com -all
# Adjust includes based on your email providers
# DMARC Record (TXT)
Type: TXT
Name: _dmarc
Content: v=DMARC1; p=quarantine; rua=mailto:dmarc@{{DOMAIN}}; pct=100; adkim=s; aspf=s
# CAA Record (restrict certificate authorities)
Type: CAA
Name: @
Content: 0 issue "letsencrypt.org"
Type: CAA
Name: @
Content: 0 issue "pki.goog"
Type: CAA
Name: @
Content: 0 issue "digicert.com"
Type: CAA
Name: @
Content: 0 issuewild ";"
Phase 8: Umami Analytics
- Create account at https://cloud.umami.is/
- Add website:
- Name:
{{SITE_NAME}} - Domain:
{{DOMAIN}}
- Name:
- Copy the Website ID (UUID format) =
UMAMI_WEBSITE_ID - The tracking script URL is:
https://cloud.umami.is/script.js
Phase 9: Cal.com Booking Setup
- Create account at https://cal.com/
- Set up your booking page:
- Event type: e.g., “30-Minute Intro Call”
- Duration: 30 minutes
- Availability: set your schedule
- Your booking link format:
https://cal.com/{{CAL_LINK}} - For the
BOOKING_URL, you may use a short URL service or the direct Cal.com URL
Phase 10: Set GitHub Secrets and Variables
In your GitHub repository, go to Settings > Secrets and variables > Actions:
Repository Secrets (encrypted):
| Secret Name | Value |
|---|---|
CLOUDFLARE_API_TOKEN | Your Cloudflare API token |
CLOUDFLARE_ACCOUNT_ID | Your Cloudflare account ID |
Repository Variables (plaintext, visible in logs):
| Variable Name | Value |
|---|---|
TURNSTILE_SITE_KEY | Your Turnstile site key |
SITE_URL | https://{{DOMAIN}} |
Phase 11: Set Cloudflare Pages Secrets
Via Wrangler CLI (after project scaffolding in Section 2):
npx wrangler pages secret put BREVO_API_KEY --project-name={{CLOUDFLARE_PROJECT_NAME}}
# Paste your Brevo API key when prompted
npx wrangler pages secret put TURNSTILE_SECRET_KEY --project-name={{CLOUDFLARE_PROJECT_NAME}}
# Paste your Turnstile secret key when prompted
npx wrangler pages secret put BREVO_LIST_ID --project-name={{CLOUDFLARE_PROJECT_NAME}}
# Enter your Brevo list ID (e.g., "2")Quick Reference: Credential Sources
| Variable | Where to Find |
|---|---|
CLOUDFLARE_ACCOUNT_ID | Cloudflare dashboard URL: dash.cloudflare.com/{ID}/... |
CLOUDFLARE_API_TOKEN | Cloudflare > Profile > API Tokens |
TURNSTILE_SITE_KEY | Cloudflare > Turnstile > Your widget |
TURNSTILE_SECRET_KEY | Cloudflare > Turnstile > Your widget |
BREVO_API_KEY | Brevo > Settings > SMTP & API > API Keys |
BREVO_LIST_ID | Brevo > Contacts > Lists > Your list |
UMAMI_WEBSITE_ID | Umami > Settings > Websites > Your site |
GITHUB_REPO | Your GitHub repository (e.g., org/repo-name) |
Section 2: Project Scaffolding
Checkpoint: After this section,
npm run buildshould succeed with a blank Astro site.
2.1 Initialize the Project
# Create project directory
mkdir {{CLOUDFLARE_PROJECT_NAME}} && cd {{CLOUDFLARE_PROJECT_NAME}}
# Initialize Astro project
npm create astro@latest . -- --template minimal --install --git --typescript strict
# Install dependencies with pinned versions
npm install @astrojs/cloudflare@^12.5.0 @astrojs/rss@^4.0.13 @astrojs/sitemap@^3.3.1 @astrojs/tailwind@^6.0.2 @fontsource/inter@^5.1.1 @tailwindcss/typography@^0.5.19 astro@^5.17.1 lucide-astro@^0.556.0 sharp@^0.33.5 tailwindcss@^3.4.17
# Install dev dependencies
npm install -D @playwright/test@^1.58.2 typescript@^5.7.3
# Install Playwright browsers
npx playwright install --with-deps chromium2.2 Package.json
Replace the generated package.json with:
{
"name": "{{CLOUDFLARE_PROJECT_NAME}}",
"type": "module",
"version": "1.0.0",
"description": "{{SITE_NAME}} — {{TAGLINE}}",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"test": "npx playwright test",
"test:ui": "npx playwright test --ui",
"test:headed": "npx playwright test --headed",
"generate-pdfs": "node scripts/generate-pdfs.mjs"
},
"dependencies": {
"@astrojs/cloudflare": "^12.5.0",
"@astrojs/rss": "^4.0.13",
"@astrojs/sitemap": "^3.3.1",
"@astrojs/tailwind": "^6.0.2",
"@fontsource/inter": "^5.1.1",
"@tailwindcss/typography": "^0.5.19",
"astro": "^5.17.1",
"lucide-astro": "^0.556.0",
"sharp": "^0.33.5",
"tailwindcss": "^3.4.17"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"typescript": "^5.7.3"
}
}2.3 Astro Configuration
Create astro.config.mjs:
// @ts-check
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import sitemap from '@astrojs/sitemap';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
site: process.env.SITE_URL || '{{SITE_URL}}',
adapter: cloudflare(),
integrations: [
tailwind(),
sitemap({
filter: (page) => !page.includes('/thank-you'),
}),
],
});2.4 Tailwind Configuration
Create tailwind.config.mjs:
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {
colors: {
primary: '{{BRAND_PRIMARY}}',
secondary: '{{BRAND_SECONDARY}}',
accent: {
DEFAULT: '{{BRAND_ACCENT}}',
hover: '{{BRAND_ACCENT_HOVER}}',
active: '{{BRAND_ACCENT_ACTIVE}}',
},
parchment: '{{BRAND_BACKGROUND}}',
'warm-stone': '{{BRAND_WARM_STONE}}',
charcoal: '{{BRAND_CHARCOAL}}',
'navy-mid': '{{BRAND_NAVY_MID}}',
pewter: '{{BRAND_PEWTER}}',
},
fontFamily: {
sans: ['{{BRAND_FONT_FAMILY}}', '{{BRAND_FONT_FAMILY_FALLBACK}}'],
},
borderRadius: {
button: '6px',
card: '12px',
modal: '16px',
},
boxShadow: {
card: '0 1px 3px rgba(2, 5, 50, 0.06)',
'card-hover': '0 4px 12px rgba(2, 5, 50, 0.10)',
dropdown: '0 4px 16px rgba(2, 5, 50, 0.12)',
focus: '0 0 0 3px rgba(9, 22, 82, 0.1)',
},
backgroundImage: {
'hero-gradient': 'linear-gradient(135deg, {{BRAND_PRIMARY}} 0%, {{BRAND_SECONDARY}} 65%, {{BRAND_ACCENT}} 130%)',
'subtle-gradient': 'linear-gradient(180deg, {{BRAND_BACKGROUND}} 0%, {{BRAND_WARM_STONE}} 100%)',
},
maxWidth: {
content: '1200px',
},
},
},
plugins: [
require('@tailwindcss/typography'),
function({ addComponents, theme }) {
addComponents({
'.prose blockquote': {
borderLeftWidth: '4px',
borderLeftColor: theme('colors.accent.DEFAULT'),
backgroundColor: theme('colors.warm-stone'),
borderRadius: theme('borderRadius.card'),
padding: '1rem 1.25rem',
fontStyle: 'normal',
color: theme('colors.charcoal'),
quotes: 'none',
},
'.prose blockquote p:first-of-type::before': {
content: 'none',
},
'.prose blockquote p:last-of-type::after': {
content: 'none',
},
});
},
],
};2.5 TypeScript Configuration
Create tsconfig.json:
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"strictNullChecks": true
}
}2.6 Wrangler Configuration
Create wrangler.toml:
name = "{{CLOUDFLARE_PROJECT_NAME}}"
compatibility_date = "2024-01-01"
[vars]
TURNSTILE_SITE_KEY = "{{TURNSTILE_SITE_KEY}}"
BREVO_SENDER_EMAIL = "{{SENDER_EMAIL}}"
BREVO_SENDER_NAME = "{{SENDER_NAME}}"
SITE_NAME = "{{SITE_NAME}}"
SITE_URL = "{{SITE_URL}}"
# Secrets are set via 'wrangler pages secret put' -- NEVER put them here
# BREVO_API_KEY -> set via wrangler pages secret put
# TURNSTILE_SECRET_KEY -> set via wrangler pages secret put
# BREVO_LIST_ID -> set via wrangler pages secret put2.7 Local Development Variables
Create .dev.vars (for local development; never committed):
BREVO_API_KEY=your-brevo-api-key
TURNSTILE_SECRET_KEY=your-turnstile-secret
BREVO_LIST_ID=2
2.8 Git Ignore
Create .gitignore:
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.*
.dev.vars
# wrangler
.wrangler/
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
# Playwright
test-results/
playwright-report/
blob-report/
# QA/development screenshots
/*.png
_screenshots/2.9 Global CSS
Create src/styles/global.css:
@import '@fontsource/inter/400.css';
@import '@fontsource/inter/500.css';
@import '@fontsource/inter/600.css';
@import '@fontsource/inter/700.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
scroll-behavior: smooth;
background-color: {{BRAND_BACKGROUND}};
color: {{BRAND_CHARCOAL}};
}
body {
font-family: '{{BRAND_FONT_FAMILY}}', {{BRAND_FONT_FAMILY_FALLBACK}};
line-height: 1.6;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
}
/* Scroll reveal animations -- scoped to JS-ready so content is visible without JS */
@layer utilities {
.js-ready .reveal {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.js-ready .reveal.visible {
opacity: 1;
transform: translateY(0);
}
}2.10 Directory Structure
Create the following directory structure:
{{CLOUDFLARE_PROJECT_NAME}}/
public/
downloads/ # Generated PDFs
favicon/ # Favicon files
images/
logo/ # Brand logo files (PNG + WebP)
stock/ # Stock photography (JPG + WebP)
team/ # Team headshots (500x500 JPG + WebP)
og-default.png # Default Open Graph image (1200x630)
_headers # Cloudflare security headers
scripts/
generate-pdfs.mjs # PDF generation script
src/
components/
icons/ # SVG icon components
content/
blog/ # Blog post markdown files
layouts/
lib/
pages/
api/ # Server-side API routes
blog/ # Blog pages
for/ # Vertical landing pages
resources/ # Resource pages
styles/
tests/
e2e/ # Playwright E2E tests
.github/
workflows/ # CI/CD workflows
# Create directory structure
mkdir -p public/{downloads,favicon,images/{logo,stock,team}} scripts src/{components/icons,content/blog,layouts,lib,pages/{api,blog,for,resources},styles} tests/e2e .github/workflows2.11 Design Tokens Document
Create design-tokens.md in the project root (for reference, not built):
# Design Tokens — {{SITE_NAME}}
## Colors
| Token | Hex | Usage |
|-------|-----|-------|
| primary | {{BRAND_PRIMARY}} | Nav bg, footer bg, headings |
| secondary | {{BRAND_SECONDARY}} | Hover states, borders |
| accent | {{BRAND_ACCENT}} | CTAs, highlights, links |
| accent-hover | {{BRAND_ACCENT_HOVER}} | Button hover |
| accent-active | {{BRAND_ACCENT_ACTIVE}} | Button active/press |
| parchment | {{BRAND_BACKGROUND}} | Page background |
| warm-stone | {{BRAND_WARM_STONE}} | Secondary bg, card borders |
| charcoal | {{BRAND_CHARCOAL}} | Body text |
| navy-mid | {{BRAND_NAVY_MID}} | Mid-tone accents |
| pewter | {{BRAND_PEWTER}} | Muted text, captions |
## Typography
| Element | Font | Weight | Size |
|---------|------|--------|------|
| Body | {{BRAND_FONT_FAMILY}} | 400 | 16px (1rem) |
| Headings | {{BRAND_FONT_FAMILY}} | 600-700 | varies |
| Small text | {{BRAND_FONT_FAMILY}} | 400 | 14px (0.875rem) |
| Captions | {{BRAND_FONT_FAMILY}} | 400 | 12px (0.75rem) |
## Spacing
| Token | Value | Usage |
|-------|-------|-------|
| border-radius-button | 6px | Buttons, inputs |
| border-radius-card | 12px | Cards, modals |
| max-width-content | 1200px | Content container |
| min-h-touch | 44px | Touch targets (a11y) |
## CTA Hierarchy (3-tier)
| Tier | Class | Usage |
|------|-------|-------|
| Primary (copper) | `bg-accent text-primary` | Main CTAs |
| Secondary (outline) | `border-2 border-accent text-accent` | Supporting CTAs |
| Tertiary (navy) | `bg-primary text-parchment` | Low-emphasis CTAs |CHECKPOINT: Run npm run build and commit.
git init
git add -A
git commit -m "feat: project scaffolding with Astro 5, Tailwind, Cloudflare adapter"
git remote add origin https://github.com/{{GITHUB_REPO}}.git
git branch -M mainSection 3: Core Components + Layouts
Checkpoint: After this section, the site has a working layout with nav, footer, and all reusable components.
3.1 Constants
Create src/lib/constants.ts:
/**
* Site-wide constants derived from env vars (build-time).
* Use in Astro components and pages -- NOT in API routes (use getEnv there).
*/
export const SENDER_EMAIL = import.meta.env.BREVO_SENDER_EMAIL || '{{SENDER_EMAIL}}';
export const SITE_NAME = import.meta.env.SITE_NAME || '{{SITE_NAME}}';
export const SITE_URL = import.meta.env.SITE_URL || '{{SITE_URL}}';
export const BOOKING_URL = '{{BOOKING_URL}}';
export const CTA_TEXT = '{{CTA_TEXT}}';3.2 API Helpers
Create src/lib/api-helpers.ts:
/**
* Shared API route utilities: validation, CORS, Turnstile, Brevo headers.
*/
/** Allowed CORS origin for API responses. */
const ALLOWED_ORIGIN = '{{SITE_URL}}';
/** Resolve env var: Cloudflare runtime -> import.meta.env -> fallback. */
export function getEnv(locals: Record<string, any>, key: string): string {
const cfEnv = (locals as any).runtime?.env || {};
return cfEnv[key] || (import.meta.env[key] as string) || '';
}
/** Maximum field lengths for API input validation. */
export const MAX_LENGTHS: Record<string, number> = {
name: 200,
email: 254,
company: 200,
phone: 30,
service: 100,
message: 5000,
};
/** Check that a string value does not exceed the specified maximum length. */
export function validateLength(value: string | undefined, max: number): boolean {
return !value || value.length <= max;
}
const EMAIL_RE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
export function validateEmail(email: string): boolean {
return EMAIL_RE.test(email);
}
interface TurnstileResult {
success: boolean;
error?: string;
}
/** Verify a Turnstile token against Cloudflare's siteverify endpoint. */
export async function verifyTurnstile(
secret: string,
token: string | undefined,
): Promise<TurnstileResult> {
if (!token) {
return { success: false, error: 'Verification required. Please try again.' };
}
const res = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret, response: token }),
},
);
const result = await res.json();
if (!result.success) {
return { success: false, error: 'Verification failed. Please try again.' };
}
return { success: true };
}
export function brevoHeaders(apiKey: string): Record<string, string> {
return {
accept: 'application/json',
'content-type': 'application/json',
'api-key': apiKey,
};
}
export function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
export function jsonResponse(
body: Record<string, unknown>,
status = 200,
): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
},
});
}3.3 Base Layout
Create src/layouts/BaseLayout.astro:
---
/**
* Base layout -- wraps every page with nav, footer, meta tags, OG, Cal.com embed.
*/
import Nav from '../components/Nav.astro';
import Footer from '../components/Footer.astro';
import { SITE_URL, SITE_NAME } from '../lib/constants';
import '../styles/global.css';
interface Props {
title: string;
description?: string;
ogImage?: string;
noindex?: boolean;
}
const { title, description = '{{META_DESCRIPTION}}', ogImage = '{{OG_IMAGE_PATH}}', noindex = false } = Astro.props;
const canonicalUrl = new URL(Astro.url.pathname, SITE_URL);
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="description" content={description} />
{noindex && <meta name="robots" content="noindex, nofollow" />}
<link rel="canonical" href={canonicalUrl} />
<!-- Preconnect to third-party origins (Cal.com omitted -- lazy-loaded on click) -->
<link rel="preconnect" href="https://challenges.cloudflare.com" />
<link rel="preconnect" href="https://cloud.umami.is" />
<!-- Umami Analytics (cookie-free, GDPR-friendly) -->
<script defer src="https://cloud.umami.is/script.js" data-website-id="{{UMAMI_WEBSITE_ID}}" data-domains="{{DOMAIN}}"></script>
<!-- Favicons -->
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/favicon/site.webmanifest" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={`${SITE_URL}${ogImage}`} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:site_name" content={SITE_NAME} />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={`${SITE_URL}${ogImage}`} />
<!-- RSS -->
<link rel="alternate" type="application/rss+xml" title={SITE_NAME} href={`${SITE_URL}/rss.xml`} />
<!-- Cal.com popup embed -- lazy-loaded on first booking click -->
<script is:inline>
(function() {
var calLoaded = false;
function loadCal() {
if (calLoaded) return;
calLoaded = true;
(function (C, A, L) { var p = function (a, ar) { a.q.push(ar); }; var d = C.document; C.Cal = C.Cal || function () { var cal = C.Cal; if (!cal.loaded) { cal.ns = {}; cal.q = cal.q || []; d.head.appendChild(d.createElement("script")).src = A; cal.loaded = true; } if (arguments[0] === L) { var api = function () { p(api, arguments); }; var namespace = arguments[1]; api.q = api.q || []; if (typeof namespace === "string") { cal.ns[namespace] = cal.ns[namespace] || api; p(cal.ns[namespace], arguments); p(cal, ["initNamespace", namespace]); } else p(cal, arguments); return; } p(cal, arguments); }; })(window, "https://app.cal.com/embed/embed.js", "init");
Cal("init", {origin:"https://cal.com"});
Cal("ui", {
theme: "light",
styles: { branding: { brandColor: "{{BRAND_ACCENT}}" } },
hideEventTypeDetails: false,
layout: "month_view"
});
}
document.addEventListener("click", function(e) {
var el = e.target.closest("[data-cal-link]");
if (!el) return;
e.preventDefault();
e.stopPropagation();
if (window.umami) { window.umami.track("book-call", { page: location.pathname }); }
loadCal();
var config = {};
try { config = JSON.parse(el.getAttribute("data-cal-config") || "{}"); } catch(err) {}
var attempts = 0;
function openModal() {
if (window.Cal && window.Cal.loaded) {
Cal("modal", {
calLink: el.getAttribute("data-cal-link"),
config: config
});
} else if (++attempts < 100) {
setTimeout(openModal, 100);
} else {
window.location.href = el.getAttribute("href") || "https://cal.com/" + el.getAttribute("data-cal-link");
}
}
openModal();
}, true);
})();
/* Style the Cal.com modal shadow DOM */
(function() {
var calStyles = [
'.my-backdrop {',
' background: rgba(10, 25, 47, 0.55) !important;',
' -webkit-backdrop-filter: blur(8px) !important;',
' backdrop-filter: blur(8px) !important;',
' display: flex !important;',
' flex-direction: column !important;',
' align-items: center !important;',
' justify-content: center !important;',
'}',
'.modal-box {',
' position: relative !important;',
' left: auto !important;',
' top: auto !important;',
' transform: none !important;',
' max-width: 1000px !important;',
' width: calc(100% - 48px) !important;',
' border-radius: 16px !important;',
' overflow: hidden !important;',
' box-shadow: 0 25px 60px -12px rgba(0,0,0,0.45), 0 0 0 1px rgba(255,255,255,0.1) !important;',
' animation: calModalIn 0.25s ease-out !important;',
'}',
'.header {',
' max-width: 1000px !important;',
' width: calc(100% - 48px) !important;',
' display: flex !important;',
' justify-content: flex-end !important;',
' padding-bottom: 8px !important;',
' position: relative !important;',
' z-index: 10 !important;',
'}',
'button.close {',
' width: 36px !important;',
' height: 36px !important;',
' border-radius: 50% !important;',
' background: rgba(255,255,255,0.92) !important;',
' box-shadow: 0 2px 12px rgba(0,0,0,0.18) !important;',
' display: flex !important;',
' align-items: center !important;',
' justify-content: center !important;',
' transition: background 0.15s, transform 0.15s, box-shadow 0.15s !important;',
' cursor: pointer !important;',
'}',
'button.close:hover {',
' background: #fff !important;',
' transform: scale(1.08) !important;',
' box-shadow: 0 4px 16px rgba(0,0,0,0.22) !important;',
'}',
'#message-container {',
' position: absolute !important;',
'}',
'iframe.cal-embed {',
' border-radius: 16px !important;',
'}',
'.modal-box::after {',
' content: "" !important;',
' position: absolute !important;',
' bottom: 0 !important;',
' left: 0 !important;',
' right: 0 !important;',
' height: 80px !important;',
' background: linear-gradient(to bottom, rgba(255,255,255,0) 0%, #fff 35%) !important;',
' pointer-events: none !important;',
' border-radius: 0 0 16px 16px !important;',
' z-index: 5 !important;',
'}',
'@keyframes calModalIn {',
' from { opacity: 0; transform: scale(0.96) translateY(8px); }',
' to { opacity: 1; transform: scale(1) translateY(0); }',
'}',
'@media (max-width: 640px) {',
' .modal-box {',
' width: 100% !important;',
' height: 100% !important;',
' max-width: 100% !important;',
' border-radius: 0 !important;',
' animation: none !important;',
' }',
' .header {',
' width: 100% !important;',
' max-width: 100% !important;',
' padding: 12px 16px !important;',
' }',
' iframe.cal-embed {',
' border-radius: 0 !important;',
' height: 100% !important;',
' max-height: 100% !important;',
' }',
'}',
].join('\n');
function injectCalStyles(el) {
if (!el.shadowRoot || el.shadowRoot.querySelector('#sol-cal-s')) return;
var s = document.createElement('style');
s.id = 'sol-cal-s';
s.textContent = calStyles;
el.shadowRoot.appendChild(s);
}
function startObserver() {
var obs = new MutationObserver(function() {
var m = document.querySelector('cal-modal-box');
if (m) injectCalStyles(m);
});
obs.observe(document.documentElement, { childList: true, subtree: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startObserver);
} else {
startObserver();
}
})();
</script>
<meta name="generator" content={Astro.generator} />
<script is:inline>document.documentElement.classList.add('js-ready');</script>
<slot name="head" />
</head>
<body class="bg-parchment text-charcoal min-h-screen flex flex-col">
<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-accent focus:text-primary focus:rounded-button focus:font-medium focus:text-sm">Skip to main content</a>
<Nav />
<main id="main-content" class="flex-1">
<slot />
</main>
<Footer />
<script is:inline>
/* Scroll reveal observer */
(function() {
var observer = new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
entry.target.classList.add('visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.1, rootMargin: '0px 0px -40px 0px' });
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.reveal').forEach(function(el) {
observer.observe(el);
});
});
})();
</script>
</body>
</html>3.4 Navigation Component
Create src/components/Nav.astro:
---
import { BOOKING_URL, CTA_TEXT } from '../lib/constants';
---
<header class="bg-primary sticky top-0 z-50">
<nav class="max-w-content mx-auto flex items-center justify-between px-6 h-20">
<a href="/" class="flex items-center">
<picture>
<source srcset="/images/logo/logo-horizontal-dark.webp" type="image/webp" />
<img
src="/images/logo/logo-horizontal-dark.png"
alt="{{SITE_NAME}}"
class="h-10 md:h-12"
width="198"
height="48"
/>
</picture>
</a>
<!-- Desktop Nav -->
<div class="hidden lg:flex items-center gap-1">
<!-- Solutions Dropdown -->
<div class="relative group">
<button aria-haspopup="true" aria-expanded="false" class="nav-dropdown-btn text-warm-stone text-sm font-medium hover:text-accent transition-colors px-3 py-2 flex items-center gap-1">
Solutions
<svg class="w-3.5 h-3.5 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div class="nav-dropdown absolute top-full left-0 pt-2 opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible transition-all duration-200">
<div class="bg-white rounded-card shadow-dropdown border border-warm-stone py-2 min-w-[220px]" role="menu">
<!-- CUSTOMIZE: Replace with {{NAV_SOLUTIONS}} items -->
<a href="/#assessment" role="menuitem" class="block px-4 py-2.5 text-sm text-charcoal hover:bg-warm-stone hover:text-accent transition-colors">Assessment</a>
<a href="/#services" role="menuitem" class="block px-4 py-2.5 text-sm text-charcoal hover:bg-warm-stone hover:text-accent transition-colors">All Services</a>
<a href="/#how-we-work" role="menuitem" class="block px-4 py-2.5 text-sm text-charcoal hover:bg-warm-stone hover:text-accent transition-colors">How We Work</a>
</div>
</div>
</div>
<!-- Industries Dropdown -->
<div class="relative group">
<button aria-haspopup="true" aria-expanded="false" class="nav-dropdown-btn text-warm-stone text-sm font-medium hover:text-accent transition-colors px-3 py-2 flex items-center gap-1">
Industries
<svg class="w-3.5 h-3.5 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div class="nav-dropdown absolute top-full left-0 pt-2 opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible transition-all duration-200">
<div class="bg-white rounded-card shadow-dropdown border border-warm-stone py-2 min-w-[220px]" role="menu">
<!-- CUSTOMIZE: Replace with {{NAV_INDUSTRIES}} items -->
<a href="/for/industry-1" role="menuitem" class="block px-4 py-2.5 text-sm text-charcoal hover:bg-warm-stone hover:text-accent transition-colors">Industry 1</a>
<a href="/for/industry-2" role="menuitem" class="block px-4 py-2.5 text-sm text-charcoal hover:bg-warm-stone hover:text-accent transition-colors">Industry 2</a>
</div>
</div>
</div>
<!-- Resources Dropdown -->
<div class="relative group">
<button aria-haspopup="true" aria-expanded="false" class="nav-dropdown-btn text-warm-stone text-sm font-medium hover:text-accent transition-colors px-3 py-2 flex items-center gap-1">
Resources
<svg class="w-3.5 h-3.5 transition-transform group-hover:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>
</button>
<div class="nav-dropdown absolute top-full left-0 pt-2 opacity-0 invisible group-hover:opacity-100 group-hover:visible group-focus-within:opacity-100 group-focus-within:visible transition-all duration-200">
<div class="bg-white rounded-card shadow-dropdown border border-warm-stone py-2 min-w-[220px]" role="menu">
<!-- CUSTOMIZE: Replace with {{NAV_RESOURCES}} items -->
<a href="/blog" role="menuitem" class="block px-4 py-2.5 text-sm text-charcoal hover:bg-warm-stone hover:text-accent transition-colors">Blog</a>
<a href="/resources/faq" role="menuitem" class="block px-4 py-2.5 text-sm text-charcoal hover:bg-warm-stone hover:text-accent transition-colors">FAQ</a>
<div class="border-t border-warm-stone my-1"></div>
<a href="/resources" role="menuitem" class="block px-4 py-2.5 text-sm text-charcoal hover:bg-warm-stone hover:text-accent transition-colors">All Resources</a>
</div>
</div>
</div>
<a href="/about" class="text-warm-stone text-sm font-medium hover:text-accent transition-colors px-3 py-2">About</a>
<a href="/contact" class="text-warm-stone text-sm font-medium hover:text-accent transition-colors px-3 py-2">Contact</a>
<a
href={BOOKING_URL}
data-cal-namespace=""
data-cal-link="{{CAL_LINK}}"
data-cal-config='{"layout":"month_view"}'
class="ml-3 inline-flex items-center px-5 py-2.5 bg-accent text-primary text-sm font-medium uppercase tracking-wider rounded-button hover:bg-accent-hover transition-colors min-h-[44px]"
>
{CTA_TEXT}
</a>
</div>
<!-- Mobile Hamburger -->
<button
id="mobile-menu-btn"
class="lg:hidden text-warm-stone p-2 min-w-[44px] min-h-[44px] flex items-center justify-center"
aria-label="Open navigation menu"
aria-expanded="false"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path id="hamburger-icon" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<path id="close-icon" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</nav>
<!-- Mobile Menu -->
<div id="mobile-menu" class="hidden lg:hidden bg-primary border-t border-secondary">
<div class="px-6 py-4 flex flex-col gap-1">
<!-- CUSTOMIZE: Mirror desktop nav structure for mobile -->
<p class="text-xs uppercase tracking-wider text-pewter mt-2 mb-1 px-2">Solutions</p>
<a href="/#assessment" class="text-warm-stone text-base font-medium hover:text-accent transition-colors py-2 px-2">Assessment</a>
<a href="/#services" class="text-warm-stone text-base font-medium hover:text-accent transition-colors py-2 px-2">All Services</a>
<p class="text-xs uppercase tracking-wider text-pewter mt-3 mb-1 px-2">Industries</p>
<a href="/for/industry-1" class="text-warm-stone text-base font-medium hover:text-accent transition-colors py-2 px-2">Industry 1</a>
<p class="text-xs uppercase tracking-wider text-pewter mt-3 mb-1 px-2">Resources</p>
<a href="/blog" class="text-warm-stone text-base font-medium hover:text-accent transition-colors py-2 px-2">Blog</a>
<a href="/resources" class="text-warm-stone text-base font-medium hover:text-accent transition-colors py-2 px-2">All Resources</a>
<div class="border-t border-secondary mt-3 pt-3">
<a href="/about" class="text-warm-stone text-base font-medium hover:text-accent transition-colors py-2 px-2 block">About</a>
<a href="/contact" class="text-warm-stone text-base font-medium hover:text-accent transition-colors py-2 px-2 block">Contact</a>
</div>
<a
href={BOOKING_URL}
data-cal-namespace=""
data-cal-link="{{CAL_LINK}}"
data-cal-config='{"layout":"month_view"}'
class="mt-3 inline-flex items-center justify-center px-5 py-2.5 bg-accent text-primary text-sm font-medium uppercase tracking-wider rounded-button hover:bg-accent-hover transition-colors min-h-[44px]"
>
{CTA_TEXT}
</a>
</div>
</div>
</header>
<script>
const btn = document.getElementById('mobile-menu-btn');
const menu = document.getElementById('mobile-menu');
const hamburger = document.getElementById('hamburger-icon');
const close = document.getElementById('close-icon');
btn?.addEventListener('click', () => {
const isOpen = menu?.classList.toggle('hidden') === false;
btn.setAttribute('aria-expanded', String(isOpen));
hamburger?.classList.toggle('hidden', isOpen);
close?.classList.toggle('hidden', !isOpen);
});
// Close menu when clicking a link
menu?.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => {
menu.classList.add('hidden');
btn?.setAttribute('aria-expanded', 'false');
hamburger?.classList.remove('hidden');
close?.classList.add('hidden');
});
});
// Desktop dropdown keyboard accessibility
document.querySelectorAll('.nav-dropdown-btn').forEach(trigger => {
const parent = trigger.closest('.group');
const dropdown = parent?.querySelector('.nav-dropdown');
if (!parent || !dropdown) return;
parent.addEventListener('focusin', () => trigger.setAttribute('aria-expanded', 'true'));
parent.addEventListener('focusout', (e: FocusEvent) => {
if (!parent.contains(e.relatedTarget as Node)) {
trigger.setAttribute('aria-expanded', 'false');
}
});
parent.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') {
trigger.setAttribute('aria-expanded', 'false');
trigger.focus();
}
});
});
</script>3.5 Footer Component
Create src/components/Footer.astro:
---
import NewsletterSignup from './NewsletterSignup.astro';
import LinkedInIcon from './icons/LinkedInIcon.astro';
import XTwitterIcon from './icons/XTwitterIcon.astro';
import { BOOKING_URL, CTA_TEXT } from '../lib/constants';
const currentYear = new Date().getFullYear();
---
<footer class="bg-primary text-warm-stone">
<!-- Newsletter bar -->
<div class="border-b border-secondary">
<div class="max-w-content mx-auto px-6 py-8">
<div class="max-w-xl mx-auto text-center">
<h3 class="text-lg font-semibold text-parchment mb-2">Stay in the loop</h3>
<p class="text-sm text-pewter mb-4">Insights and updates from {{SITE_NAME}}. No spam.</p>
<NewsletterSignup />
</div>
</div>
</div>
<div class="max-w-content mx-auto px-6 py-16">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-12">
<!-- Brand -->
<div>
<picture>
<source srcset="/images/logo/logo-horizontal-dark.webp" type="image/webp" />
<img
src="/images/logo/logo-horizontal-dark.png"
alt="{{SITE_NAME}}"
class="h-12 md:h-14 mb-4"
width="231"
height="56"
/>
</picture>
<p class="text-sm text-pewter leading-relaxed tracking-wide">
{{TAGLINE}}
</p>
</div>
<!-- Navigation -->
<div>
<h3 class="text-base font-semibold mb-4 text-parchment">Navigate</h3>
<ul class="space-y-2 text-sm">
<!-- CUSTOMIZE: Replace with {{FOOTER_NAV}} items -->
<li><a href="/#services" class="hover:text-accent transition-colors">Services</a></li>
<li><a href="/blog" class="hover:text-accent transition-colors">Blog</a></li>
<li><a href="/resources" class="hover:text-accent transition-colors">Resources</a></li>
<li><a href="/about" class="hover:text-accent transition-colors">About</a></li>
<li><a href="/contact" class="hover:text-accent transition-colors">Contact</a></li>
</ul>
</div>
<!-- Industries -->
<div>
<h3 class="text-base font-semibold mb-4 text-parchment">Industries</h3>
<ul class="space-y-2 text-sm">
<!-- CUSTOMIZE: Replace with {{FOOTER_INDUSTRIES}} items -->
<li><a href="/for/industry-1" class="hover:text-accent transition-colors">Industry 1</a></li>
</ul>
</div>
<!-- Connect -->
<div>
<h3 class="text-base font-semibold mb-4 text-parchment">Connect</h3>
<ul class="space-y-2 text-sm">
<li>
<span class="email-address" data-e1="{{EMAIL_PREFIX}}" data-e2="{{EMAIL_DOMAIN}}">
<a href="#" class="hover:text-accent transition-colors" onclick="return false;">Email us</a>
</span>
</li>
<li>
<span class="phone-number" data-p1="{{PHONE_P1}}" data-p2="{{PHONE_P2}}" data-p3="{{PHONE_P3}}">
<a href="#" class="hover:text-accent transition-colors" onclick="return false;">Call us</a>
</span>
</li>
<li>
<a href={BOOKING_URL} data-cal-namespace="" data-cal-link="{{CAL_LINK}}" data-cal-config='{"layout":"month_view"}' class="hover:text-accent transition-colors">{CTA_TEXT}</a>
</li>
</ul>
<div class="flex gap-4 mt-4">
<a href="{{LINKEDIN_URL}}" target="_blank" rel="noopener noreferrer" aria-label="LinkedIn" class="hover:text-accent transition-colors">
<LinkedInIcon />
</a>
<a href="https://x.com/{{TWITTER_HANDLE}}" target="_blank" rel="noopener noreferrer" aria-label="X / Twitter" class="hover:text-accent transition-colors">
<XTwitterIcon />
</a>
</div>
</div>
</div>
<!-- Disclaimer -->
<div class="border-t border-secondary mt-12 pt-6 text-xs text-pewter/70 max-w-3xl mx-auto text-center">
<p>{{LEGAL_DISCLAIMER}}</p>
</div>
<!-- Bottom Bar -->
<div class="border-t border-secondary mt-6 pt-6 flex flex-col sm:flex-row justify-between items-center gap-4 text-xs text-pewter">
<p>© {currentYear} {{COMPANY_LEGAL_NAME}}. All rights reserved.</p>
<div class="flex gap-4">
<a href="/privacy" class="hover:text-accent transition-colors">Privacy Policy</a>
<a href="/accessibility" class="hover:text-accent transition-colors">Accessibility</a>
<a href="/terms" class="hover:text-accent transition-colors">Terms of Service</a>
</div>
</div>
</div>
</footer>
<script>
// Phone number spam protection -- assemble on hover/focus
document.querySelectorAll('.phone-number').forEach(el => {
const link = el.querySelector('a');
if (!link) return;
const p1 = el.getAttribute('data-p1');
const p2 = el.getAttribute('data-p2');
const p3 = el.getAttribute('data-p3');
const num = `${p1}-${p2}-${p3}`;
el.addEventListener('mouseenter', () => {
link.textContent = num;
link.href = `tel:${p1}${p2}${p3}`;
link.onclick = null;
}, { once: true });
link.addEventListener('focus', () => {
link.textContent = num;
link.href = `tel:${p1}${p2}${p3}`;
link.onclick = null;
}, { once: true });
link.addEventListener('click', () => {
if ((window as any).umami) { (window as any).umami.track('phone-click'); }
});
});
// Email spam protection -- assemble on hover/focus
document.querySelectorAll('.email-address').forEach(el => {
const link = el.querySelector('a');
if (!link) return;
const e1 = el.getAttribute('data-e1');
const e2 = el.getAttribute('data-e2');
const addr = `${e1}@${e2}`;
el.addEventListener('mouseenter', () => {
link.textContent = addr;
link.href = `mailto:${addr}`;
link.onclick = null;
}, { once: true });
link.addEventListener('focus', () => {
link.textContent = addr;
link.href = `mailto:${addr}`;
link.onclick = null;
}, { once: true });
link.addEventListener('click', () => {
if ((window as any).umami) { (window as any).umami.track('email-click'); }
});
});
</script>3.6 Icon Components
Create src/components/icons/LinkedInIcon.astro:
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>Create src/components/icons/XTwitterIcon.astro:
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>3.7 Contact Form Component
Create src/components/ContactForm.astro:
---
/**
* Reusable contact form with Turnstile protection.
* Used in: Homepage (inline), /contact page (full).
*/
interface Props {
variant?: 'inline' | 'full';
formId?: string;
}
const { variant = 'inline', formId } = Astro.props;
const turnstileSiteKey = import.meta.env.TURNSTILE_SITE_KEY || '';
const prefix = formId ? '' : `contact-${Math.random().toString(36).slice(2, 8)}-`;
---
<div class="contact-form-wrapper">
{variant === 'full' && (
<h2 class="text-2xl font-bold text-primary mb-6">Send us a message</h2>
)}
<form
class="contact-form space-y-4"
data-sitekey={turnstileSiteKey}
{...(formId ? { id: formId } : {})}
>
<!-- Honeypot -->
<div class="hidden" aria-hidden="true">
<label for={`${prefix}_url`}>URL</label>
<input type="text" id={`${prefix}_url`} name="_url" tabindex="-1" autocomplete="off" />
</div>
<div class="grid sm:grid-cols-2 gap-4">
<div>
<label for={`${prefix}name`} class="block text-sm font-medium text-charcoal mb-1">Name <span class="text-red-600">*</span></label>
<input
type="text"
id={`${prefix}name`}
name="name"
required
class="w-full h-11 px-4 bg-white border border-warm-stone rounded-button text-base focus:border-secondary focus:shadow-focus outline-none transition-all"
placeholder="Your name"
/>
</div>
<div>
<label for={`${prefix}email`} class="block text-sm font-medium text-charcoal mb-1">Email <span class="text-red-600">*</span></label>
<input
type="email"
id={`${prefix}email`}
name="email"
required
class="w-full h-11 px-4 bg-white border border-warm-stone rounded-button text-base focus:border-secondary focus:shadow-focus outline-none transition-all"
placeholder="you@company.com"
/>
</div>
</div>
<div class="grid sm:grid-cols-2 gap-4">
<div>
<label for={`${prefix}company`} class="block text-sm font-medium text-charcoal mb-1">Company</label>
<input
type="text"
id={`${prefix}company`}
name="company"
class="w-full h-11 px-4 bg-white border border-warm-stone rounded-button text-base focus:border-secondary focus:shadow-focus outline-none transition-all"
placeholder="Your organization"
/>
</div>
<div>
<label for={`${prefix}phone`} class="block text-sm font-medium text-charcoal mb-1">Phone</label>
<input
type="tel"
id={`${prefix}phone`}
name="phone"
class="w-full h-11 px-4 bg-white border border-warm-stone rounded-button text-base focus:border-secondary focus:shadow-focus outline-none transition-all"
placeholder="(optional)"
/>
</div>
</div>
<div>
<label for={`${prefix}service`} class="block text-sm font-medium text-charcoal mb-1">What can we help with? <span class="text-red-600">*</span></label>
<select
id={`${prefix}service`}
name="service"
required
class="w-full h-11 px-4 bg-white border border-warm-stone rounded-button text-base focus:border-secondary focus:shadow-focus outline-none transition-all"
>
<option value="">Select a service...</option>
<!-- CUSTOMIZE: Replace with {{SERVICE_OPTIONS}} items -->
<option value="general">General inquiry</option>
</select>
</div>
<div>
<label for={`${prefix}message`} class="block text-sm font-medium text-charcoal mb-1">Message <span class="text-red-600">*</span></label>
<textarea
id={`${prefix}message`}
name="message"
required
rows="5"
class="w-full px-4 py-3 bg-white border border-warm-stone rounded-button text-base focus:border-secondary focus:shadow-focus outline-none transition-all resize-vertical"
placeholder="Tell us about your situation..."
></textarea>
</div>
<!-- Turnstile widget -->
<div class="cf-turnstile-container my-4"></div>
<div
class="contact-form-status hidden rounded-button p-4 text-sm"
{...(formId ? { id: 'form-status' } : {})}
></div>
<button
type="submit"
class="inline-flex items-center px-6 py-3 bg-accent text-primary text-sm font-medium uppercase tracking-wider rounded-button hover:bg-accent-hover transition-colors min-h-[44px] disabled:opacity-40 disabled:cursor-not-allowed"
>
Send Message
</button>
<p class="text-xs text-charcoal/60 mt-2">
By submitting, you agree to our <a href="/privacy" class="underline hover:text-accent">Privacy Policy</a>.
</p>
</form>
</div>
<!-- Turnstile Script (browser deduplicates identical src) -->
<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
<script>
document.querySelectorAll('.contact-form-wrapper').forEach(wrapper => {
const form = wrapper.querySelector('.contact-form') as HTMLFormElement;
const statusEl = wrapper.querySelector('.contact-form-status') as HTMLElement;
const turnstileContainer = wrapper.querySelector('.cf-turnstile-container') as HTMLElement;
if (!form) return;
const siteKey = form.getAttribute('data-sitekey');
let turnstileWidgetId: string | null = null;
let turnstileToken = '';
function initTurnstile() {
if (!siteKey || !turnstileContainer || !(window as any).turnstile) return;
if (turnstileWidgetId !== null) return;
turnstileWidgetId = (window as any).turnstile.render(turnstileContainer, {
sitekey: siteKey,
callback: (token: string) => { turnstileToken = token; },
'expired-callback': () => { turnstileToken = ''; },
});
}
if ((window as any).turnstile) {
initTurnstile();
} else {
const interval = setInterval(() => {
if ((window as any).turnstile) {
initTurnstile();
clearInterval(interval);
}
}, 200);
setTimeout(() => clearInterval(interval), 10000);
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement;
const originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = 'Sending...';
if (statusEl) {
statusEl.classList.add('hidden');
}
const formData = new FormData(form);
const payload = {
name: formData.get('name'),
email: formData.get('email'),
company: formData.get('company'),
phone: formData.get('phone'),
service: formData.get('service'),
message: formData.get('message'),
_url: formData.get('_url'),
turnstileToken,
};
try {
const res = await fetch('/api/send-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const result = await res.json();
if (result.success) {
if ((window as any).umami) { (window as any).umami.track('contact-form-submit', { service: payload.service as string }); }
window.location.href = '/thank-you';
} else {
showStatus(result.error || 'Something went wrong. Please try again.', false);
}
} catch {
showStatus('Network error. Please try again or email us directly.', false);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = originalText;
if (turnstileWidgetId !== null && (window as any).turnstile) {
(window as any).turnstile.reset(turnstileWidgetId);
turnstileToken = '';
}
}
});
function showStatus(message: string, success: boolean) {
if (!statusEl) return;
statusEl.textContent = message;
statusEl.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'bg-red-100', 'text-red-800');
statusEl.classList.add(success ? 'bg-green-100' : 'bg-red-100', success ? 'text-green-800' : 'text-red-800');
}
});
</script>3.8 Newsletter Signup Component
Create src/components/NewsletterSignup.astro:
---
/**
* Reusable newsletter signup form with Turnstile protection.
* Used in: Footer (inline), /newsletter page (full).
*/
interface Props {
variant?: 'inline' | 'full';
}
const { variant = 'inline' } = Astro.props;
const turnstileSiteKey = import.meta.env.TURNSTILE_SITE_KEY || '';
const id = `newsletter-${Math.random().toString(36).slice(2, 8)}`;
---
<div class="newsletter-signup" data-id={id}>
{variant === 'full' && (
<h3 class="text-lg font-semibold text-primary mb-2">Get practical insights, weekly</h3>
<p class="text-sm text-charcoal mb-4">Updates from {{SITE_NAME}}. No spam, no jargon.</p>
)}
<form class="newsletter-form flex flex-col sm:flex-row gap-3 flex-wrap" data-sitekey={turnstileSiteKey}>
<input
type="email"
name="email"
required
placeholder="Enter your email"
class="flex-1 h-11 px-4 bg-white border border-warm-stone rounded-button text-base focus:border-secondary focus:shadow-focus outline-none transition-all"
/>
<button
type="submit"
class="inline-flex items-center justify-center px-5 py-2.5 bg-accent text-primary text-sm font-medium uppercase tracking-wider rounded-button hover:bg-accent-hover transition-colors min-h-[44px] whitespace-nowrap"
>
Subscribe
</button>
<div class="cf-turnstile-container w-full"></div>
</form>
<p class="text-xs text-pewter mt-2">
By subscribing, you agree to our <a href="/privacy" class="underline hover:text-accent">Privacy Policy</a>.
</p>
<div class="newsletter-status hidden mt-2 rounded-button p-3 text-sm"></div>
</div>
<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
<script>
document.querySelectorAll('.newsletter-signup').forEach(wrapper => {
const form = wrapper.querySelector('.newsletter-form') as HTMLFormElement;
const statusEl = wrapper.querySelector('.newsletter-status') as HTMLElement;
const turnstileContainer = wrapper.querySelector('.cf-turnstile-container') as HTMLElement;
if (!form) return;
const siteKey = form.getAttribute('data-sitekey');
let turnstileWidgetId: string | null = null;
let turnstileToken = '';
function initTurnstile() {
if (!siteKey || !turnstileContainer || !(window as any).turnstile) return;
if (turnstileWidgetId !== null) return;
turnstileWidgetId = (window as any).turnstile.render(turnstileContainer, {
sitekey: siteKey,
size: 'compact',
callback: (token: string) => { turnstileToken = token; },
'expired-callback': () => { turnstileToken = ''; },
});
}
if ((window as any).turnstile) {
initTurnstile();
} else {
const interval = setInterval(() => {
if ((window as any).turnstile) {
initTurnstile();
clearInterval(interval);
}
}, 200);
setTimeout(() => clearInterval(interval), 10000);
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const emailInput = form.querySelector('input[name="email"]') as HTMLInputElement;
const btn = form.querySelector('button') as HTMLButtonElement;
const email = emailInput?.value;
btn.disabled = true;
btn.textContent = 'Subscribing...';
statusEl.classList.add('hidden');
try {
const res = await fetch('/api/newsletter-subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, turnstileToken }),
});
const result = await res.json();
statusEl.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'bg-red-100', 'text-red-800');
if (result.success) {
if ((window as any).umami) { (window as any).umami.track('newsletter-subscribe'); }
statusEl.textContent = result.message || "You're subscribed!";
statusEl.classList.add('bg-green-100', 'text-green-800');
emailInput.value = '';
} else {
statusEl.textContent = result.error || 'Something went wrong.';
statusEl.classList.add('bg-red-100', 'text-red-800');
}
} catch {
statusEl.classList.remove('hidden');
statusEl.textContent = 'Network error. Please try again.';
statusEl.classList.add('bg-red-100', 'text-red-800');
} finally {
btn.disabled = false;
btn.textContent = 'Subscribe';
if (turnstileWidgetId !== null && (window as any).turnstile) {
(window as any).turnstile.reset(turnstileWidgetId);
turnstileToken = '';
}
}
});
});
</script>3.9 Blog Card Component
Create src/components/BlogCard.astro:
---
import type { CollectionEntry } from 'astro:content';
interface Props {
post: CollectionEntry<'blog'>;
}
const { post } = Astro.props;
---
<a href={`/blog/${post.id}`} class="group">
<article class="bg-white rounded-card shadow-card hover:shadow-card-hover transition-all border border-warm-stone overflow-hidden h-full flex flex-col">
{post.data.image && (
<picture>
<source srcset={post.data.image.replace('.jpg', '.webp')} type="image/webp" />
<img
src={post.data.image}
alt={post.data.imageAlt || ''}
class="w-full h-48 object-cover"
width="400"
height="192"
loading="lazy"
/>
</picture>
)}
<div class="p-6 flex-1 flex flex-col">
<div class="flex items-center gap-3 mb-3 text-xs">
<span class="bg-accent/10 text-accent px-2 py-1 rounded-full font-medium">
{post.data.pillar.replaceAll('-', ' ')}
</span>
<span class="text-pewter">{post.data.readingTime} min read</span>
</div>
<h2 class="text-lg font-semibold text-primary mb-2 group-hover:text-accent transition-colors">
{post.data.title}
</h2>
<p class="text-sm text-charcoal leading-relaxed mb-4 flex-1">
{post.data.description}
</p>
<div class="text-xs text-pewter">
{post.data.date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
</div>
</div>
</article>
</a>3.10 Team Member Component
Create src/components/TeamMember.astro:
---
import LinkedInIcon from './icons/LinkedInIcon.astro';
interface Props {
name: string;
role: string;
bio: string[];
imageSrc: string;
imageWebp: string;
imageAlt?: string;
linkedIn?: string;
imagePosition?: 'left' | 'right';
priority?: boolean;
}
const {
name,
role,
bio,
imageSrc,
imageWebp,
imageAlt = name,
linkedIn,
imagePosition = 'right',
priority = false,
} = Astro.props;
---
<div class="grid md:grid-cols-5 gap-12 items-start">
<div class={`md:col-span-3 ${imagePosition === 'left' ? 'md:order-last' : ''}`}>
<!-- Mobile: inline avatar + name/role -->
<div class="flex items-center gap-4 md:hidden mb-6">
<picture>
<source srcset={imageWebp} type="image/webp" />
<img
src={imageSrc}
alt={imageAlt}
class="w-20 h-20 rounded-full border-2 border-warm-stone object-cover shadow-card-hover flex-shrink-0"
width="80"
height="80"
loading="lazy"
/>
</picture>
<div>
<h3 class="text-xl font-bold text-primary">{name}</h3>
<p class="text-accent font-semibold text-sm uppercase tracking-wider">{role}</p>
</div>
</div>
<!-- Desktop: name/role without avatar -->
<div class="hidden md:block mb-6" aria-hidden="true">
<p class="text-xl font-bold text-primary">{name}</p>
<p class="text-accent font-semibold text-sm uppercase tracking-wider mt-1">{role}</p>
</div>
<div class="prose prose-lg max-w-none text-charcoal space-y-4">
{bio.map((paragraph) => <p>{paragraph}</p>)}
</div>
<slot />
{linkedIn && (
<div class="mt-6 flex items-center gap-4">
<a
href={linkedIn}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 text-secondary hover:text-accent transition-colors font-medium"
>
<LinkedInIcon />
Connect on LinkedIn
</a>
</div>
)}
</div>
<!-- Desktop: large circular headshot -->
<div class={`md:col-span-2 hidden md:flex justify-center ${imagePosition === 'left' ? 'md:order-first' : ''}`}>
<picture>
<source srcset={imageWebp} type="image/webp" />
<img
src={imageSrc}
alt={imageAlt}
class="w-56 h-56 rounded-full border-2 border-warm-stone object-cover shadow-card-hover"
width="224"
height="224"
loading={priority ? 'eager' : 'lazy'}
fetchpriority={priority ? 'high' : undefined}
/>
</picture>
</div>
</div>3.11 Stats Banner Component
Create src/components/StatsBanner.astro:
---
/**
* Key metrics strip with dark navy background.
* CUSTOMIZE: Replace stats with client-specific credibility metrics.
*/
---
<section class="py-12 md:py-16 bg-primary">
<div class="max-w-content mx-auto px-6">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-8 text-center">
<div>
<p class="text-3xl md:text-4xl font-bold text-accent mb-2">XX+</p>
<p class="text-warm-stone text-sm">Years of experience</p>
</div>
<div>
<p class="text-3xl md:text-4xl font-bold text-accent mb-2">XX</p>
<p class="text-warm-stone text-sm">Key metric two</p>
</div>
<div>
<p class="text-3xl md:text-4xl font-bold text-accent mb-2">XX</p>
<p class="text-warm-stone text-sm">Key metric three</p>
</div>
</div>
</div>
</section>3.12 FAQ Component
Create src/components/FAQ.astro:
---
import { BOOKING_URL, CTA_TEXT } from '../lib/constants';
/**
* FAQ component with category filtering and JSON-LD schema.
* CUSTOMIZE: Replace FAQ_CATEGORIES and FAQ_ITEMS with client content.
*/
type Category = string;
interface FaqItem {
q: string;
a: string;
category: Category;
}
// CUSTOMIZE: Replace with {{FAQ_ITEMS}}
const faqs: FaqItem[] = [
{
q: "What services do you offer?",
a: "<p>We offer a range of services including...</p>",
category: 'general',
},
];
// CUSTOMIZE: Replace with {{FAQ_CATEGORIES}}
const categories: { key: string; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'general', label: 'General' },
];
const categoryKeys = categories.filter(c => c.key !== 'all').map(c => c.key);
const categoryMeta: Record<string, { label: string }> = Object.fromEntries(
categories.filter(c => c.key !== 'all').map(c => [c.key, { label: c.label }])
);
// FAQPage JSON-LD
const faqSchema = {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": faqs.map(faq => ({
"@type": "Question",
"name": faq.q,
"acceptedAnswer": {
"@type": "Answer",
"text": faq.a.replace(/<[^>]*>/g, ''),
},
})),
};
---
<section id="faq" class="py-16 md:py-24 bg-parchment">
<div class="max-w-4xl mx-auto px-6">
<h2 class="text-2xl md:text-3xl font-bold text-primary mb-4 text-center">
Frequently Asked Questions
</h2>
<p class="text-charcoal text-center max-w-2xl mx-auto mb-8">
Common questions about our services
</p>
<!-- Category filter pills -->
<div class="flex flex-wrap justify-center gap-2 mb-10" role="tablist" aria-label="Filter FAQ by category">
{categories.map((cat, i) => (
<button
role="tab"
aria-selected={i === 0 ? 'true' : 'false'}
data-filter={cat.key}
class:list={[
'px-4 py-2 rounded-full text-sm font-medium transition-colors min-h-[44px]',
i === 0
? 'bg-accent text-primary font-semibold'
: 'bg-accent/10 text-accent hover:bg-accent/20',
]}
>
{cat.label}
</button>
))}
</div>
<!-- FAQ accordion -->
<div class="space-y-4" id="faq-list">
{categoryKeys.map((catKey) => (
<>
<div class="faq-group-header flex items-center gap-3 pt-4 pb-1" data-group={catKey}>
<span class="text-xs uppercase tracking-wider text-pewter font-medium">
{categoryMeta[catKey].label}
</span>
</div>
{faqs.filter(f => f.category === catKey).map((faq) => (
<details
class="group bg-white rounded-card border border-warm-stone shadow-card open:border-l-4 open:border-l-accent"
data-category={faq.category}
>
<summary class="flex items-center justify-between cursor-pointer px-6 py-4 text-left font-semibold text-primary hover:text-secondary transition-colors min-h-[44px]">
<span>{faq.q}</span>
<svg class="w-5 h-5 flex-shrink-0 ml-4 transition-transform group-open:rotate-180 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="px-6 pb-4 text-charcoal leading-relaxed" set:html={faq.a} />
</details>
))}
</>
))}
</div>
<!-- Still have questions? CTA -->
<div class="bg-white rounded-card p-6 shadow-card text-center mt-8">
<p class="text-lg font-semibold text-primary mb-3">Still have questions? Let's talk.</p>
<a
href={BOOKING_URL}
data-cal-link="{{CAL_LINK}}"
class="inline-block bg-accent hover:bg-accent-hover text-primary font-semibold py-3 px-8 rounded-full transition-colors min-h-[44px]"
>
{CTA_TEXT}
</a>
</div>
</div>
<!-- FAQPage JSON-LD -->
<script type="application/ld+json" set:html={JSON.stringify(faqSchema)} />
</section>
<script>
const tabs = document.querySelectorAll<HTMLButtonElement>('[role="tab"][data-filter]');
const items = document.querySelectorAll<HTMLDetailsElement>('#faq-list details[data-category]');
const groupHeaders = document.querySelectorAll<HTMLDivElement>('.faq-group-header');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const filter = tab.dataset.filter;
tabs.forEach(t => {
const isActive = t === tab;
t.setAttribute('aria-selected', String(isActive));
t.classList.toggle('bg-accent', isActive);
t.classList.toggle('text-primary', isActive);
t.classList.toggle('font-semibold', isActive);
t.classList.toggle('bg-accent/10', !isActive);
t.classList.toggle('text-accent', !isActive);
t.classList.toggle('hover:bg-accent/20', !isActive);
});
items.forEach(item => {
const show = filter === 'all' || item.dataset.category === filter;
item.hidden = !show;
});
groupHeaders.forEach(header => {
header.hidden = filter !== 'all';
});
});
});
</script>CHECKPOINT: Run npm run build and commit.
git add -A
git commit -m "feat: core components, layouts, and design system"Section 4: API Routes
Checkpoint: After this section, the contact form and newsletter subscription work end-to-end.
4.1 Contact Form API Route
Create src/pages/api/send-email.ts:
/**
* Contact form API route -- sends notification + auto-reply via Brevo.
*/
export const prerender = false;
import type { APIRoute } from 'astro';
import {
getEnv,
validateEmail,
validateLength,
MAX_LENGTHS,
verifyTurnstile,
brevoHeaders,
escapeHtml,
jsonResponse,
} from '../../lib/api-helpers';
import { BOOKING_URL } from '../../lib/constants';
interface ContactFormData {
name: string;
email: string;
company?: string;
phone?: string;
service: string;
message: string;
turnstileToken: string;
_url?: string; // honeypot
}
export const POST: APIRoute = async ({ request, locals }) => {
try {
const env = (key: string) => getEnv(locals, key);
const data: ContactFormData = await request.json();
// Honeypot check -- if filled, silently return success
if (data._url) {
return jsonResponse({ success: true });
}
// Validate required fields
if (!data.name?.trim() || !data.email?.trim() || !data.message?.trim()) {
return jsonResponse({ error: 'Name, email, and message are required.' }, 400);
}
// Validate field lengths
if (
!validateLength(data.name, MAX_LENGTHS.name) ||
!validateLength(data.email, MAX_LENGTHS.email) ||
!validateLength(data.company, MAX_LENGTHS.company) ||
!validateLength(data.phone, MAX_LENGTHS.phone) ||
!validateLength(data.service, MAX_LENGTHS.service) ||
!validateLength(data.message, MAX_LENGTHS.message)
) {
return jsonResponse({ error: 'One or more fields exceed the maximum length.' }, 400);
}
if (!validateEmail(data.email)) {
return jsonResponse({ error: 'Please provide a valid email address.' }, 400);
}
// Verify Turnstile token
const turnstileSecret = env('TURNSTILE_SECRET_KEY');
if (turnstileSecret) {
const result = await verifyTurnstile(turnstileSecret, data.turnstileToken);
if (!result.success) {
return jsonResponse({ error: result.error || 'Spam verification failed.' }, 400);
}
}
const brevoApiKey = env('BREVO_API_KEY');
const senderEmail = env('BREVO_SENDER_EMAIL') || '{{SENDER_EMAIL}}';
const senderName = env('BREVO_SENDER_NAME') || '{{SENDER_NAME}}';
const siteName = env('SITE_NAME') || '{{SITE_NAME}}';
// Send notification email to site owner
const notificationResponse = await fetch('https://api.brevo.com/v3/smtp/email', {
method: 'POST',
headers: brevoHeaders(brevoApiKey),
body: JSON.stringify({
sender: { name: senderName, email: senderEmail },
to: [{ email: senderEmail, name: senderName }],
subject: `New Contact Form: ${data.name}${data.company ? ` at ${data.company}` : ''}`,
htmlContent: `
<h2>New Contact Form Submission</h2>
<table style="border-collapse:collapse;width:100%;max-width:600px;">
<tr><td style="padding:8px;border-bottom:1px solid #eee;font-weight:bold;">Name</td><td style="padding:8px;border-bottom:1px solid #eee;">${escapeHtml(data.name)}</td></tr>
<tr><td style="padding:8px;border-bottom:1px solid #eee;font-weight:bold;">Email</td><td style="padding:8px;border-bottom:1px solid #eee;"><a href="mailto:${escapeHtml(data.email)}">${escapeHtml(data.email)}</a></td></tr>
${data.company ? `<tr><td style="padding:8px;border-bottom:1px solid #eee;font-weight:bold;">Company</td><td style="padding:8px;border-bottom:1px solid #eee;">${escapeHtml(data.company)}</td></tr>` : ''}
${data.phone ? `<tr><td style="padding:8px;border-bottom:1px solid #eee;font-weight:bold;">Phone</td><td style="padding:8px;border-bottom:1px solid #eee;">${escapeHtml(data.phone)}</td></tr>` : ''}
<tr><td style="padding:8px;border-bottom:1px solid #eee;font-weight:bold;">Service</td><td style="padding:8px;border-bottom:1px solid #eee;">${escapeHtml(data.service)}</td></tr>
<tr><td style="padding:8px;font-weight:bold;vertical-align:top;">Message</td><td style="padding:8px;">${escapeHtml(data.message).replace(/\n/g, '<br>')}</td></tr>
</table>
`,
}),
});
if (!notificationResponse.ok) {
console.error('Brevo notification email failed:', await notificationResponse.text());
return jsonResponse({ error: 'Failed to send message. Please try again or email us directly.' }, 500);
}
// Send auto-reply to the submitter
try {
await fetch('https://api.brevo.com/v3/smtp/email', {
method: 'POST',
headers: brevoHeaders(brevoApiKey),
body: JSON.stringify({
sender: { name: senderName, email: senderEmail },
to: [{ email: data.email, name: data.name }],
subject: `Thanks for reaching out to ${siteName}`,
htmlContent: `
<div style="font-family:{{BRAND_FONT_FAMILY}},system-ui,sans-serif;max-width:600px;margin:0 auto;color:{{BRAND_CHARCOAL}};">
<div style="background:{{BRAND_PRIMARY}};padding:24px;text-align:center;">
<h1 style="color:{{BRAND_BACKGROUND}};margin:0;font-size:24px;">${siteName}</h1>
</div>
<div style="padding:32px 24px;">
<p>Hi ${escapeHtml(data.name)},</p>
<p>Thanks for reaching out! We received your message and will be in touch within one business day.</p>
<p>In the meantime, if you'd like to schedule a call directly, you can <a href="${BOOKING_URL}" style="color:{{BRAND_ACCENT}};">book a time here</a>.</p>
<p>Best,<br>The ${siteName} Team</p>
</div>
<div style="background:{{BRAND_WARM_STONE}};padding:16px 24px;text-align:center;font-size:12px;color:{{BRAND_PEWTER}};">
<p>${siteName} · {{LOCATION}} · <a href="mailto:${senderEmail}" style="color:{{BRAND_PEWTER}};">${senderEmail}</a></p>
</div>
</div>
`,
}),
});
} catch (autoReplyErr) {
console.error('Auto-reply email failed:', autoReplyErr);
}
return jsonResponse({ success: true });
} catch (err) {
console.error('Contact form error:', err);
return jsonResponse({ error: 'Something went wrong. Please try again.' }, 500);
}
};4.2 Newsletter Subscribe API Route
Create src/pages/api/newsletter-subscribe.ts:
/**
* Newsletter subscription API route -- adds contacts to Brevo list.
*/
export const prerender = false;
import type { APIRoute } from 'astro';
import {
getEnv,
validateEmail,
validateLength,
MAX_LENGTHS,
verifyTurnstile,
brevoHeaders,
jsonResponse,
} from '../../lib/api-helpers';
export const POST: APIRoute = async ({ request, locals }) => {
try {
const env = (key: string) => getEnv(locals, key);
const data = await request.json();
const { email, turnstileToken } = data;
if (!email?.trim()) {
return jsonResponse({ error: 'Email is required.' }, 400);
}
if (!validateLength(email, MAX_LENGTHS.email)) {
return jsonResponse({ error: 'Email address is too long.' }, 400);
}
if (!validateEmail(email)) {
return jsonResponse({ error: 'Please provide a valid email address.' }, 400);
}
// Verify Turnstile
const turnstileSecret = env('TURNSTILE_SECRET_KEY');
if (turnstileSecret) {
const result = await verifyTurnstile(turnstileSecret, turnstileToken);
if (!result.success) {
return jsonResponse({ error: result.error || 'Verification failed.' }, 400);
}
}
const brevoApiKey = env('BREVO_API_KEY');
const listId = parseInt(env('BREVO_LIST_ID') || '2', 10);
// Add contact to Brevo
const brevoResponse = await fetch('https://api.brevo.com/v3/contacts', {
method: 'POST',
headers: brevoHeaders(brevoApiKey),
body: JSON.stringify({
email,
listIds: [listId],
attributes: {
SOURCE: 'website',
SIGNUP_DATE: new Date().toISOString(),
},
updateEnabled: true,
}),
});
if (brevoResponse.status === 204 || brevoResponse.ok) {
return jsonResponse({ success: true, message: "You're subscribed! Check your inbox." });
}
const brevoResult = await brevoResponse.json();
if (brevoResult.code === 'duplicate_parameter') {
return jsonResponse({ success: true, message: "You're already on the list!" });
}
console.error('Brevo subscribe error:', brevoResult);
return jsonResponse({ error: 'Subscription failed. Please try again.' }, 500);
} catch (err) {
console.error('Newsletter error:', err);
return jsonResponse({ error: 'Something went wrong. Please try again.' }, 500);
}
};4.3 Middleware (Staging Protection)
Create src/middleware.ts:
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware((_context, next) => {
const siteUrl = import.meta.env.SITE_URL || '{{SITE_URL}}';
const isStaging = siteUrl.includes('staging');
return next().then((response) => {
if (isStaging) {
response.headers.set('X-Robots-Tag', 'noindex, nofollow');
}
return response;
});
});4.4 RSS Feed
Create src/pages/rss.xml.ts:
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';
export async function GET(context: APIContext) {
const posts = await getCollection('blog', ({ data }) => {
return !data.draft && data.date <= new Date();
});
const sortedPosts = posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
return rss({
title: `${import.meta.env.SITE_NAME || '{{SITE_NAME}}'} Blog`,
description: '{{META_DESCRIPTION}}',
site: context.site!,
items: sortedPosts.map((post) => ({
title: post.data.title,
pubDate: post.data.date,
description: post.data.description,
link: `/blog/${post.id}`,
})),
});
}4.5 Dynamic Robots.txt
Create src/pages/robots.txt.ts:
import type { APIRoute } from 'astro';
export const GET: APIRoute = () => {
const siteUrl = import.meta.env.SITE_URL || '{{SITE_URL}}';
const isStaging = siteUrl.includes('staging');
const body = isStaging
? `User-agent: *\nDisallow: /\n`
: `User-agent: *\nAllow: /\nSitemap: ${siteUrl}/sitemap-index.xml\n`;
return new Response(body, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
};CHECKPOINT: Run npm run build and commit.
git add -A
git commit -m "feat: API routes for contact form, newsletter, RSS, robots.txt"Section 5: Content Architecture
Checkpoint: After this section, the blog system works and key pages are in place.
5.1 Content Collection Configuration
Create src/content.config.ts:
/**
* Content collection schemas.
*/
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
date: z.coerce.date(),
author: z.string().default('{{SITE_NAME}} Team'),
tags: z.array(z.string()).default([]),
pillar: z.enum([
// CUSTOMIZE: Replace with {{BLOG_PILLARS}} values
'general',
]),
image: z.string().optional(),
imageAlt: z.string().optional(),
readingTime: z.number(),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };5.2 Blog Listing Page
Create src/pages/blog/index.astro:
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import BlogCard from '../../components/BlogCard.astro';
import { getCollection } from 'astro:content';
const posts = await getCollection('blog', ({ data }) => {
return !data.draft && data.date <= new Date();
});
const sortedPosts = posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
---
<BaseLayout title="Blog | {{SITE_NAME}}" description="Insights and updates from {{SITE_NAME}}.">
<section class="bg-hero-gradient py-16 md:py-24 px-6">
<div class="max-w-content mx-auto text-center">
<h1 class="text-3xl md:text-4xl font-bold text-parchment mb-3">Blog</h1>
<p class="text-lg text-warm-stone max-w-2xl mx-auto">
Practical insights and updates from {{SITE_NAME}}.
</p>
</div>
</section>
<section class="py-12 md:py-16 bg-parchment">
<div class="max-w-content mx-auto px-6">
{sortedPosts.length === 0 ? (
<p class="text-center text-charcoal">No posts yet. Check back soon!</p>
) : (
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{sortedPosts.map((post) => (
<BlogCard post={post} />
))}
</div>
)}
</div>
</section>
</BaseLayout>5.3 Blog Detail Page
Create src/pages/blog/[...slug].astro:
---
/**
* Individual blog post template.
*/
import BaseLayout from '../../layouts/BaseLayout.astro';
import LinkedInIcon from '../../components/icons/LinkedInIcon.astro';
import XTwitterIcon from '../../components/icons/XTwitterIcon.astro';
import { SITE_URL } from '../../lib/constants';
import { getCollection, render } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog', ({ data }) => {
return !data.draft && data.date <= new Date();
});
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
const postUrl = `${SITE_URL}/blog/${post.id}`;
// Get related posts from same pillar
const allPosts = await getCollection('blog', ({ data }) => {
return !data.draft && data.date <= new Date();
});
const relatedPosts = allPosts
.filter(p => p.data.pillar === post.data.pillar && p.id !== post.id)
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime())
.slice(0, 3);
---
<BaseLayout
title={`${post.data.title} | {{SITE_NAME}}`}
description={post.data.description}
ogImage={post.data.image}
>
<!-- JSON-LD Article Schema -->
<script type="application/ld+json" set:html={JSON.stringify({
"@context": "https://schema.org",
"@type": "Article",
"headline": post.data.title,
"description": post.data.description,
"datePublished": post.data.date.toISOString(),
"author": {
"@type": "Organization",
"name": post.data.author,
},
"publisher": {
"@type": "Organization",
"name": "{{SITE_NAME}}",
"url": SITE_URL,
},
"url": postUrl,
...(post.data.image ? { "image": `${SITE_URL}${post.data.image}` } : {}),
})} />
<article>
<!-- Hero -->
<section class="bg-primary py-16 md:py-24 px-6">
<div class="max-w-3xl mx-auto">
<div class="flex items-center gap-3 mb-4 text-sm">
<span class="bg-accent/20 text-accent px-3 py-1 rounded-full font-medium">
{post.data.pillar.replace('-', ' ')}
</span>
<span class="text-pewter">{post.data.readingTime} min read</span>
</div>
<h1 class="text-3xl md:text-4xl font-bold text-parchment mb-4 leading-tight">
{post.data.title}
</h1>
<div class="flex items-center gap-4 text-sm text-warm-stone">
<span>{post.data.author}</span>
<span>|</span>
<time datetime={post.data.date.toISOString()}>
{post.data.date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}
</time>
</div>
</div>
</section>
<!-- Content -->
<section class="py-12 md:py-16 bg-parchment">
<div class="max-w-3xl mx-auto px-6">
<div class="prose prose-lg max-w-none
prose-headings:text-primary prose-headings:font-semibold
prose-a:text-secondary prose-a:underline hover:prose-a:text-accent
prose-strong:text-primary
prose-li:text-charcoal
prose-p:text-charcoal prose-p:leading-relaxed
">
<Content />
</div>
<!-- Share Links -->
<div class="mt-12 pt-8 border-t border-warm-stone">
<h3 class="text-sm font-semibold text-primary mb-3">Share this article</h3>
<div class="flex gap-4">
<a
href={`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(postUrl)}`}
target="_blank"
rel="noopener noreferrer"
class="text-secondary hover:text-accent transition-colors"
aria-label="Share on LinkedIn"
>
<LinkedInIcon />
</a>
<a
href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(postUrl)}&text=${encodeURIComponent(post.data.title)}`}
target="_blank"
rel="noopener noreferrer"
class="text-secondary hover:text-accent transition-colors"
aria-label="Share on X"
>
<XTwitterIcon />
</a>
<button
class="copy-link-btn text-secondary hover:text-accent transition-colors text-sm font-medium"
data-url={postUrl}
>
Copy link
</button>
</div>
</div>
</div>
</section>
<!-- Related Posts -->
{relatedPosts.length > 0 && (
<section class="py-12 bg-warm-stone">
<div class="max-w-content mx-auto px-6">
<h2 class="text-xl font-semibold text-primary mb-6">Related Posts</h2>
<div class="grid md:grid-cols-3 gap-6">
{relatedPosts.map((related) => (
<a href={`/blog/${related.id}`} class="group">
<article class="bg-white rounded-card shadow-card hover:shadow-card-hover transition-all p-6 border border-warm-stone">
<h3 class="font-semibold text-primary group-hover:text-accent transition-colors mb-2">
{related.data.title}
</h3>
<p class="text-sm text-charcoal line-clamp-2">{related.data.description}</p>
</article>
</a>
))}
</div>
</div>
</section>
)}
</article>
</BaseLayout>
<script>
document.querySelectorAll('.copy-link-btn').forEach(btn => {
btn.addEventListener('click', () => {
const url = btn.getAttribute('data-url');
if (url) {
navigator.clipboard.writeText(url).then(() => {
btn.textContent = 'Copied!';
setTimeout(() => { btn.textContent = 'Copy link'; }, 2000);
});
}
});
});
</script>5.4 Sample Blog Post
Create src/content/blog/welcome.md:
---
title: "Welcome to {{SITE_NAME}}"
description: "Introducing our new website and what we're building."
date: 2026-01-01
author: "{{SITE_NAME}} Team"
tags: ["announcement"]
pillar: "general"
readingTime: 3
draft: false
---
Welcome to the new {{SITE_NAME}} website. We're excited to share insights and updates with you.
## What We Do
{{SITE_NAME}} provides professional services to help organizations grow securely.
## Stay Connected
Subscribe to our newsletter for regular updates, or [book a call]({{BOOKING_URL}}) to discuss how we can help.5.5 Vertical Landing Page Pattern
Vertical-specific landing pages live at /for/[vertical]. Each targets a specific industry with tailored messaging, pain points, FAQ, and CTA.
Create src/pages/for/vertical-name.astro:
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import ContactForm from '../../components/ContactForm.astro';
import { BOOKING_URL, CTA_TEXT } from '../../lib/constants';
const faqs = [
{
q: "Industry-specific question?",
a: "Tailored answer with industry context."
},
// Add 4-6 FAQs relevant to this vertical
];
---
<BaseLayout
title="Service for [Vertical] | {{SITE_NAME}}"
description="Targeted description for this vertical (under 160 chars)."
>
<!-- Hero -->
<section class="bg-hero-gradient py-20 md:py-32 px-6">
<div class="max-w-content mx-auto text-center">
<h1 class="text-3xl md:text-5xl font-bold text-parchment mb-4 tracking-tight leading-tight">
Vertical-specific headline that speaks to their pain
</h1>
<p class="text-lg md:text-xl text-warm-stone mb-6 max-w-2xl mx-auto">
Subheadline with specific value prop + timeline.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<a
href={BOOKING_URL}
data-cal-namespace=""
data-cal-link="{{CAL_LINK}}"
data-cal-config='{"layout":"month_view"}'
class="inline-flex items-center px-6 py-3 bg-accent text-primary text-sm font-medium uppercase tracking-wider rounded-button hover:bg-accent-hover transition-colors min-h-[44px]"
>
{CTA_TEXT}
</a>
<a href="#contact" class="text-warm-stone hover:text-accent transition-colors underline underline-offset-4">
Or send us a message
</a>
</div>
</div>
</section>
<!-- Pain Points (3-4 cards) -->
<section class="py-16 md:py-24 bg-parchment">
<div class="max-w-content mx-auto px-6">
<h2 class="text-2xl md:text-3xl font-bold text-primary mb-10 text-center">
Problems this vertical faces
</h2>
<div class="grid md:grid-cols-2 gap-6">
<!-- Pain point cards -->
<div class="bg-white rounded-card shadow-card p-6 border border-warm-stone">
<h3 class="text-lg font-semibold text-primary mb-2">Pain Point Title</h3>
<p class="text-charcoal">Specific problem description for this industry.</p>
</div>
<!-- Repeat for each pain point -->
</div>
</div>
</section>
<!-- How We Work (process steps) -->
<section class="py-16 md:py-24 bg-warm-stone">
<div class="max-w-content mx-auto px-6">
<h2 class="text-2xl md:text-3xl font-bold text-primary mb-10 text-center">How it works</h2>
<div class="grid md:grid-cols-3 lg:grid-cols-5 gap-6">
<!-- Step cards with numbered circles -->
</div>
</div>
</section>
<!-- FAQ -->
<section class="py-16 md:py-24 bg-parchment">
<div class="max-w-3xl mx-auto px-6">
<h2 class="text-2xl md:text-3xl font-bold text-primary mb-10 text-center">Frequently asked questions</h2>
<div class="space-y-4">
{faqs.map(({ q, a }) => (
<details class="group bg-white rounded-card shadow-card border border-warm-stone">
<summary class="p-5 cursor-pointer font-semibold text-primary hover:text-accent transition-colors list-none">
<span class="flex justify-between items-center">
{q}
<span class="text-accent group-open:rotate-45 transition-transform text-xl">+</span>
</span>
</summary>
<div class="px-5 pb-5 text-charcoal">{a}</div>
</details>
))}
</div>
</div>
</section>
<!-- Contact Form -->
<section id="contact" class="py-16 md:py-24 bg-parchment">
<div class="max-w-content mx-auto px-6">
<h2 class="text-2xl font-bold text-primary mb-6 text-center">Get started</h2>
<ContactForm />
</div>
</section>
</BaseLayout>Key pattern notes:
- Each vertical gets its own
.astrofile (not dynamic routes) so content is fully static - FAQ is inline (not the shared FAQ component) because vertical FAQs are unique
- Nav dropdown links to
/for/[vertical]pages - Add to both desktop and mobile nav in
Nav.astro, and footer Industries column
5.6 Resource Pages + PrintLayout (Dual-Mode Web/Print)
Resource pages serve dual purposes: web pages for browsing and print-optimized pages for PDF export. The PrintLayout.astro handles both modes via a ?print=true query parameter.
How it works:
?print=true→ Renders a standalone HTML document with inline CSS, no nav/footer, print-specific margins- No query param → Renders inside
BaseLayoutwith a download sidebar - The
generate-pdfs.mjsscript (Section 9.5) opens?print=trueand exports as PDF
Create src/layouts/PrintLayout.astro (key structure):
---
/**
* Dual-mode layout: web view wraps in BaseLayout; print view is standalone.
*/
import BaseLayout from './BaseLayout.astro';
import { SITE_URL, BOOKING_URL } from '../lib/constants';
interface Props {
title: string;
description: string;
slug: string; // For download link: /downloads/project-name-{slug}.pdf
docTitle: string; // Document heading
docSubtitle?: string;
pdfFilename: string; // e.g., "project-name-intro.pdf"
}
const { title, description, slug, docTitle, docSubtitle, pdfFilename } = Astro.props;
const isPrint = Astro.url.searchParams.get('print') === 'true';
export const prerender = false; // Needed for query parameter access
---
{isPrint ? (
<!-- PRINT MODE: Standalone HTML with inline CSS for PDF -->
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>{title}</title>
<style>
/* Inline all CSS needed for PDF rendering */
@page { margin: 0.6in 0.6in 0.5in 0.6in; size: letter; }
body { font-family: 'Inter', system-ui, sans-serif; color: #111827; }
/* ... extensive print-specific styles ... */
</style>
</head>
<body>
<header class="print-header-brand">
<img src="/images/logo/logo-horizontal-dark.png" alt="{{SITE_NAME}}" />
</header>
<h1>{docTitle}</h1>
{docSubtitle && <p class="subtitle">{docSubtitle}</p>}
<slot />
<footer class="print-footer">
<p>{{SITE_URL}} · {{SENDER_EMAIL}}</p>
</footer>
</body>
</html>
) : (
<!-- WEB MODE: BaseLayout with download sidebar -->
<BaseLayout title={title} description={description}>
<div class="max-w-content mx-auto px-6 py-12 lg:grid lg:grid-cols-[1fr_280px] lg:gap-12">
<article class="prose prose-lg max-w-none">
<h1>{docTitle}</h1>
{docSubtitle && <p class="text-lg text-pewter">{docSubtitle}</p>}
<slot />
</article>
<aside class="hidden lg:block">
<div class="sticky top-24 bg-white rounded-card shadow-card p-6 border border-warm-stone">
<h3 class="font-semibold text-primary mb-3">Download PDF</h3>
<a href={`/downloads/${pdfFilename}`} class="block bg-accent text-primary text-center py-2 rounded-button font-medium hover:bg-accent-hover transition-colors">
Download PDF
</a>
<a href={BOOKING_URL} data-cal-link="{{CAL_LINK}}" class="block mt-3 text-center text-sm text-accent hover:underline">
Book a call to discuss
</a>
</div>
</aside>
</div>
</BaseLayout>
)}Resource page example (src/pages/resources/intro.astro):
---
import PrintLayout from '../../layouts/PrintLayout.astro';
import { BOOKING_URL, CTA_TEXT } from '../../lib/constants';
export const prerender = false;
const isPrint = Astro.url.searchParams.get('print') === 'true';
---
<PrintLayout
title="Introduction to {{SITE_NAME}}"
description="One-page overview of {{SITE_NAME}} capabilities."
slug="intro"
docTitle="{{SITE_NAME}}"
docSubtitle="{{TAGLINE}}"
pdfFilename="{{CLOUDFLARE_PROJECT_NAME}}-intro.pdf"
>
<!-- Service cards, value props, CTA — all using the same Tailwind classes -->
<!-- Content renders identically in web view and print/PDF -->
</PrintLayout>5.7 Essential Pages
Create the following pages following the BaseLayout pattern:
src/pages/contact.astro - Contact page with full-width form
src/pages/about.astro - About page with team members
src/pages/thank-you.astro - Post-submission thank you page (noindex)
src/pages/privacy.astro - Privacy policy
src/pages/terms.astro - Terms of service
src/pages/accessibility.astro - Accessibility statement
src/pages/newsletter.astro - Dedicated newsletter signup page
src/pages/404.astro - Custom 404 page
Each page follows this pattern:
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="Page Title | {{SITE_NAME}}" description="Page description.">
<!-- Hero section -->
<section class="bg-hero-gradient py-16 md:py-24 px-6">
<div class="max-w-content mx-auto text-center">
<h1 class="text-3xl md:text-4xl font-bold text-parchment mb-3">Page Title</h1>
</div>
</section>
<!-- Content -->
<section class="py-12 md:py-16 bg-parchment">
<div class="max-w-3xl mx-auto px-6">
<!-- Page content here -->
</div>
</section>
</BaseLayout>5.8 Homepage Pattern
The homepage (src/pages/index.astro) should include these sections in order:
- Hero - Main value proposition + primary CTA
- Trust indicators - Stats or client logos
- Problem statement - Why your service matters (3-6 pain point cards)
- Who we serve - Target audience cards (link to
/for/[vertical]) - Core offering - Detailed assessment/service explanation
- Stats Banner - Credibility metrics
- Differentiators - What makes you different (3-4 cards)
- Services grid - All services overview
- How We Work - Process steps
- FAQ - FAQ component
- Blog - Latest 3 blog posts
- Contact - Contact form + sidebar
CHECKPOINT: Run npm run build and commit.
git add -A
git commit -m "feat: content architecture, blog system, and essential pages"Section 6: Security Hardening
Checkpoint: After this section, the site has production-grade security headers and input protection.
6.1 Security Headers
Create public/_headers:
# Security headers
/api/*
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type
Access-Control-Max-Age: 86400
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Strict-Transport-Security: max-age=31536000; includeSubDomains
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://app.cal.com https://cloud.umami.is; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://challenges.cloudflare.com https://api.cal.com https://api-gateway.umami.dev; frame-src https://challenges.cloudflare.com https://app.cal.com;
6.2 Security Best Practices Implemented
The following security measures are built into the codebase:
| Measure | Location | Description |
|---|---|---|
| HSTS | _headers | Force HTTPS with 1-year max-age |
| CSP | _headers | Restrict script/style/connect sources |
| X-Frame-Options | _headers | Prevent clickjacking (DENY) |
| Input validation | api-helpers.ts | MAX_LENGTHS, email regex |
| HTML escaping | api-helpers.ts | Prevent XSS in email templates |
| Honeypot | ContactForm.astro | Hidden field catches bots |
| Turnstile | ContactForm.astro | Privacy-respecting CAPTCHA |
| CORS | api-helpers.ts | Restrict API to {{SITE_URL}} only |
| Email obfuscation | Footer.astro | JS-assembled on hover/focus |
| Staging noindex | middleware.ts | Prevent staging from being indexed |
| Secret separation | wrangler.toml | Secrets via wrangler pages secret put |
6.3 NEW: security.txt (RFC 9116)
Create public/.well-known/security.txt:
Contact: mailto:security@{{DOMAIN}}
Expires: 2027-12-31T23:59:59.000Z
Preferred-Languages: en
Canonical: https://{{DOMAIN}}/.well-known/security.txt
6.4 NEW: Subresource Integrity (SRI)
Install and configure @kindspells/astro-shield for SRI hashes on script/style tags:
npm install @kindspells/astro-shieldAdd to astro.config.mjs:
import shield from '@kindspells/astro-shield';
export default defineConfig({
// ... existing config
integrations: [
tailwind(),
sitemap({ filter: (page) => !page.includes('/thank-you') }),
shield(), // Adds SRI hashes to script and style tags
],
});6.5 NEW: Cloudflare WAF Rate Limiting
After deployment, configure in Cloudflare dashboard:
- Go to Security > WAF > Rate limiting rules
- Create rule:
- Name: API rate limit
- Expression:
(http.request.uri.path contains "/api/") - Rate: 10 requests per minute per IP
- Action: Block for 60 seconds
- Enable the Cloudflare Free Managed Ruleset under WAF > Managed Rules
6.6 DNS Security Verification Commands
Run these after deployment to verify DNS security:
# TLS version
curl -sI https://{{DOMAIN}} | grep -i "strict-transport"
# SPF
dig TXT {{DOMAIN}} +short | grep "v=spf1"
# DMARC
dig TXT _dmarc.{{DOMAIN}} +short
# DKIM (adjust selector based on your email provider)
dig TXT mail._domainkey.{{DOMAIN}} +short
# CAA
dig CAA {{DOMAIN}} +short
# Security headers
curl -sI https://{{DOMAIN}} | grep -iE "(x-frame|x-content|strict-transport|content-security|referrer-policy|permissions-policy)"CHECKPOINT: Run npm run build and commit.
git add -A
git commit -m "feat: security hardening with CSP, HSTS, Turnstile, rate limiting"Section 7: Testing Framework
Checkpoint: After this section, the full E2E test suite passes.
7.1 Playwright Configuration
Create playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : 4,
reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'html',
use: {
baseURL: 'http://localhost:4321',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run build && npm run preview',
url: 'http://localhost:4321',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
TURNSTILE_SITE_KEY: '1x00000000000000000000AA',
},
},
});Key design decisions:
- Build + preview (not dev server) for reliable parallel testing
- 4 local workers for speed; 1 in CI for determinism
- Turnstile test key (
1x00000000000000000000AA) always passes verification - 120s webServer timeout accounts for build step
7.2 Smoke Test
Create tests/e2e/smoke.spec.ts:
import { test, expect } from '@playwright/test';
const pages = [
'/',
'/about',
'/contact',
'/blog',
'/newsletter',
'/privacy',
'/terms',
'/accessibility',
'/resources',
];
for (const path of pages) {
test(`${path} returns 200`, async ({ page }) => {
const response = await page.goto(path);
expect(response?.status()).toBe(200);
});
}
test('404 page renders for unknown routes', async ({ page }) => {
const response = await page.goto('/this-page-does-not-exist');
expect(response?.status()).toBe(404);
});
test('no console errors on homepage', async ({ page }) => {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') {
const text = msg.text();
// Ignore third-party errors
if (text.includes('cal.com') || text.includes('turnstile') || text.includes('umami')) return;
errors.push(text);
}
});
await page.goto('/');
expect(errors).toEqual([]);
});7.3 SEO Test
Create tests/e2e/seo.spec.ts:
import { test, expect } from '@playwright/test';
const pages = [
{ path: '/', titleContains: '{{SITE_NAME}}' },
{ path: '/about', titleContains: 'About' },
{ path: '/contact', titleContains: 'Contact' },
{ path: '/blog', titleContains: 'Blog' },
];
for (const { path, titleContains } of pages) {
test(`${path} has proper SEO meta tags`, async ({ page }) => {
await page.goto(path);
// Title
const title = await page.title();
expect(title).toContain(titleContains);
expect(title.length).toBeGreaterThan(10);
expect(title.length).toBeLessThan(70);
// Meta description
const desc = await page.getAttribute('meta[name="description"]', 'content');
expect(desc).toBeTruthy();
expect(desc!.length).toBeGreaterThan(50);
expect(desc!.length).toBeLessThan(160);
// Open Graph
expect(await page.getAttribute('meta[property="og:title"]', 'content')).toBeTruthy();
expect(await page.getAttribute('meta[property="og:description"]', 'content')).toBeTruthy();
expect(await page.getAttribute('meta[property="og:image"]', 'content')).toBeTruthy();
expect(await page.getAttribute('meta[property="og:url"]', 'content')).toBeTruthy();
// Twitter Card
expect(await page.getAttribute('meta[name="twitter:card"]', 'content')).toBe('summary_large_image');
// Canonical URL
const canonical = await page.getAttribute('link[rel="canonical"]', 'href');
expect(canonical).toContain(path === '/' ? '{{DOMAIN}}' : path);
});
}
test('/thank-you has noindex', async ({ page }) => {
await page.goto('/thank-you');
const robots = await page.getAttribute('meta[name="robots"]', 'content');
expect(robots).toContain('noindex');
});
test('all page titles are unique', async ({ page }) => {
const titles: string[] = [];
for (const { path } of pages) {
await page.goto(path);
titles.push(await page.title());
}
const unique = new Set(titles);
expect(unique.size).toBe(titles.length);
});7.4 Security Headers Test
Create tests/e2e/security-headers.spec.ts:
import { test, expect } from '@playwright/test';
import { readFileSync } from 'fs';
import { join } from 'path';
test('public/_headers file exists with security directives', () => {
const headers = readFileSync(join(process.cwd(), 'public/_headers'), 'utf-8');
expect(headers).toContain('X-Frame-Options: DENY');
expect(headers).toContain('X-Content-Type-Options: nosniff');
expect(headers).toContain('Referrer-Policy: strict-origin-when-cross-origin');
expect(headers).toContain('Strict-Transport-Security');
expect(headers).toContain('Content-Security-Policy');
expect(headers).toContain('Permissions-Policy');
});
test('CSP includes required directives', () => {
const headers = readFileSync(join(process.cwd(), 'public/_headers'), 'utf-8');
expect(headers).toContain("default-src 'self'");
expect(headers).toContain('challenges.cloudflare.com');
expect(headers).toContain('cloud.umami.is');
});
test('CORS is restricted to API routes', () => {
const headers = readFileSync(join(process.cwd(), 'public/_headers'), 'utf-8');
expect(headers).toContain('/api/*');
expect(headers).toContain('Access-Control-Allow-Methods: POST, OPTIONS');
});7.5 Contact Form Test
Create tests/e2e/contact-form.spec.ts:
import { test, expect } from '@playwright/test';
test('contact page has all form fields', async ({ page }) => {
await page.goto('/contact');
await expect(page.locator('input[name="name"]')).toBeVisible();
await expect(page.locator('input[name="email"]')).toBeVisible();
await expect(page.locator('input[name="company"]')).toBeVisible();
await expect(page.locator('input[name="phone"]')).toBeVisible();
await expect(page.locator('select[name="service"]')).toBeVisible();
await expect(page.locator('textarea[name="message"]')).toBeVisible();
await expect(page.locator('button[type="submit"]')).toBeVisible();
});
test('contact form validates required fields', async ({ page }) => {
await page.goto('/contact');
// Try to submit empty form
await page.click('button[type="submit"]');
// HTML5 validation should prevent submission
const nameInput = page.locator('input[name="name"]');
const isValid = await nameInput.evaluate((el: HTMLInputElement) => el.validity.valid);
expect(isValid).toBe(false);
});
test('honeypot field is hidden', async ({ page }) => {
await page.goto('/contact');
const honeypot = page.locator('input[name="_url"]');
await expect(honeypot).toBeHidden();
});7.6 Blog Test
Create tests/e2e/blog.spec.ts:
import { test, expect } from '@playwright/test';
test('blog listing page shows posts', async ({ page }) => {
await page.goto('/blog');
const articles = page.locator('article');
const count = await articles.count();
if (count > 0) {
// Verify first post has required elements
const firstArticle = articles.first();
await expect(firstArticle.locator('h2')).toBeVisible();
}
});
test('blog posts are sorted newest first', async ({ page }) => {
await page.goto('/blog');
const dates = await page.locator('article .text-pewter').allTextContents();
if (dates.length > 1) {
const parsedDates = dates.map(d => new Date(d).getTime());
for (let i = 1; i < parsedDates.length; i++) {
expect(parsedDates[i]).toBeLessThanOrEqual(parsedDates[i - 1]);
}
}
});7.7 Mobile Navigation Test
Create tests/e2e/mobile-nav.spec.ts:
import { test, expect } from '@playwright/test';
test('mobile menu toggles on hamburger click', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
const menuBtn = page.locator('#mobile-menu-btn');
const menu = page.locator('#mobile-menu');
await expect(menu).toBeHidden();
await menuBtn.click();
await expect(menu).toBeVisible();
await menuBtn.click();
await expect(menu).toBeHidden();
});7.8 Legal Pages Test
Create tests/e2e/legal-pages.spec.ts:
import { test, expect } from '@playwright/test';
const legalPages = ['/privacy', '/terms', '/accessibility'];
for (const path of legalPages) {
test(`${path} has content`, async ({ page }) => {
await page.goto(path);
const heading = page.locator('h1');
await expect(heading).toBeVisible();
});
}
test('footer has links to all legal pages', async ({ page }) => {
await page.goto('/');
await expect(page.locator('footer a[href="/privacy"]')).toBeVisible();
await expect(page.locator('footer a[href="/terms"]')).toBeVisible();
await expect(page.locator('footer a[href="/accessibility"]')).toBeVisible();
});
test('skip-to-content link exists', async ({ page }) => {
await page.goto('/');
const skipLink = page.locator('a[href="#main-content"]');
await expect(skipLink).toHaveClass(/sr-only/);
const main = page.locator('main#main-content');
await expect(main).toBeAttached();
});7.9 Test Helper: API Mocks
Create tests/e2e/helpers/api-mocks.ts to mock API routes in tests:
import type { Page } from '@playwright/test';
interface MockOptions {
status?: number;
body?: Record<string, unknown>;
delay?: number;
abort?: boolean;
}
export async function mockSendEmail(page: Page, opts: MockOptions = {}) {
const {
status = 200,
body = { success: true },
delay = 0,
abort = false,
} = opts;
await page.route('**/api/send-email', async (route) => {
if (abort) { await route.abort('failed'); return; }
if (delay) await new Promise((r) => setTimeout(r, delay));
await route.fulfill({
status,
contentType: 'application/json',
body: JSON.stringify(body),
});
});
}
export async function mockNewsletterSubscribe(page: Page, opts: MockOptions = {}) {
const {
status = 200,
body = { success: true, message: "You're subscribed! Check your inbox." },
delay = 0,
abort = false,
} = opts;
await page.route('**/api/newsletter-subscribe', async (route) => {
if (abort) { await route.abort('failed'); return; }
if (delay) await new Promise((r) => setTimeout(r, delay));
await route.fulfill({
status,
contentType: 'application/json',
body: JSON.stringify(body),
});
});
}7.10 Newsletter Test
Create tests/e2e/newsletter.spec.ts:
import { test, expect } from '@playwright/test';
import { mockNewsletterSubscribe } from './helpers/api-mocks';
test.describe('Newsletter Subscribe (Footer)', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
const getFooterForm = (page: import('@playwright/test').Page) =>
page.locator('footer .newsletter-signup');
test('footer renders newsletter form', async ({ page }) => {
const form = getFooterForm(page);
await expect(form.locator('input[name="email"]')).toBeVisible();
await expect(form.locator('button[type="submit"]')).toBeVisible();
});
test('successful subscribe shows success message', async ({ page }) => {
await mockNewsletterSubscribe(page, {
body: { success: true, message: "You're subscribed! Check your inbox." },
});
const form = getFooterForm(page);
await form.locator('input[name="email"]').fill('test@example.com');
await form.locator('button[type="submit"]').click();
const status = form.locator('.newsletter-status');
await expect(status).toBeVisible();
await expect(status).toContainText("You're subscribed");
});
test('server error shows error message', async ({ page }) => {
await mockNewsletterSubscribe(page, {
status: 500,
body: { error: 'Subscription failed. Please try again.' },
});
const form = getFooterForm(page);
await form.locator('input[name="email"]').fill('test@example.com');
await form.locator('button[type="submit"]').click();
const status = form.locator('.newsletter-status');
await expect(status).toBeVisible();
await expect(status).toContainText('Subscription failed');
});
test('empty email blocked by browser validation', async ({ page }) => {
const form = getFooterForm(page);
await form.locator('button[type="submit"]').click();
const emailInput = form.locator('input[name="email"]');
await expect(emailInput).toHaveAttribute('required', '');
await expect(form.locator('.newsletter-status')).toBeHidden();
});
});
test.describe('Newsletter Page (/newsletter)', () => {
test('newsletter page renders the signup form', async ({ page }) => {
await page.goto('/newsletter');
const mainForm = page.locator('main .newsletter-signup');
await expect(mainForm.locator('input[name="email"]')).toBeVisible();
await expect(mainForm.locator('button[type="submit"]')).toBeVisible();
});
});7.11 Cal.com Embed Test
Create tests/e2e/cal-embed.spec.ts:
import { test, expect } from '@playwright/test';
// CUSTOMIZE: Replace with your Cal.com link and booking URL
const CAL_LINK = '{{CAL_LINK}}';
const CAL_HREF = '{{BOOKING_URL}}';
const CAL_SELECTOR = `a[data-cal-link="${CAL_LINK}"]`;
test.describe('Cal.com popup modal embed', () => {
test('Cal.com embed initializer is present on every page', async ({ page }) => {
for (const path of ['/', '/contact', '/about']) {
await page.goto(path);
const html = await page.content();
expect(html, `Cal.com loader script should exist on ${path}`).toContain('app.cal.com/embed/embed.js');
}
});
test('homepage has booking buttons with data-cal-link', async ({ page }) => {
await page.goto('/');
const buttons = page.locator(CAL_SELECTOR);
// Count varies: page CTAs + nav (desktop + mobile) + footer
const count = await buttons.count();
expect(count).toBeGreaterThanOrEqual(3);
for (const btn of await buttons.all()) {
await expect(btn).toHaveAttribute('href', CAL_HREF);
await expect(btn).toHaveAttribute('data-cal-config', '{"layout":"month_view"}');
}
});
test('footer has booking link with data-cal-link', async ({ page }) => {
await page.goto('/');
const footerLink = page.locator('footer').locator(CAL_SELECTOR);
await expect(footerLink).toHaveCount(1);
await expect(footerLink).toHaveAttribute('href', CAL_HREF);
});
test('clicking booking button opens Cal.com popup modal', async ({ page }) => {
test.setTimeout(60000);
await page.goto('/', { waitUntil: 'load' });
const urlBefore = page.url();
const heroBtn = page.locator(`section a[data-cal-link="${CAL_LINK}"]`).first();
await heroBtn.click();
await page.waitForFunction(() => (window as any).Cal?.loaded === true, null, { timeout: 15000 });
await page.waitForTimeout(4000);
expect(page.url()).toBe(urlBefore); // Must stay on same page (modal, not navigation)
const hasContent = await page.evaluate(() => {
return document.querySelectorAll('iframe').length > 0 ||
document.querySelectorAll('[class*="--booker-"]').length > 0;
});
expect(hasContent, 'Cal.com modal should render booking content').toBe(true);
});
});7.12 NEW: Accessibility Test (axe-playwright)
Install axe-playwright:
npm install -D @axe-core/playwrightCreate tests/e2e/accessibility.spec.ts:
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const pagesToTest = ['/', '/about', '/contact', '/blog'];
for (const path of pagesToTest) {
test(`${path} has no critical accessibility violations`, async ({ page }) => {
await page.goto(path);
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.exclude('.cf-turnstile-container') // Exclude third-party widget
.analyze();
const critical = results.violations.filter(v =>
v.impact === 'critical' || v.impact === 'serious'
);
expect(critical).toEqual([]);
});
}CHECKPOINT: Run npx playwright test and commit.
git add -A
git commit -m "feat: E2E test suite with Playwright (smoke, SEO, security, a11y)"Section 8: CI/CD Pipeline
Checkpoint: After this section, pushes to main auto-deploy and PRs run tests.
8.1 Deploy Workflow
Create .github/workflows/deploy.yml:
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build Astro site
run: npm run build
env:
TURNSTILE_SITE_KEY: ${{ vars.TURNSTILE_SITE_KEY }}
SITE_URL: ${{ vars.SITE_URL }}
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist/ --project-name={{CLOUDFLARE_PROJECT_NAME}}8.2 Test Workflow
Create .github/workflows/test.yml:
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test
env:
TURNSTILE_SITE_KEY: 1x00000000000000000000AA
- name: Upload test report
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 148.3 Scheduled Deploy (Blog Auto-Publish)
Create .github/workflows/scheduled-deploy.yml:
name: Scheduled Deploy (Blog Auto-Publish)
on:
schedule:
- cron: '0 12 * * *' # Daily at noon UTC
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build Astro site
run: npm run build
env:
TURNSTILE_SITE_KEY: ${{ vars.TURNSTILE_SITE_KEY }}
SITE_URL: ${{ vars.SITE_URL }}
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist/ --project-name={{CLOUDFLARE_PROJECT_NAME}}8.4 Dependabot
Create .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 10
labels:
- "dependencies"8.5 NEW: Lighthouse CI (Optional)
Add a Lighthouse CI step to the test workflow:
- name: Run Lighthouse CI
uses: treosh/lighthouse-ci-action@v12
with:
urls: |
http://localhost:4321/
http://localhost:4321/about
http://localhost:4321/blog
budgetPath: ./lighthouse-budget.json
uploadArtifacts: trueCreate lighthouse-budget.json:
[
{
"path": "/*",
"timings": [
{ "metric": "first-contentful-paint", "budget": 2000 },
{ "metric": "interactive", "budget": 5000 }
],
"resourceSizes": [
{ "resourceType": "total", "budget": 500 }
]
}
]CHECKPOINT: Commit workflows.
git add -A
git commit -m "feat: CI/CD with deploy, test, scheduled publish, and dependabot"Section 9: Image + Asset Pipeline
Checkpoint: After this section, all images are optimized and the PDF generation script works.
9.1 Directory Structure
public/
downloads/ # Generated PDFs (gitignored in some setups)
favicon/
apple-touch-icon.png # 180x180
favicon-32x32.png # 32x32
favicon-16x16.png # 16x16
site.webmanifest # Web app manifest
images/
logo/
logo-horizontal-dark.png # For dark backgrounds
logo-horizontal-dark.webp # WebP version
logo-horizontal-light.png # For light backgrounds
logo-horizontal-light.webp # WebP version
stock/
hero.jpg # 1200x800
hero.webp # WebP version
team/
person-name.jpg # 500x500 headshots
person-name.webp # WebP version
og-default.png # 1200x630 Open Graph image
9.2 Image Optimization
All images should have both JPG and WebP versions. Use Sharp for conversion:
# Convert JPG to WebP
node -e "require('sharp')('input.jpg').webp({quality:80}).toFile('output.webp')"
# Resize and convert
node -e "require('sharp')('input.jpg').resize(1200,800,{fit:'cover'}).jpeg({quality:85}).toFile('output.jpg')"9.3 Picture Element Pattern
Always use the <picture> element with WebP source:
<picture>
<source srcset="/images/stock/hero.webp" type="image/webp" />
<img
src="/images/stock/hero.jpg"
alt="Descriptive alt text"
class="w-full h-48 object-cover"
width="1200"
height="800"
loading="lazy"
/>
</picture>9.4 Favicon Generation
Generate favicons from a source image (512x512 PNG recommended):
# Using Sharp
node -e "const s=require('sharp'); s('source.png').resize(180,180).toFile('public/favicon/apple-touch-icon.png')"
node -e "const s=require('sharp'); s('source.png').resize(32,32).toFile('public/favicon/favicon-32x32.png')"
node -e "const s=require('sharp'); s('source.png').resize(16,16).toFile('public/favicon/favicon-16x16.png')"Create public/favicon/site.webmanifest:
{
"name": "{{SITE_NAME}}",
"short_name": "{{SITE_NAME}}",
"icons": [
{ "src": "/favicon/favicon-32x32.png", "sizes": "32x32", "type": "image/png" },
{ "src": "/favicon/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }
],
"theme_color": "{{BRAND_PRIMARY}}",
"background_color": "{{BRAND_BACKGROUND}}",
"display": "standalone"
}9.5 PDF Generation Script
Create scripts/generate-pdfs.mjs:
/**
* Generates PDF versions of resource pages using Playwright.
* Renders each /resources/[slug]?print=true page and exports as US Letter PDF.
*
* Usage:
* node scripts/generate-pdfs.mjs # Generate all PDFs
* node scripts/generate-pdfs.mjs slug-name # Generate single PDF
*/
import { chromium } from 'playwright';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { existsSync, mkdirSync } from 'fs';
import { spawn } from 'child_process';
import net from 'net';
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = join(__dirname, '..');
const outputDir = join(projectRoot, 'public', 'downloads');
// CUSTOMIZE: Add your resource pages here
const resources = [
{ slug: 'intro', path: '/resources/intro' },
// { slug: 'assessment-overview', path: '/resources/assessment-overview', multiPage: true },
];
const BASE_URL = process.env.BASE_URL || 'http://localhost:4321';
const PORT = new URL(BASE_URL).port || 4321;
const STARTUP_TIMEOUT = 30_000;
function isPortOpen(port) {
return new Promise((resolve) => {
const socket = net.createConnection({ port, host: 'localhost' });
socket.setTimeout(1000);
socket.on('connect', () => { socket.destroy(); resolve(true); });
socket.on('error', () => { socket.destroy(); resolve(false); });
socket.on('timeout', () => { socket.destroy(); resolve(false); });
});
}
async function waitForServer(port, timeout) {
const start = Date.now();
while (Date.now() - start < timeout) {
if (await isPortOpen(port)) return true;
await new Promise((r) => setTimeout(r, 500));
}
return false;
}
function startDevServer() {
const child = spawn('npx', ['astro', 'dev', '--port', String(PORT)], {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'pipe'],
shell: true,
});
child.stdout.on('data', (data) => {
const line = data.toString().trim();
if (line) console.log(` [dev-server] ${line}`);
});
child.stderr.on('data', (data) => {
const line = data.toString().trim();
if (line) console.log(` [dev-server] ${line}`);
});
return child;
}
function killServer(child) {
if (!child || child.killed) return;
try {
if (process.platform === 'win32') {
spawn('taskkill', ['/pid', String(child.pid), '/f', '/t'], { stdio: 'ignore' });
} else {
child.kill('SIGTERM');
}
} catch { /* Already dead */ }
}
async function generatePdf(browser, resource) {
const page = await browser.newPage();
const url = `${BASE_URL}${resource.path}?print=true`;
const outputPath = join(outputDir, `{{CLOUDFLARE_PROJECT_NAME}}-${resource.slug}.pdf`);
console.log(` Rendering: ${url}`);
try {
await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
await page.evaluate(() => document.fonts.ready);
// Convert logo to base64 for PDF reliability
await page.evaluate(async () => {
const img = document.querySelector('.print-header-brand img');
if (img) {
const response = await fetch(img.src);
const blob = await response.blob();
const reader = new FileReader();
const dataUrl = await new Promise(resolve => {
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
img.src = dataUrl;
}
});
await page.waitForTimeout(1500);
const pdfOptions = {
path: outputPath,
format: 'Letter',
printBackground: true,
preferCSSPageSize: false,
};
if (resource.multiPage) {
pdfOptions.displayHeaderFooter = true;
pdfOptions.headerTemplate = '<span></span>';
pdfOptions.footerTemplate = '<div style="width: 100%; text-align: center; font-size: 7pt; color: #9597A9; padding-top: 4px;"><span class="pageNumber"></span> of <span class="totalPages"></span></div>';
pdfOptions.margin = { top: '0in', right: '0in', bottom: '0.35in', left: '0in' };
} else {
pdfOptions.displayHeaderFooter = false;
pdfOptions.margin = { top: '0in', right: '0in', bottom: '0in', left: '0in' };
}
await page.pdf(pdfOptions);
console.log(` Saved: ${outputPath}`);
return true;
} catch (err) {
console.error(` FAILED: ${resource.slug} -- ${err.message}`);
return false;
} finally {
await page.close();
}
}
async function main() {
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
const targetSlug = process.argv[2];
const toGenerate = targetSlug
? resources.filter(r => r.slug === targetSlug)
: resources;
if (targetSlug && toGenerate.length === 0) {
console.error(`Unknown resource slug: "${targetSlug}"`);
process.exit(1);
}
let serverProcess = null;
const serverAlreadyRunning = await isPortOpen(PORT);
if (!serverAlreadyRunning) {
console.log(`\nStarting dev server on port ${PORT}...`);
serverProcess = startDevServer();
const cleanup = () => killServer(serverProcess);
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
process.on('exit', cleanup);
const ready = await waitForServer(PORT, STARTUP_TIMEOUT);
if (!ready) {
console.error('Dev server did not start in time.');
killServer(serverProcess);
process.exit(1);
}
}
console.log(`\nGenerating ${toGenerate.length} PDF(s)...\n`);
const browser = await chromium.launch();
let success = 0, failed = 0;
try {
for (const resource of toGenerate) {
const ok = await generatePdf(browser, resource);
if (ok) success++; else failed++;
}
} finally {
await browser.close();
if (serverProcess) killServer(serverProcess);
}
console.log(`\nDone. ${success} succeeded, ${failed} failed.\n`);
if (failed > 0) process.exit(1);
}
main().catch(err => { console.error('Fatal error:', err); process.exit(1); });9.6 CREDITS.md Template
Create CREDITS.md:
# Image Credits
| Filename | Description | Source |
|----------|-------------|--------|
| og-default.png | Default Open Graph image | Custom |
| hero.jpg | Homepage hero image | Unsplash / Pexels / Custom |CHECKPOINT: Commit.
git add -A
git commit -m "feat: image pipeline, PDF generation, and asset structure"Section 10: Deployment + Operations
Checkpoint: After this section, the site is live in production.
10.1 First Deployment
# Push to GitHub (triggers deploy workflow)
git push -u origin mainThe deploy workflow will:
- Check out code
- Install dependencies
- Build the Astro site
- Deploy to Cloudflare Pages
10.2 Custom Domain Setup
In Cloudflare Pages dashboard:
- Go to your project > Custom domains
- Add
{{DOMAIN}} - Add
www.{{DOMAIN}} - Cloudflare auto-provisions SSL certificates
Set up www redirect:
- In Cloudflare DNS, ensure both
@andwwwpoint to Pages - Create a Redirect Rule in Cloudflare:
- When: Hostname equals
www.{{DOMAIN}} - Then: Dynamic redirect to
https://{{DOMAIN}}${http.request.uri.path} - Status code: 301
- When: Hostname equals
10.3 Environment Variable Management
Three tiers of environment variables:
| Tier | Where | Access | Example |
|---|---|---|---|
| Build-time (public) | GitHub Actions vars, wrangler.toml [vars] | import.meta.env.VAR in Astro components | TURNSTILE_SITE_KEY, SITE_URL |
| Runtime secrets | Cloudflare Pages secrets | getEnv(locals, 'VAR') in API routes | BREVO_API_KEY, TURNSTILE_SECRET_KEY |
| Local dev | .dev.vars file | Both methods | All secrets for local testing |
10.4 Umami Analytics Setup
Umami is a cookie-free, GDPR-friendly analytics platform. The free cloud tier is sufficient for most sites.
- Sign up at https://cloud.umami.is/
- Add your website:
{{DOMAIN}} - Copy the Website ID (UUID) to your variable sheet as
{{UMAMI_WEBSITE_ID}} - The tracking script is already in
BaseLayout.astro(data-domains restricts tracking to production)
Custom event tracking (already wired into components):
| Event | Triggered By | Purpose |
|---|---|---|
contact-form-submit | ContactForm.astro | Form submission tracking |
newsletter-subscribe | NewsletterSignup.astro | Email list growth |
booking-click | Cal.com CTA buttons | Conversion tracking |
Add custom events in components:
// In form submission handlers:
if (typeof umami !== 'undefined') {
umami.track('contact-form-submit');
}10.5 Manual Wrangler Deployment
For deploying without GitHub Actions (e.g., first deploy or CI bypass):
# Build locally
npm run build
# Deploy to Cloudflare Pages
npx wrangler pages deploy dist/ --project-name={{CLOUDFLARE_PROJECT_NAME}}
# Set secrets (one-time, per environment)
npx wrangler pages secret put BREVO_API_KEY --project-name={{CLOUDFLARE_PROJECT_NAME}}
npx wrangler pages secret put TURNSTILE_SECRET_KEY --project-name={{CLOUDFLARE_PROJECT_NAME}}
npx wrangler pages secret put BREVO_LIST_ID --project-name={{CLOUDFLARE_PROJECT_NAME}}10.6 NEW: Uptime Monitoring
Set up free uptime monitoring (choose one):
Better Stack (recommended, free tier):
- Sign up at https://betterstack.com/
- Add monitor:
https://{{DOMAIN}} - Check interval: 3 minutes
- Alert via email + Slack
UptimeRobot (alternative, free tier):
- Sign up at https://uptimerobot.com/
- Add HTTP(s) monitor:
https://{{DOMAIN}} - Check interval: 5 minutes
10.7 NEW: Error Tracking (Sentry)
For production error tracking:
npm install @sentry/astroAdd to astro.config.mjs:
import sentry from '@sentry/astro';
export default defineConfig({
integrations: [
// ... existing integrations
sentry({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 0.1,
}),
],
});10.8 NEW: Core Web Vitals Monitoring
Umami free tier does not track Core Web Vitals (LCP, INP, CLS). Options:
- Cloudflare Web Analytics (built-in, free): Provides basic RUM data
- Google Search Console: Tracks CWV from Chrome User Experience Report
- Vercel Speed Insights (if migrating): Full RUM with CWV breakdown
For now, rely on Lighthouse CI (Section 8.5) for synthetic CWV measurement.
CHECKPOINT: Verify site is live at https://{{DOMAIN}}.
Section 11: WordPress Migration Path
This section covers migrating an existing WordPress site to Astro. Skip if building from scratch.
11.1 Migration Tools
| Tool | Purpose | Link |
|---|---|---|
| wordpress-export-to-markdown | Convert WP XML export to Markdown files | npm package |
| wp-to-astro | Automated WP-to-Astro migration tool | GitHub |
| Astro Migration Guide | Official Astro docs for WP migration | docs.astro.build |
11.2 High-Level Process
-
Export WordPress content:
- WordPress Admin > Tools > Export > All Content
- Downloads an XML file with all posts, pages, and media
-
Convert to Markdown:
npx wordpress-export-to-markdown --input=export.xml --output=./content -
Map content to Astro collections:
- Posts →
src/content/blog/ - Pages →
src/pages/ - Media →
public/images/
- Posts →
-
Generate 301 redirects:
- Map old WordPress URLs to new Astro URLs
- Add to
public/_redirectsor Cloudflare Redirect Rules
-
Verify:
- Check all URLs return 200 or redirect correctly
- Verify images are served
- Test forms and interactive elements
- Submit new sitemap to Google Search Console
11.3 Common WordPress URL Patterns
# WordPress -> Astro URL mapping
/yyyy/mm/dd/post-slug/ -> /blog/post-slug
/category/cat-name/ -> /blog (with filter)
/wp-content/uploads/... -> /images/...
/feed/ -> /rss.xml
Add redirects in Cloudflare:
- Go to Rules > Redirect Rules
- Create rules for each old URL pattern
Section 12: Quality Assurance Process
12.1 Build Verification
After every change set:
# 1. Build
npm run build
# 2. Run tests
npx playwright test
# 3. Check for build warnings
npm run build 2>&1 | grep -i "warn"12.2 Lighthouse Targets
All pages should score 90+ in all four categories:
| Category | Target | Key Metrics |
|---|---|---|
| Performance | 90+ | LCP < 2.5s, FID < 100ms, CLS < 0.1 |
| Accessibility | 90+ | No critical/serious WCAG violations |
| Best Practices | 90+ | HTTPS, no mixed content, no deprecated APIs |
| SEO | 90+ | Meta tags, semantic HTML, sitemap |
12.3 WCAG 2.1 AA Checklist
| Requirement | Implementation |
|---|---|
| Color contrast 4.5:1 | Design tokens validated |
| Keyboard navigation | Tab order, Escape key for dropdowns |
| Skip to content | <a href="#main-content"> in BaseLayout |
| Focus indicators | :focus styles on all interactive elements |
| Alt text | All images have descriptive alt attributes |
| Touch targets | min-h-[44px] on all interactive elements |
| Reduced motion | prefers-reduced-motion media query in global.css |
| Form labels | All inputs have associated <label> elements |
| Error messages | Form validation with visible error states |
12.4 Senior Reviewer Workflow
After every significant change set, launch a Senior Reviewer agent:
Reviewer responsibilities:
- Read all changed source files
- Verify build succeeds (
npm run build) - Run the full E2E test suite (
npx playwright test) - Check for regressions, convention violations, and security issues
- Document findings as PASS/FAIL categories
What to verify:
- Content accuracy (no placeholder text, no lorem ipsum)
- Template variable consistency (all
{{VAR}}replaced correctly) - SEO meta tags present and unique per page
- Security headers applied
- Forms functional (honeypot hidden, Turnstile renders, submission works)
- Mobile responsive (no horizontal scroll)
- Accessibility (skip link, alt text, keyboard nav, focus indicators)
- Image optimization (WebP + JPG pairs, correct dimensions)
- No console errors
Post-review actions:
- Trivial fixes: apply immediately
- Non-trivial suggestions: add to
FUTURE-SUGGESTIONS.md - On PASS: commit + push to production
12.5 Cross-Browser Testing
Test in:
- Chrome (latest)
- Firefox (latest)
- Safari (latest, if macOS available)
- Mobile Chrome (Android)
- Mobile Safari (iOS)
12.6 Pre-Launch Review Checklist
- All pages return 200 status
- No console errors (excluding third-party)
- Contact form sends email successfully
- Newsletter subscription works
- Cal.com booking modal opens
- Mobile navigation works
- All links are valid (no 404s)
- Images load with WebP fallback
- RSS feed is valid
- Sitemap is generated
- robots.txt is correct
- Favicon displays in browser tab
- OG images render in social media previews
- Print/PDF versions render correctly (if applicable)
- Security headers are applied
- SSL certificate is valid
- www redirects to apex (or vice versa)
Section 12.5: Optional: TinaCMS for Client Self-Service
Include this section when the client needs to edit content without developer assistance.
Overview
TinaCMS is a git-based CMS that provides visual editing for Astro sites. It commits changes directly to the Git repository, maintaining the developer workflow.
When to Include
- Client needs to edit blog posts, pages, or content frequently
- Client is not comfortable using Markdown or Git
- Budget allows for TinaCMS Cloud ($29/mo) or self-hosting setup
Setup
- Install TinaCMS:
npx @tinacms/cli@latest init- Configure content models in
tina/config.ts:
import { defineConfig } from 'tinacms';
export default defineConfig({
branch: process.env.TINA_BRANCH || 'main',
clientId: process.env.TINA_CLIENT_ID || '',
token: process.env.TINA_TOKEN || '',
build: {
outputFolder: 'admin',
publicFolder: 'public',
},
media: {
tina: {
mediaRoot: 'images',
publicFolder: 'public',
},
},
schema: {
collections: [
{
name: 'blog',
label: 'Blog Posts',
path: 'src/content/blog',
format: 'md',
fields: [
{ type: 'string', name: 'title', label: 'Title', required: true },
{ type: 'string', name: 'description', label: 'Description', required: true },
{ type: 'datetime', name: 'date', label: 'Publish Date', required: true },
{ type: 'string', name: 'author', label: 'Author' },
{ type: 'string', name: 'pillar', label: 'Content Pillar', list: false, options: [
// CUSTOMIZE: Match your blog pillars
{ value: 'general', label: 'General' },
]},
{ type: 'number', name: 'readingTime', label: 'Reading Time (minutes)', required: true },
{ type: 'boolean', name: 'draft', label: 'Draft' },
{ type: 'rich-text', name: 'body', label: 'Body', isBody: true },
],
},
],
},
});- Access the CMS:
- Local:
http://localhost:4321/admin - Production:
https://{{DOMAIN}}/admin(requires TinaCMS Cloud or self-hosted backend)
- Local:
Deployment Options
| Option | Cost | Pros | Cons |
|---|---|---|---|
| TinaCMS Cloud | $29/mo | Managed, easy setup | Monthly cost |
| Self-hosted | Free | No cost | Requires backend setup |
Section 13: Post-Launch Checklist
13.1 DNS Verification
# Verify A/CNAME records
dig A {{DOMAIN}} +short
dig CNAME www.{{DOMAIN}} +short
# Verify SSL
curl -sI https://{{DOMAIN}} | head -5
# Verify HSTS
curl -sI https://{{DOMAIN}} | grep -i "strict-transport"
# Verify nameservers
dig NS {{DOMAIN}} +short13.2 SEO Submission
-
Google Search Console:
- Go to https://search.google.com/search-console
- Add property:
{{SITE_URL}} - Verify via DNS TXT record
- Submit sitemap:
{{SITE_URL}}/sitemap-index.xml
-
Bing Webmaster Tools:
- Go to https://www.bing.com/webmasters
- Add site and verify
- Submit sitemap
13.3 Functional Testing
- Submit contact form with real data
- Subscribe to newsletter with real email
- Click booking CTA and complete a test booking
- Visit every page and check for visual issues
- Test on mobile device (not just responsive mode)
- Verify emails arrive (both notification and auto-reply)
13.4 Performance Baseline
Run Lighthouse on key pages and record scores:
| Page | Performance | Accessibility | Best Practices | SEO |
|---|---|---|---|---|
| / | ||||
| /about | ||||
| /contact | ||||
| /blog |
Target: 90+ in all categories.
13.5 Documentation Checklist
After launch, ensure these files exist in the project:
-
CLAUDE.md- Claude Code conventions and project context -
design-tokens.md- Design system reference -
content-style-guide.md- Writing style and voice guidelines -
CREDITS.md- Image and asset attribution -
.env.example- Template for environment variables -
README.md- Project overview and setup instructions -
FUTURE-SUGGESTIONS.md- Backlog of improvements and ideas
13.6 CLAUDE.md Template
Create a CLAUDE.md at the project root to guide future Claude Code sessions:
# {{SITE_NAME}} Website — Claude Code Instructions
## Master Documentation
- **Design tokens:** `design-tokens.md`
- **Content style guide:** `content-style-guide.md`
## Design System
- Follow `design-tokens.md` — do not introduce new colors, fonts, or spacing values
- Page background: {{BRAND_BACKGROUND}}, NOT pure white
- Card/content areas: white (#FFFFFF) for depth against background
## Code Conventions
- TypeScript for all `.ts` files
- Tailwind CSS utility classes — avoid custom CSS where Tailwind suffices
- Astro components for all pages and layouts
- Use `import.meta.env.VARIABLE_NAME` in Astro components
- Use `process.env.VARIABLE_NAME` in config files
- Never hardcode site URL, domain, or project names — read from `.env`
- **Booking URL:** Always use `BOOKING_URL` from `src/lib/constants.ts`
- **Cal.com links:** Every `data-cal-link` element must also have `href={BOOKING_URL}` as a no-JS fallback
## Every Page Must Have
- Unique `<title>` tag
- Unique `<meta name="description">`
- Open Graph tags (og:title, og:description, og:image, og:url)
- Twitter card meta tags
- Canonical URL
- Responsive design (mobile-first)
## Images
- Use `<picture>` element with WebP source + JPG fallback
- Stock photos: `public/images/stock/`
- Logos: `public/images/logo/`
## Performance Target
- All pages: 90+ on Lighthouse for Performance, Accessibility, Best Practices, SEO
## Quality Gate
- After every change set: run `npm run build` and `npx playwright test`
- Fix any failures before committing
## Git Conventions
- Conventional commits
- Never commit `.env` or `.dev.vars`13.7 .env.example Template
Create .env.example with placeholder values:
# {{SITE_NAME}} — Environment Variables
# Copy to .dev.vars for local development
# Cloudflare Turnstile (use test keys for local dev)
TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
# Brevo Email
BREVO_API_KEY=xkeysib-your-key-here
BREVO_SENDER_EMAIL={{SENDER_EMAIL}}
BREVO_SENDER_NAME={{SENDER_NAME}}
BREVO_LIST_ID=2
# Site Configuration
SITE_NAME={{SITE_NAME}}
SITE_URL=http://localhost:4321
# Umami Analytics (optional for local dev)
# UMAMI_WEBSITE_ID=your-uuid-hereAppendix A: Full File Reference
Complete File Tree
{{CLOUDFLARE_PROJECT_NAME}}/
|-- .github/
| |-- dependabot.yml # Dependency update automation
| |-- workflows/
| |-- deploy.yml # Push-to-main deployment
| |-- test.yml # E2E tests on push/PR
| |-- scheduled-deploy.yml # Daily auto-publish
|-- public/
| |-- _headers # Cloudflare security headers
| |-- .well-known/
| | |-- security.txt # Security contact (RFC 9116)
| |-- downloads/ # Generated PDFs
| |-- favicon/
| | |-- apple-touch-icon.png
| | |-- favicon-16x16.png
| | |-- favicon-32x32.png
| | |-- site.webmanifest
| |-- images/
| |-- logo/ # Brand logos (PNG + WebP)
| |-- stock/ # Stock photos (JPG + WebP)
| |-- team/ # Headshots (500x500 JPG + WebP)
| |-- og-default.png # OG image (1200x630)
|-- scripts/
| |-- generate-pdfs.mjs # Playwright PDF generator
|-- src/
| |-- components/
| | |-- BlogCard.astro # Blog post card
| | |-- ContactForm.astro # Contact form with Turnstile
| | |-- FAQ.astro # FAQ with filters + JSON-LD
| | |-- Footer.astro # Site footer with newsletter
| | |-- Nav.astro # Sticky nav with dropdowns
| | |-- NewsletterSignup.astro # Newsletter form
| | |-- StatsBanner.astro # Credibility metrics
| | |-- TeamMember.astro # Team bio card
| | |-- icons/
| | |-- LinkedInIcon.astro
| | |-- XTwitterIcon.astro
| |-- content/
| | |-- blog/ # Blog post markdown files
| |-- content.config.ts # Content collection schemas
| |-- layouts/
| | |-- BaseLayout.astro # Base layout (all pages)
| | |-- PrintLayout.astro # Dual-mode web/print layout
| |-- lib/
| | |-- api-helpers.ts # API utilities
| | |-- constants.ts # Site-wide constants
| |-- middleware.ts # Staging noindex middleware
| |-- pages/
| | |-- 404.astro # Custom 404
| | |-- about.astro
| | |-- accessibility.astro
| | |-- blog/
| | | |-- index.astro # Blog listing
| | | |-- [...slug].astro # Blog detail
| | |-- contact.astro
| | |-- for/ # Vertical landing pages (§5.5)
| | | |-- vertical-a.astro # e.g., /for/healthcare
| | | |-- vertical-b.astro # e.g., /for/finance
| | |-- index.astro # Homepage
| | |-- newsletter.astro
| | |-- privacy.astro
| | |-- resources/ # Resource pages
| | |-- rss.xml.ts # RSS feed
| | |-- robots.txt.ts # Dynamic robots.txt
| | |-- terms.astro
| | |-- thank-you.astro
| | |-- api/
| | |-- send-email.ts # Contact form API
| | |-- newsletter-subscribe.ts # Newsletter API
| |-- styles/
| |-- global.css # Global styles + font imports
|-- tests/
| |-- e2e/
| |-- helpers/
| | |-- api-mocks.ts # Mock API routes for testing
| |-- accessibility.spec.ts # axe-playwright a11y tests
| |-- blog.spec.ts # Blog listing + detail tests
| |-- cal-embed.spec.ts # Cal.com booking modal tests
| |-- contact-form.spec.ts # Contact form tests
| |-- legal-pages.spec.ts # Privacy/terms/a11y tests
| |-- mobile-nav.spec.ts # Mobile navigation tests
| |-- newsletter.spec.ts # Newsletter subscribe tests
| |-- security-headers.spec.ts # Security headers tests
| |-- seo.spec.ts # SEO meta tag tests
| |-- smoke.spec.ts # All-pages smoke tests
|-- .dev.vars # Local dev secrets (gitignored)
|-- .gitignore
|-- astro.config.mjs # Astro configuration
|-- CLAUDE.md # Claude Code project instructions
|-- CREDITS.md # Image/asset attribution
|-- design-tokens.md # Design system reference
|-- package.json
|-- playwright.config.ts # Playwright test configuration
|-- tailwind.config.mjs # Tailwind CSS configuration
|-- tsconfig.json # TypeScript configuration
|-- wrangler.toml # Cloudflare Workers configuration
File-to-Section Mapping
| File | Playbook Section |
|---|---|
astro.config.mjs | 2.3 |
tailwind.config.mjs | 2.4 |
wrangler.toml | 2.6 |
src/lib/constants.ts | 3.1 |
src/lib/api-helpers.ts | 3.2 |
src/layouts/BaseLayout.astro | 3.3 |
src/layouts/PrintLayout.astro | 5.6 |
src/components/Nav.astro | 3.4 |
src/components/Footer.astro | 3.5 |
src/components/ContactForm.astro | 3.7 |
src/components/NewsletterSignup.astro | 3.8 |
src/components/BlogCard.astro | 3.9 |
src/components/TeamMember.astro | 3.10 |
src/components/StatsBanner.astro | 3.11 |
src/components/FAQ.astro | 3.12 |
src/pages/api/send-email.ts | 4.1 |
src/pages/api/newsletter-subscribe.ts | 4.2 |
src/middleware.ts | 4.3 |
src/pages/rss.xml.ts | 4.4 |
src/pages/robots.txt.ts | 4.5 |
src/content.config.ts | 5.1 |
src/pages/for/*.astro | 5.5 |
src/pages/resources/*.astro | 5.6 |
public/_headers | 6.1 |
public/.well-known/security.txt | 6.3 |
playwright.config.ts | 7.1 |
tests/e2e/helpers/api-mocks.ts | 7.9 |
tests/e2e/newsletter.spec.ts | 7.10 |
tests/e2e/cal-embed.spec.ts | 7.11 |
tests/e2e/accessibility.spec.ts | 7.12 |
.github/workflows/deploy.yml | 8.1 |
.github/workflows/test.yml | 8.2 |
.github/workflows/scheduled-deploy.yml | 8.3 |
.github/dependabot.yml | 8.4 |
lighthouse-budget.json | 8.5 |
scripts/generate-pdfs.mjs | 9.5 |
CLAUDE.md | 13.6 |
.env.example | 13.7 |
Appendix B: Troubleshooting
Common Build Errors
“Cannot find module ‘@astrojs/cloudflare’”
npm install @astrojs/cloudflare“Type error: Property ‘runtime’ does not exist”
- This is a TypeScript error for the Cloudflare adapter runtime context
- Add
/// <reference types="@astrojs/cloudflare" />tosrc/env.d.ts
“Error: Expected a valid module specifier”
- Check
astro.config.mjsimports; ensure all integration packages are installed
Cloudflare Adapter Issues
API routes return 404 in production:
- Ensure
export const prerender = false;is set on all API routes - Verify wrangler.toml
compatibility_dateis not too old
Environment variables undefined in API routes:
- Use
getEnv(locals, 'KEY')instead ofimport.meta.env.KEY - Secrets must be set via
wrangler pages secret put
Build fails in Cloudflare Pages:
- Check Node.js version: set
NODE_VERSION=22in Pages environment variables - Verify all dependencies are in
package.json(not just dev dependencies)
Turnstile Issues
Turnstile widget doesn’t render:
- Check
TURNSTILE_SITE_KEYis set correctly - Ensure
localhostis in the Turnstile widget’s allowed domains - Check browser console for CSP violations
Turnstile always fails in tests:
- Use test key:
1x00000000000000000000AA(always passes) - Set via
webServer.envinplaywright.config.ts
Sharp on Windows
“Something went wrong installing the ‘sharp’ package”
# Clear npm cache
npm cache clean --force
# Reinstall sharp
npm install sharp --platform=win32
# If still failing, use Python/Pillow as fallback for image processingPlaywright CI Failures
Tests timeout in CI:
- Increase
timeoutin playwright.config.ts - Use
workers: 1in CI for deterministic execution - Ensure
webServer.timeoutaccounts for build time
“Executable doesn’t exist” error:
npx playwright install --with-deps chromiumInconsistent test results:
- Add
retries: 2for CI - Use
webServer.reuseExistingServer: falsein CI - Check for race conditions in tests that modify state
Email Not Sending
Brevo API returns 401:
- API key is incorrect or expired
- Re-generate in Brevo > Settings > SMTP & API
Emails going to spam:
- Verify DKIM, SPF, and DMARC DNS records
- Escalate DMARC from
p=nonetop=quarantine - Check Brevo sender reputation
Auto-reply not sending:
- Auto-reply failure is logged but doesn’t block the main notification
- Check Brevo logs for the specific error
Cal.com Modal Issues
Modal doesn’t open:
- Check CSP allows
frame-src https://challenges.cloudflare.com https://app.cal.com - Verify
data-cal-linkattribute matches your Cal.com event slug - Check browser console for script loading errors
Modal opens but shows blank:
- Cal.com may be blocked by ad blockers
- The fallback
hrefshould redirect tocal.com/{{CAL_LINK}}
Lessons Learned: What We Changed to Make It Professional
These lessons come from building the Solanasis website iteratively over weeks across many Claude Code sessions. They represent the delta between “working” and “professional-grade.” Apply them proactively when building a new site.
Design & Visual Polish
-
Parchment background, not white. Pure white pages feel clinical. A warm background color (e.g., FEF9F1) with white cards on top creates visual depth and warmth. This single change transforms the feel of the entire site.
-
CTA hierarchy matters. We went through multiple iterations before settling on a 3-tier CTA system:
- Primary (copper/accent bg):
bg-accent text-primary— one per section max - Secondary (outline):
border-2 border-accent text-accent— supporting actions - Tertiary (navy bg):
bg-primary text-parchment— footer, less emphasis Without this hierarchy, every button competes for attention and nothing converts.
- Primary (copper/accent bg):
-
Hero gradient, not flat color. A
linear-gradient(135deg, primary 0%, secondary 65%, accent 130%)creates visual interest without being busy. Much more professional than a flat color hero. -
Card shadows + rounded corners. The combination of
rounded-card(custom border-radius) +shadow-card+hover:shadow-card-hoveron cards gives tactile depth. Define these as design tokens, not inline values. -
Email obfuscation via JS data-attributes. Never put a mailto: link in plain HTML. Assemble the email from
data-e1anddata-e2attributes on hover/focus using JavaScript. Same for phone numbers. This dramatically reduces spam. -
Touch targets: 44px minimum. Every button and link needs
min-h-[44px]. This isn’t just accessibility compliance; it makes the site feel intentional on mobile.
Component Architecture
-
Shared ContactForm component. We initially had separate forms on different pages. Consolidating into one
ContactForm.astrowith randomized field IDs (anti-bot), honeypot, Turnstile, Umami tracking, and success/error states eliminated bugs and inconsistency. -
FAQ component evolution. The FAQ started as simple details/summary. We later added category filter pills, category group headers with icons, and FAQPage JSON-LD schema. The upgrade happened because prospects were scanning for specific topics. Build the filterable version from the start.
-
Dual-mode PrintLayout. Adding
?print=truemode to resource pages was a game-changer for the sales process. It meant one source of truth for both web pages and downloadable PDFs. Key lesson: inline all CSS in print mode (external stylesheets don’t render in Playwright PDF export). -
Cal.com lazy-load pattern. Loading the Cal.com embed on page load adds ~200KB to every page. Instead, load it on first click of any booking CTA. The embed script goes in BaseLayout, shadow DOM CSS customization keeps it on-brand. Every
data-cal-linkelement must also havehref={BOOKING_URL}as a no-JS fallback.
Content & SEO
-
No acronym expansion on the website. Industry professionals know what SEC, RIA, FINRA, NIST, MFA mean. Expanding them reads as condescending and hurts credibility. Keep a documented acronym policy in
content-style-guide.md. -
Product naming consistency. We spent time renaming services across the site for consistency. Pick one canonical name per service (e.g., “Compliance Readiness Assessment” not “security checkup”) and use it everywhere. Store in
constants.ts. -
JSON-LD on every page type. We added Article schema on blog posts, FAQPage schema on FAQ sections, LocalBusiness schema on the homepage, and ProfessionalService schema. This is invisible to users but significantly helps search engines.
-
Blog future-date filtering. The
data.date <= new Date()filter combined with a daily scheduled deploy workflow (cron: '0 12 * * *') means you can write posts ahead and they auto-publish. This was a late addition that transformed the content workflow.
Security Hardening
-
Turnstile test key in Playwright config. The magic key
1x00000000000000000000AAalways passes verification. Set it viawebServer.envin playwright.config.ts so tests never wait for real CAPTCHA. -
Input validation at the boundary. Define
MAX_LENGTHSonce inapi-helpers.tsand validate every field against them.escapeHtml()every user input before putting it in email templates. This is the kind of thing that’s easy to skip and devastating when exploited. -
CORS origin checking. The
ALLOWED_ORIGINconstant inapi-helpers.tsrestricts API access to your domain only. Without it, anyone can POST to your contact form API from any origin. -
Staging noindex middleware. A single middleware that checks
SITE_URLand addsX-Robots-Tag: noindexfor non-production domains. Prevents staging sites from being indexed by search engines.
Testing & CI/CD
-
Build + preview, not dev server. Running tests against
npm run build && npm run previewcatches build-time errors that the dev server hides (missing env vars, SSR issues, prerender failures). The 120s webServer timeout accounts for the full build. -
API mocking in tests. The
tests/e2e/helpers/api-mocks.tspattern lets tests verify form behavior without hitting real APIs. Mock success, error, delay, and abort scenarios. -
SHA-pinned GitHub Actions. Every
uses:in workflow files references a specific commit SHA, not a version tag. This prevents supply chain attacks where a compromised action tag could exfiltrate secrets. -
4 local workers, 1 in CI. Parallel test execution locally (fast feedback) but single-threaded in CI (deterministic results). The
process.env.CIternary in playwright.config.ts handles this automatically.
Process & Workflow
-
Senior Reviewer after every change set. Having an automated reviewer catch issues before pushing to production eliminated most bugs from reaching users. The reviewer should check: build success, test pass, content accuracy, SEO tags, security headers, mobile responsiveness, and accessibility.
-
FUTURE-SUGGESTIONS.md as a living backlog. Instead of losing improvement ideas, document them in a structured file with the Hormozi Value Equation framework. This became the roadmap for iterative improvement.
-
Design tokens document. Having
design-tokens.mdas a single reference for all colors, fonts, spacing, and shadows prevented gradual drift. When adding new elements, check the design tokens first; never introduce new values ad hoc.
Research Findings: Best Practices Checklist
Items identified through research that should be implemented when time allows:
Critical (do soon)
- Uptime monitoring (Section 10.6)
- axe-playwright accessibility testing (Section 7.12)
- Core Web Vitals RUM measurement (Section 10.8)
- WordPress migration toolkit (Section 11)
Important (next 1-2 months)
- FAQPage JSON-LD structured data (Section 3.12)
- BreadcrumbList structured data
- Subresource Integrity via astro-shield (Section 6.4)
- Rate limiting on API endpoints (Section 6.5)
- Lighthouse CI in GitHub Actions (Section 8.5)
- Error tracking with Sentry (Section 10.7)
- CSP reporting endpoint
- Visual regression testing (Playwright snapshots)
- SAST scanning with Semgrep
- Explicit
prerender = trueon static pages - Deploy failure notifications (Slack/email webhook)
- Cloudflare WAF Free Managed Ruleset (Section 6.5)
- Unique OG images per blog post
- security.txt (Section 6.3)
Nice-to-have (backlog)
- Astro
<Image>component migration - Pagefind site search
- TinaCMS for client self-service (Section 12.5)
- HSTS preload submission
- Cloudflare DNS API scripts
- Preview deployment URL bot on PRs
- Astro 6 evaluation (when stable)
Strategic Note
Cloudflare acquired Astro in January 2026. Astro + Cloudflare is now the tightest framework/hosting integration in the market. Astro 6 beta brings native workerd runtime, built-in CSP nonce support, and first-class Durable Objects. This validates the tech stack choice and should be monitored for migration opportunities.
Generated by Solanasis LLC. This playbook is a proprietary deliverable. Last updated: 2026-03-20