Neue Skills, Referenzen & OpenWiki-Doku integriert

Umfangreiche Erweiterung der Skill-Bibliothek: Neue Skills für Humanisierung (Englisch/PT-BR), Design-Validierung, AI-SEO und Coolify-Deployment inkl. Regelwerke, Presets, Pattern-Referenzen, Testfälle und Automatisierungsskripte. Zusätzliche Skills für Revenue-Centric Design, Pier Cloud, OKF, Lebenslauf- und LinkedIn-Optimierung sowie zahlreiche Referenzdateien, Checklisten und YAML/JSON/Markdown-Templates. Einführung einer vollständigen OpenWiki-Dokumentation mit Architektur-, Domain- und Workflow-Beschreibungen, zentralem Index und automatisierten Updates. Modularer Aufbau, restriktive Lizenzen und umfassende Qualitäts- und Evaluationsmechanismen für alle neuen Inhalte.
This commit is contained in:
Tim Krampitz
2026-07-26 14:00:58 +02:00
parent 070727d5cd
commit 01046b01e4
202 changed files with 31290 additions and 0 deletions

View File

@@ -0,0 +1,172 @@
# AI Dev Server Guide
Reference for AI agents interacting with the Astro development server programmatically.
---
## 1. Background Mode
Start the dev server as a detached background process that blocks until the server is fully ready to accept requests:
```bash
astro dev --background
```
### Auto-Detection
Astro automatically detects AI agent environments and enables background mode without explicit flags. This applies to known CI/agent runtimes.
### Lockfile
A lockfile at `.astro/dev.json` prevents duplicate server instances. If a server is already running, the lockfile ensures a second `astro dev --background` call returns the existing instance info instead of spawning a new process.
### Commands
| Command | Description |
|---------|-------------|
| `astro dev --background` | Start detached server, block until ready |
| `astro dev stop` | Stop the running background server |
| `astro dev status` | Check if a background server is running |
| `astro dev logs` | Stream logs from the background server |
### Opt-Out
Disable automatic background mode by setting the environment variable:
```bash
ASTRO_DEV_BACKGROUND=0 astro dev
```
---
## 2. Health Endpoint
Verify the dev server is ready before making requests:
```
GET /_astro/status
```
Response:
```json
{"ok": true}
```
> **Important:** This endpoint is only available in development mode. It does not exist in production builds.
### Usage
```bash
curl http://localhost:4321/_astro/status
```
Wait for a `200` response with `{"ok": true}` before issuing any page requests.
---
## 3. JSON Logging
Enable structured JSON output for machine-readable log parsing:
```bash
astro dev --json
```
### Configuration in `astro.config.mjs`
```js
import { logHandlers } from 'astro';
export default defineConfig({
logger: logHandlers.json(),
});
```
### Compose Multiple Handlers
Output to both console and JSON simultaneously:
```js
import { logHandlers } from 'astro';
export default defineConfig({
logger: logHandlers.compose(
logHandlers.console(),
logHandlers.json()
),
});
```
### Auto-Enabled
JSON logging is automatically enabled when an AI agent environment is detected.
### Use Cases
- Error parsing — structured error objects with file, line, column
- Build status — track compilation progress programmatically
- HMR events — detect when hot module replacement completes after file changes
---
## 4. Agent Workflow
Step-by-step workflow for AI agents developing with Astro:
```bash
# 1. Start the dev server in background (blocks until ready)
astro dev --background
# 2. Verify the server is ready
curl http://localhost:4321/_astro/status
# 3. Make changes to source files
# (edit .astro, .ts, .css files as needed)
# 4. Verify output after HMR processes changes
curl http://localhost:4321/page-to-test
# 5. Cleanup when done
astro dev stop
```
### Notes
- Step 2 should return `{"ok": true}` before proceeding.
- After step 3, wait briefly for HMR to process before step 4.
- Always run step 5 to avoid orphaned processes.
---
## 5. Idempotency Rules
The dev server commands are designed to be safely called multiple times:
| Scenario | Behavior |
|----------|----------|
| Start when already running | Returns existing instance info (port, PID) |
| Stop when not running | Silent success (exit code 0) |
| Crash or unexpected termination | Lockfile is cleaned up, no zombie processes |
These guarantees mean agents can call `astro dev --background` at the start of every task without checking current state first, and call `astro dev stop` at cleanup without error handling.
---
## 6. MCP Integration
The Astro Docs MCP server provides real-time documentation access:
- **Endpoint:** `https://mcp.docs.astro.build/mcp`
- **Tool:** `search_astro_docs`
### Usage
Always query the MCP server for the latest API details, configuration options, and component references rather than relying on cached knowledge.
```
search_astro_docs("dev server background mode")
search_astro_docs("content collections config")
```
This ensures agents work with current documentation even as Astro's API evolves between versions.

View File

@@ -0,0 +1,415 @@
# Deploying Astro on Coolify
Production-tested patterns for deploying Astro sites on self-hosted Coolify (v4.x). Based on 17+ live deployments.
---
## Build Pack Decision
| Scenario | build_pack | Notes |
|----------|-----------|-------|
| Astro v6+ (requires Node ≥22.12.0) | `dockerfile` | Nixpacks can't pin minor version |
| Astro v5 or earlier | `nixpacks` | `NIXPACKS_NODE_VERSION=22` works |
| Astro `output: 'static'` with package.json | `nixpacks` | start: `npx serve dist -l 80 -s` |
| Astro `output: 'static'` (Dockerfile) | `dockerfile` | nginx serves directly |
| HTML/CSS only (no package.json) | `static` | `static_image: nginx:alpine` |
**Rule:** For Astro v6+ and v7, always use `dockerfile`. Nixpacks resolves Node 22.11.0 from its internal nixpkgs archive, but Astro v6+ requires ≥22.12.0.
**Astro v7 Docker base image rule:** Use `node:22-slim` (Debian/glibc) for the build stage, NOT `node:22-alpine`. Sätteri's native binding only supports glibc. The runtime stage can still use Alpine/Caddy since it only serves files.
---
## Dockerfile — Astro SSR (Node Adapter)
> **Requires `@astrojs/node@^11.0.0`** for Astro v7. The v10 adapter crashes at runtime with `TypeError: app.getAdapterLogger is not a function`.
```dockerfile
# Use node:22-slim (NOT alpine) — Sätteri needs glibc for Astro v7
FROM node:22-slim AS build
WORKDIR /app
ENV NODE_OPTIONS="--max-old-space-size=512"
# Coolify injects env vars as ARG — must convert to ENV for npm run build
ARG MY_API_KEY
ARG PUBLIC_SITE_URL
ENV MY_API_KEY=$MY_API_KEY
ENV PUBLIC_SITE_URL=$PUBLIC_SITE_URL
COPY package*.json .npmrc ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD ["node", "dist/server/entry.mjs"]
```
## Dockerfile — Astro Static (nginx)
```dockerfile
# Use node:22-slim (NOT alpine) — Sätteri needs glibc
FROM node:22-slim AS build
WORKDIR /app
ENV NODE_OPTIONS="--max-old-space-size=512"
COPY package*.json .npmrc ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
```
---
## Critical Gotchas
### ARG vs ENV — Build-time secrets
Coolify injects variables as Docker `ARG`. But `ARG` does NOT become an environment variable for child processes like `npm run build`. Astro/Vite resolves `import.meta.env.VAR` during build — if the variable doesn't exist in the process environment, it silently becomes `undefined`.
**Fix:** For every secret the build needs:
```dockerfile
ARG RESEND_API_KEY
ENV RESEND_API_KEY=$RESEND_API_KEY
```
### OOM on Resource-Limited Servers
The `astro build` process can die with exit code 255 and no clear error message on servers with limited RAM (~2GB).
**Fix:** Add to build stage:
```dockerfile
ENV NODE_OPTIONS="--max-old-space-size=512"
```
### Nixpacks Node Version
Nixpacks only accepts **major version**. `NIXPACKS_NODE_VERSION=22` can resolve to 22.11.0, causing:
```
Node.js v22.11.0 is not supported by Astro!
```
**Fix options:**
1. Use Dockerfile instead (recommended for Astro v6+)
2. Set `NIXPACKS_NODE_VERSION=24` (skips a major)
3. Pin nixpkgs archive via `nixpacks.toml`:
```toml
[phases.setup]
nixpkgsArchive = "5ef6c8a1bf89a0bfe4e15e7baf5bab7feeff86a5"
```
### Nixpacks Timeout
Nixpacks downloads ~600MB nixpkgs archive during build. On servers with limited bandwidth, build dies silently during `unpacking` step.
**Fix:** Switch to Dockerfile. `node:22-alpine` is ~50MB vs ~600MB.
### Sätteri Native Binding on Alpine (Astro v7)
Astro v7 uses Sätteri (Rust-based Markdown) by default. Sätteri ships native bindings but **only for glibc** (`@bruits/satteri-linux-x64-gnu`). Alpine uses musl libc — no musl binding exists, and the WASM fallback has a cpu platform check that also fails.
```
Cannot find module '@bruits/satteri-linux-x64-musl'
```
**Fix:** Use `node:22-slim` (Debian/glibc) for the build stage. The runtime stage can still use Alpine since it only serves static files:
```dockerfile
FROM node:22-slim AS build # glibc — satteri works
WORKDIR /app
COPY package*.json .npmrc ./
RUN npm ci
COPY . .
RUN npm run build
FROM caddy:2-alpine # runtime doesn't need Node
COPY --from=build /app/dist /srv
```
**Affected projects:** Any Astro v7 project using Sätteri (default) or Starlight 0.40+ on Alpine.
**Not affected:** Projects using `unified()` processor explicitly (they bypass Sätteri).
### Sätteri Native Binding on ARM64 (Cross-Platform Lockfile)
When the dev machine is x86_64 but the Coolify build server is ARM64 (e.g., OCI Ampere), `npm ci` and even `npm install --include=optional` fail with:
```
Cannot find module '@bruits/satteri-linux-arm64-gnu'
Require stack:
- /app/node_modules/satteri/index.js
```
**Root cause:** The `package-lock.json` was generated on x86_64 and only includes `@bruits/satteri-linux-x64-gnu` in its optional dependency tree. npm respects the lockfile's platform resolution even on a different architecture — this is [npm bug #4828](https://github.com/npm/cli/issues/4828).
**What does NOT work:**
- `.npmrc` with `include=optional` — npm still reads the lockfile's platform tree
- `npm install --include=optional` in Dockerfile — lockfile still constrains resolution
- Adding `@bruits/satteri-linux-arm64-gnu` to `optionalDependencies` — npm may still skip it
**Fix:** Do NOT copy `package-lock.json` into the Docker build. Let npm resolve fresh on arm64:
```dockerfile
FROM node:22-slim AS build
WORKDIR /app
ENV NODE_OPTIONS="--max-old-space-size=512"
COPY package.json .npmrc ./
# Deliberately omit package-lock.json — forces fresh resolution on arm64
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
```
**Tradeoff:** Build is slightly less deterministic (no lockfile pinning in Docker). For static sites this is acceptable. For SSR with strict reproducibility needs, generate the lockfile inside an arm64 container instead.
**Affected:** Any Astro v7 project building on ARM64 servers when lockfile was generated on x86_64.
**Confirmed working:** valeria.med.br on OCI Ampere A1 via Coolify (2026-07-03).
### legacy-peer-deps and npm ci in Docker
When using `--legacy-peer-deps` locally (required for Astro v7 due to transient peer dep conflicts in Starlight plugins), Docker's `npm ci` will fail unless the `.npmrc` is copied into the container.
**Fix:** Always copy `.npmrc` before `npm ci`:
```dockerfile
COPY package*.json .npmrc ./
RUN npm ci
```
The `.npmrc` must contain:
```
legacy-peer-deps=true
```
### @astrojs/node Must Be v11+ for Astro v7
Astro v7's runtime API changed — `app.getAdapterLogger()` was added and the standalone entry module depends on it. If `@astrojs/node` stays at v10, the container builds fine but **crashes at startup**:
```
TypeError: app.getAdapterLogger is not a function
at createAppHandler (dist/server/entry.mjs)
```
Coolify shows `restarting:unknown` or `exited:unhealthy` — the build log looks green, but the container crash-loops.
**Fix:** Always upgrade `@astrojs/node` to v11 together with Astro v7:
```bash
npm install astro@latest @astrojs/node@latest
```
**Checklist for SSR v7 migration:**
- `astro` → `^7.0.0`
- `@astrojs/node` → `^11.0.0`
- `@astrojs/mdx` → `^7.0.0` (if used)
### pnpm approve-builds in Docker (pnpm 11.9+)
pnpm 11.9+ blocks install scripts (postinstall, install) by default. Packages like `esbuild` and `sharp` need native binaries built after install. Without approval, `pnpm install --frozen-lockfile` fails:
```
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: esbuild@0.28.1, sharp@0.34.5
Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.
```
**Fix:** Run `pnpm approve-builds` locally, which creates `pnpm-workspace.yaml` with:
```yaml
allowBuilds:
esbuild: true
sharp: true
```
Then **copy `pnpm-workspace.yaml` into the Docker container** alongside the lockfile:
```dockerfile
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
RUN pnpm install --frozen-lockfile
```
Missing this file = build fails in CI/Docker but works locally (because local node_modules already has the binaries).
### package-lock.json Desync After Major Upgrade
After `npm install --legacy-peer-deps` for a major version upgrade, the lockfile may reference packages that `npm ci` (strict mode) cannot resolve. Symptoms: `npm ci` fails with "lock file's X does not satisfy Y".
**Fix:** Delete lockfile and regenerate:
```bash
rm package-lock.json node_modules -rf
npm install --legacy-peer-deps
# Then test: npm ci must pass
```
### Integrations That Download ML Models (transformers.js, ONNX)
Integrations like `@philnash/astro-related-content` download ONNX models (~300MB) during `astro build` to generate embeddings. On Coolify servers with limited bandwidth/disk, this causes 20+ minute builds or disk exhaustion.
**Pattern:** Generate artifacts locally, commit them, skip the heavy integration in CI.
**Fix:** Dual-mode config with `ENV CI=true` in Dockerfile. See [Related Content reference](references/related-content.md) for complete implementation.
**Key principle:**
- **Local:** Integration runs fully (downloads model, generates embeddings)
- **CI/Docker:** Vite plugin serves pre-built `data.json` (zero model download)
- **Cache commitado:** `.astro-related-content/data.json` + `vectors.json` go in git (~750KB for 32 posts)
This pattern applies to ANY integration that downloads large artifacts at build time.
---
## Coolify API — Create App
```bash
COOLIFY_URL="https://cool.example.com/api/v1"
COOLIFY_KEY="your-token"
curl -sS -X POST "$COOLIFY_URL/applications/private-deploy-key" \
-H "Authorization: Bearer $COOLIFY_KEY" \
-H "Content-Type: application/json" \
-d '{
"project_uuid": "PROJECT_UUID",
"environment_name": "production",
"server_uuid": "SERVER_UUID",
"private_key_uuid": "SSH_KEY_UUID",
"git_repository": "git@gitlab.com:user/project.git",
"git_branch": "main",
"build_pack": "dockerfile",
"dockerfile_location": "/Dockerfile",
"ports_exposes": "4321",
"name": "my-astro-site"
}'
```
### Set Domain
```bash
# Use "domains", NOT "fqdn" — fqdn returns "field not allowed"
curl -sS -X PATCH "$COOLIFY_URL/applications/$APP_UUID" \
-H "Authorization: Bearer $COOLIFY_KEY" \
-H "Content-Type: application/json" \
-d '{"domains": "https://mysite.com"}'
```
### Set Environment Variables
```bash
curl -sS -X POST "$COOLIFY_URL/applications/$APP_UUID/envs" \
-H "Authorization: Bearer $COOLIFY_KEY" \
-H "Content-Type: application/json" \
-d '{"key": "MY_VAR", "value": "secret-value", "is_preview": false}'
```
> Do NOT send `is_build_time` — API rejects it.
### GitLab Webhook (auto-deploy on push)
```bash
curl -sS -X POST "https://gitlab.com/api/v4/projects/$PROJECT_ID/hooks" \
-H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://cool.example.com/webhooks/source/gitlab/events/manual",
"token": "WEBHOOK_SECRET_FROM_APP",
"push_events": true,
"enable_ssl_verification": true
}'
```
> **Critical:** The secret goes in the `"token"` field (sent as `X-Gitlab-Token` header), NEVER as `?secret=` query parameter in the URL. With secret in URL, Coolify returns 200 but does NOT trigger deploy.
### Deploy and Validate
```bash
# Trigger deploy (Coolify 4.1+ — uses GET, NOT POST)
# The /deploy endpoint accepts uuid as query param, force=true rebuilds from scratch
curl -sS -X GET "$COOLIFY_URL/deploy?uuid=$APP_UUID&force=true" \
-H "Authorization: Bearer $COOLIFY_KEY"
# Returns: {"deployments":[{"message":"Application X deployment queued.","resource_uuid":"...","deployment_uuid":"..."}]}
# ⚠️ POST /applications/$UUID/deploy returns "Not found" in Coolify 4.1
# ⚠️ POST /applications/$UUID/restart only restarts existing container (no rebuild)
# Use restart when image is already built. Use /deploy?uuid=...&force=true for rebuild.
# Check status (~60s wait for build + container start)
curl -sS -H "Authorization: Bearer $COOLIFY_KEY" \
"$COOLIFY_URL/applications/$APP_UUID" | python3 -c "
import sys,json; d=json.load(sys.stdin); print(d['status'])"
# Verify HTTP response
curl -sS -o /dev/null -w "HTTP %{http_code}\n" https://mysite.com
```
**Status interpretation:**
| Status | Meaning |
|--------|---------|
| `running:healthy` | Container up and health check passing |
| `running:unknown` | Container up, no health check configured |
| `restarting:unknown` | Container crash-looping — check runtime logs |
| `exited:unhealthy` | Container stopped — likely build or startup failure |
---
## Astro Config for Coolify SSR
```typescript
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'server', // or hybrid with per-page prerender
adapter: node({ mode: 'standalone' }),
server: { host: '0.0.0.0', port: 4321 },
});
```
For static output, no adapter needed — the Dockerfile handles nginx serving.
---
## Port Configuration
| Output Mode | Port | CMD |
|-------------|------|-----|
| SSR (Node adapter) | 4321 | `node dist/server/entry.mjs` |
| Static (nginx) | 80 | nginx default |
| Static (serve) | 80 | `npx serve dist -l 80 -s` |
Set `ports_exposes` in Coolify to match.
---
## Recommended Stack (Homelab-Tested)
Based on 17 production Astro sites:
```javascript
import seoGraph from '@jdevalk/astro-seo-graph/integration';
import agentmarkup from '@agentmarkup/astro';
import UnoCSS from '@unocss/astro';
import critters from 'astro-critters';
import compress from '@playform/compress';
// Key: compress() MUST be last integration
integrations: [mdx(), UnoCSS(), sitemap(), seoGraph(), agentmarkup(), critters(), compress()]
```
| Tool | Why |
|------|-----|
| UnoCSS > Tailwind | 5x faster build, smaller bundle |
| @playform/compress > astro-compress | Better maintained |
| astro-critters | Critical CSS inlining |
| @jdevalk/astro-seo-graph | All-in-one SEO (replaces astro-seo + robots-txt + indexnow) |
| @agentmarkup/astro | LLM visibility (llms.txt, markdown mirrors) |
| Plausible > GA4 | 1kb script, no cookie banner, self-hosted |
| @philnash/astro-related-content | Semantic related posts via local embeddings (CI: use prebuilt data.json) |

View File

@@ -0,0 +1,396 @@
# Deployment Guide
> Astro 7 deployment across all major platforms. Covers adapters, route caching, and platform-specific gotchas.
---
## 1. General Build
```bash
astro build # output in dist/
astro preview # test production build locally
```
Key config in `astro.config.mjs`:
```js
import { defineConfig } from 'astro/config';
export default defineConfig({
site: 'https://example.com',
base: '/',
trailingSlash: 'never', // 'always' | 'never' | 'ignore'
});
```
- `site` — full production URL (required for sitemaps, canonical URLs, RSS)
- `base` — subpath when deploying to a subdirectory (e.g., `/docs`)
- `trailingSlash` — MUST match hosting platform expectations to avoid redirect loops
---
## 2. Cloudflare Pages
**Adapter:** `@astrojs/cloudflare`
```bash
npx astro add cloudflare
```
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
output: 'server',
adapter: cloudflare(),
});
```
**Route Caching (private beta):**
```js
// astro.config.mjs
import { cacheCloudflare } from '@astrojs/cloudflare/cache';
export default defineConfig({
output: 'server',
adapter: cloudflare(),
experimental: {
serverIslands: true,
},
routeCache: cacheCloudflare(),
});
```
**Deploy:**
- Connect git repo in Cloudflare Dashboard → Pages → Create a project
- Build command: `astro build`
- Build output directory: `dist`
- Node.js compatibility flag is set automatically by the adapter
---
## 3. Vercel
**Adapter:** `@astrojs/vercel`
```bash
npx astro add vercel
```
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel';
import { cacheVercel } from '@astrojs/vercel/cache';
export default defineConfig({
output: 'server',
adapter: vercel(),
routeCache: cacheVercel(),
});
```
**ISR via routeRules:**
```js
// astro.config.mjs
export default defineConfig({
output: 'server',
adapter: vercel({
isr: true, // enable ISR globally
}),
routeCache: cacheVercel({
routeRules: {
'/blog/**': { revalidate: 60 }, // revalidate every 60s
'/static/**': { prerender: true }, // fully static at build
},
}),
});
```
**Deploy:**
- Connect repo via Vercel Dashboard or `vercel` CLI
- Framework preset: Astro (auto-detected)
---
## 4. Netlify
**Adapter:** `@astrojs/netlify`
```bash
npx astro add netlify
```
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import netlify from '@astrojs/netlify';
import { cacheNetlify } from '@astrojs/netlify/cache';
export default defineConfig({
output: 'server',
adapter: netlify(),
routeCache: cacheNetlify(),
});
```
**Deploy:**
- Connect repo in Netlify Dashboard
- Build command: `astro build`
- Publish directory: `dist`
- Functions auto-detected from adapter output
---
## 5. Firebase Hosting
**Static only** — no adapter needed for SSG output.
```js
// astro.config.mjs
export default defineConfig({
output: 'static',
trailingSlash: 'never', // CRITICAL: must match Firebase config
});
```
**firebase.json:**
```json
{
"hosting": {
"public": "dist",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"trailingSlash": false,
"rewrites": [
{ "source": "**", "destination": "/404.html" }
]
}
}
```
**Deploy:**
```bash
astro build
firebase deploy --only hosting
```
### CRITICAL: trailingSlash Alignment
Mismatch between Firebase and Astro causes **infinite redirect loops**.
| Firebase `trailingSlash` | Astro `trailingSlash` | Result |
|---|---|---|
| `false` | `'never'` | ✅ Works |
| `true` | `'always'` | ✅ Works |
| `false` | `'always'` | ❌ Redirect loop |
| `true` | `'never'` | ❌ Redirect loop |
---
## 6. GitHub Pages
**Static output only** — no adapter needed.
```js
// astro.config.mjs
export default defineConfig({
site: 'https://username.github.io',
base: '/repo-name', // omit for username.github.io root
output: 'static',
});
```
**GitHub Actions workflow** (`.github/workflows/deploy.yml`):
```yaml
name: Deploy to GitHub Pages
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
```
---
## 7. Docker / Self-Hosted (Coolify)
**Adapter:** `@astrojs/node`
```bash
npx astro add node
```
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone',
}),
server: {
host: '0.0.0.0', // REQUIRED for Docker
port: 4321,
},
});
```
**Dockerfile:**
```dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package.json ./
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:4321/api/health || exit 1
CMD ["node", "./dist/server/entry.mjs"]
```
**Health check endpoint** (`src/pages/api/health.ts`):
```ts
import type { APIRoute } from 'astro';
export const GET: APIRoute = () => {
return new Response(JSON.stringify({ status: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
```
**Coolify:** Set Dockerfile build pack, expose port 4321, configure health check to `/api/health`.
---
## 8. Azure Static Web Apps
Works with **static output** (SSG). For SSR, use Azure Functions integration.
**GitHub Actions workflow** (`.github/workflows/azure-swa.yml`):
```yaml
name: Azure Static Web Apps
on:
push:
branches: [main]
jobs:
build_and_deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- uses: Azure/static-web-apps-deploy@v1
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_TOKEN }}
repo_token: ${{ secrets.GITHUB_TOKEN }}
action: upload
app_location: /
output_location: dist
skip_app_build: true
```
The `skip_app_build: true` pattern means we build ourselves (for control over Node version and env vars) and only upload the output.
**staticwebapp.config.json:**
```json
{
"navigationFallback": {
"rewrite": "/404.html"
},
"globalHeaders": {
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff"
},
"routes": [
{
"route": "/api/*",
"allowedRoles": ["authenticated"]
}
]
}
```
For **private registry auth** (private npm packages):
```yaml
- run: |
echo "//npm.pkg.github.com/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
- run: npm ci
```
---
## 9. Pre-Deploy Checklist
- [ ] `astro build` exits 0
- [ ] `astro check` reports no errors
- [ ] `astro preview` works correctly (test production build locally)
- [ ] Images use `<Image/>` component or are in `public/`
- [ ] SEO metadata present on all pages (title, description, og tags)
- [ ] `src/pages/404.astro` exists
- [ ] Environment variables set on target platform
- [ ] `trailingSlash` matches hosting platform expectations
- [ ] Sitemap generating correctly (`@astrojs/sitemap`)
- [ ] RSS feed working if applicable (`@astrojs/rss`)
- [ ] Route caching configured for SSR pages (platform-specific cache helper)
- [ ] `robots.txt` present and correct
- [ ] HTTPS redirect configured on platform
- [ ] Custom domain DNS configured and propagated

View File

@@ -0,0 +1,190 @@
# Installing the Astro Docs MCP Server
The Astro Docs MCP server provides real-time access to the latest Astro documentation via the Model Context Protocol.
- **URL:** `https://mcp.docs.astro.build/mcp`
- **Transport:** Streamable HTTP
- **Tool:** `search_astro_docs`
- **Source:** Open-source, powered by kapa.ai
---
## By Tool
### Kiro CLI
```bash
kiro-cli mcp add --name astro-docs --scope global --command npx --args "-y" --args "mcp-remote" --args "https://mcp.docs.astro.build/mcp"
```
Or create/edit `~/.kiro/settings/mcp.json`:
```json
{
"mcpServers": {
"astro-docs": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.docs.astro.build/mcp"],
"env": {}
}
}
}
```
### Claude Code CLI
```bash
claude mcp add --transport http astro-docs https://mcp.docs.astro.build/mcp
```
### Codex CLI
Add to `~/.codex/config.toml`:
```toml
[mcp_servers.astro-docs]
command = "npx"
args = ["-y", "mcp-remote", "https://mcp.docs.astro.build/mcp"]
```
### Cursor
Use the deeplink or add to `.cursor/mcp.json`:
```json
{
"mcpServers": {
"Astro docs": {
"type": "http",
"url": "https://mcp.docs.astro.build/mcp"
}
}
}
```
### VS Code (Copilot Chat)
Add to `.vscode/mcp.json`:
```json
{
"mcpServers": {
"Astro docs": {
"type": "http",
"url": "https://mcp.docs.astro.build/mcp"
}
}
}
```
### Windsurf
Edit `~/.codeium/windsurf/mcp_config.json`:
```json
{
"mcpServers": {
"Astro docs": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.docs.astro.build/mcp"]
}
}
}
```
### Gemini CLI
Add to `.gemini/settings.json`:
```json
{
"mcpServers": {
"Astro docs": {
"httpUrl": "https://mcp.docs.astro.build/mcp"
}
}
}
```
### Zed
Add to `~/.config/zed/settings.json`:
```json
{
"context_servers": {
"Astro docs": {
"settings": {},
"enabled": true,
"url": "https://mcp.docs.astro.build/mcp"
}
}
}
```
### Claude.ai / Claude Desktop
1. Go to Settings → Connectors
2. Click "Add custom connector"
3. URL: `https://mcp.docs.astro.build/mcp`
4. Name: `Astro docs`
### Warp
Settings → AI → MCP Servers → Add:
```json
{
"mcpServers": {
"Astro docs": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.docs.astro.build/mcp"],
"start_on_launch": true
}
}
}
```
---
## Generic (any tool supporting MCP)
**Streamable HTTP** (preferred):
```json
{
"mcpServers": {
"Astro docs": {
"type": "http",
"url": "https://mcp.docs.astro.build/mcp"
}
}
}
```
**Local Proxy** (for tools that only support stdio):
```json
{
"mcpServers": {
"Astro docs": {
"type": "stdio",
"command": "npx",
"args": ["-y", "mcp-remote", "https://mcp.docs.astro.build/mcp"]
}
}
}
```
---
## Troubleshooting
| Issue | Fix |
|---|---|
| Server not responding | Verify URL is exactly `https://mcp.docs.astro.build/mcp` |
| Tool not connecting | Check internet access; some firewalls block MCP |
| Stale results | MCP always fetches latest docs — no cache to clear |
| Local proxy crashes | Ensure `npx` is in PATH and Node.js ≥ 18 installed |
Issues: https://github.com/withastro/docs-mcp/issues

View File

@@ -0,0 +1,557 @@
# Migration Guide: Astro v6 → v7
> Official reference: https://docs.astro.build/en/guides/upgrade-to/v7/
---
## 1. Pre-Migration Checklist
- [ ] **Backup** — commit all changes, create a branch: `git checkout -b feat/astro-v7-upgrade`
- [ ] **Node.js ≥ 22** — required (v22.5.0+ for `node:sqlite` if replacing `@astrojs/db`)
```bash
node -v # must be >= 22
```
- [ ] **Audit dependencies** — check for packages that depend on Vite internals or the Go compiler
```bash
npx astro info
```
- [ ] **Review remark/rehype plugins** — if you have any, plan for Sätteri migration or `@astrojs/markdown-remark` fallback
- [ ] **Check for `src/fetch.ts`** — if this file exists for non-routing purposes, plan a rename
- [ ] **Check for `@astrojs/db`** usage — plan a replacement (Drizzle, node:sqlite, Turso, Neon)
---
## 2. Upgrade Commands
```bash
# npm
npx @astrojs/upgrade
# pnpm
pnpm dlx @astrojs/upgrade
# yarn
yarn dlx @astrojs/upgrade
```
This upgrades Astro and all official integrations together. For manual control:
```bash
npm install astro@latest
npm install @astrojs/react@latest @astrojs/mdx@latest # repeat for each integration
```
---
## 3. Breaking Changes
### 3.1 Vite 8
Astro v7 upgrades to [Vite 8](https://vite.dev/blog/announcing-vite8). The main impact is on **custom Vite plugins** and projects using Vite internals directly.
Key Vite 8 changes:
- **esbuild → Rolldown** as the production bundler (Rolldown is a Rust-based Rollup replacement)
- Plugin API surface changes — check the [Vite 8 migration guide](https://vite.dev/guide/migration)
**What to do:**
- If you have custom Vite plugins in `astro.config.mjs`, verify they work with Vite 8
- If you use `esbuild`-specific options (e.g. `esbuild.target`, `esbuild.jsxFactory`), check if they still apply under Rolldown
```js
// Before: esbuild-specific config (may need review)
export default defineConfig({
vite: {
esbuild: {
target: 'esnext',
jsxFactory: 'h',
},
},
});
// After: verify compatibility — most configs carry over, but test your build
export default defineConfig({
vite: {
// Rolldown handles bundling; esbuild options may behave differently
// Test `astro build` and check output
},
});
```
> **Most Astro users need no changes.** This primarily affects integration authors and projects with custom Vite plugins.
---
### 3.2 Rust Compiler
The Rust-based compiler is now the **default and only compiler**, replacing the Go-based compiler. It is stricter about HTML syntax.
#### Unclosed tags now produce errors
```astro
<!-- Before: Go compiler silently accepted this -->
<p>Hello world
<!-- After: Rust compiler requires closing tags -->
<p>Hello world</p>
```
```astro
---
import Layout from '../layouts/Layout.astro';
---
<!-- Before: unclosed component tag accepted -->
<Layout>
<p>Content here
<!-- After: all tags must be closed -->
<Layout>
<p>Content here</p>
</Layout>
```
> **Void elements** (`<br>`, `<img>`, `<input>`, `<hr>`) do NOT need closing tags.
#### No HTML auto-correction
The Go compiler silently reordered invalid HTML (e.g. `<div>` inside `<p>`). The Rust compiler passes markup through as-is.
```astro
<!-- Before: compiler restructured this silently -->
<p>
<div>Block inside paragraph</div>
</p>
<!-- After: browser handles it (will close <p> early, breaking layout) -->
<!-- Fix: use valid nesting -->
<div>
<div>Block content here</div>
</div>
```
#### JSX whitespace handling
See [Section 3.5 compressHTML](#35-compresshtml-jsx-is-new-default) for the related whitespace changes.
#### CSS output differences (cosmetic, no action needed)
- Named colors may become hex: `rebeccapurple` → `#639`
- `url()` values may gain/lose quotes: `url(/path)` ↔ `url('/path')`
---
### 3.3 Reserved File Name: `src/fetch.ts`
`src/fetch.ts` (or `.js`) is now reserved for [advanced routing](https://docs.astro.build/en/guides/routing/#advanced-routing) configuration.
```js
// Before: you had src/fetch.ts for custom fetch logic
// src/fetch.ts — your custom utility
export function fetchData() { /* ... */ }
// After: Option A — rename your file
// src/fetcher.ts (or src/api-client.ts, etc.)
export function fetchData() { /* ... */ }
// Update all imports:
// import { fetchData } from '../fetch' → import { fetchData } from '../fetcher'
```
```js
// After: Option B — disable advanced routing in astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
fetchFile: null, // disables advanced routing, keeps your src/fetch.ts
});
```
```js
// After: Option C — point fetchFile elsewhere
import { defineConfig } from 'astro/config';
export default defineConfig({
fetchFile: './src/router.ts', // use a different file for advanced routing
});
```
---
### 3.4 New Default Markdown Processor: Sätteri
[Sätteri](https://satteri.bruits.org/) replaces the remark/rehype (unified) pipeline as the default Markdown processor. `@astrojs/markdown-remark` is no longer installed by default.
**If you DON'T use remark/rehype plugins:** no action needed. Sätteri applies GFM and SmartyPants like before.
**If you DO use remark/rehype plugins:**
```bash
# Install the unified pipeline package
npm install @astrojs/markdown-remark
```
```js
// Before: plugins configured directly (worked because unified was the default)
import { defineConfig } from 'astro/config';
import remarkToc from 'remark-toc';
import rehypeSlug from 'rehype-slug';
export default defineConfig({
markdown: {
remarkPlugins: [remarkToc],
rehypePlugins: [rehypeSlug],
},
});
// After: explicitly set unified() as processor + install @astrojs/markdown-remark
import { defineConfig } from 'astro/config';
import { unified } from '@astrojs/markdown-remark';
import remarkToc from 'remark-toc';
import rehypeSlug from 'rehype-slug';
export default defineConfig({
markdown: {
processor: unified({
remarkPlugins: [remarkToc],
rehypePlugins: [rehypeSlug],
}),
},
});
```
**Alternative:** Port your plugins to Sätteri MDAST/HAST plugins:
```js
// Using Sätteri with its native plugin model
import { defineConfig } from 'astro/config';
import { satteri } from '@astrojs/markdown-satteri';
import { myMdastPlugin } from './my-satteri-plugin.mjs';
export default defineConfig({
markdown: {
processor: satteri({
mdastPlugins: [myMdastPlugin()],
features: { directive: true },
}),
},
});
```
---
### 3.5 `compressHTML: 'jsx'` is New Default
Whitespace between inline elements is now stripped using JSX rules (like React), instead of HTML-aware compression.
```astro
<!-- Before (v6): renders as "hello world" (space preserved) -->
<span>hello</span>
<em>world</em>
<!-- After (v7): renders as "helloworld" (space removed) -->
<span>hello</span>
<em>world</em>
```
**Fix: add explicit space with `{' '}`:**
```astro
<!-- After: explicit space between inline elements -->
<span>hello</span>{' '}<em>world</em>
```
**Or revert to v6 behavior globally:**
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
compressHTML: true, // v6 HTML-aware behavior
// compressHTML: false // preserve ALL whitespace
});
```
---
## 4. Deprecated
### `getContainerRenderer()` from package root
Importing `getContainerRenderer()` from the integration's package root is deprecated. Use the dedicated `/container-renderer` entrypoint.
```js
// Before
import { getContainerRenderer } from '@astrojs/react';
// After
import { getContainerRenderer } from '@astrojs/react/container-renderer';
```
Available for: `@astrojs/react`, `@astrojs/preact`, `@astrojs/solid-js`, `@astrojs/svelte`, `@astrojs/vue`, `@astrojs/mdx`.
---
## 5. Removed
### 5.1 `@astrojs/db`
The package is removed and no longer maintained. Replace with:
| Alternative | Use case |
|---|---|
| `node:sqlite` | Node.js adapter, local SQLite (Node ≥ 22.5.0) |
| [Drizzle ORM](https://orm.drizzle.team/) | Schema-based queries with any DB |
| [Turso](https://turso.tech/) | Edge SQLite (libSQL) |
| [Neon](https://neon.tech/) | Serverless Postgres |
```bash
# Remove
npm uninstall @astrojs/db
```
```js
// Before: @astrojs/db
import { db, sql } from 'astro:db';
const results = await db.select().from(Posts).all();
// After: Drizzle ORM example
import { drizzle } from 'drizzle-orm/node-postgres';
import { posts } from './schema';
const db = drizzle(process.env.DATABASE_URL);
const results = await db.select().from(posts);
```
Remove `db` from `astro.config.mjs` integrations array and delete `db/` config files.
---
### 5.2 `astro:transitions` Internals
The following exports are removed:
| Removed API | Replacement |
|---|---|
| `TRANSITION_BEFORE_PREPARATION` | `'astro:before-preparation'` |
| `TRANSITION_AFTER_PREPARATION` | `'astro:after-preparation'` |
| `TRANSITION_BEFORE_SWAP` | `'astro:before-swap'` |
| `TRANSITION_AFTER_SWAP` | `'astro:after-swap'` |
| `TRANSITION_PAGE_LOAD` | `'astro:page-load'` |
| `isTransitionBeforePreparationEvent()` | `event.type === 'astro:before-preparation'` |
| `isTransitionBeforeSwapEvent()` | `event.type === 'astro:before-swap'` |
| `createAnimationScope()` | Remove entirely |
```js
// Before
import {
TRANSITION_AFTER_SWAP,
isTransitionBeforePreparationEvent,
} from 'astro:transitions/client';
document.addEventListener(TRANSITION_AFTER_SWAP, (event) => {
if (isTransitionBeforePreparationEvent(event)) { /* ... */ }
});
// After
document.addEventListener('astro:after-swap', (event) => {
if (event.type === 'astro:before-preparation') { /* ... */ }
});
```
---
## 6. Experimental Flags to Remove (Now Stable)
Remove these from your `astro.config.mjs` `experimental` block:
| Flag | Status in v7 |
|---|---|
| `experimental.logger` | Stable — use top-level `logger` field |
| `experimental.queuedRendering` | Default behavior — just remove |
| `experimental.rustCompiler` | Default and only compiler — just remove |
| `experimental.advancedRouting` | Default — just remove (note: `src/fetch.ts` is now reserved) |
| `experimental.cache` | Stable — move to top-level `cache` field |
| `experimental.routeRules` | Stable — move to top-level `routeRules` field |
```js
// Before
import { defineConfig, logHandlers, memoryCache } from 'astro/config';
export default defineConfig({
experimental: {
logger: logHandlers.json({ pretty: true }),
queuedRendering: { enabled: true },
rustCompiler: true,
advancedRouting: true,
cache: { provider: memoryCache() },
routeRules: {
'/blog/[...path]': { maxAge: 300, swr: 60 },
},
},
});
// After
import { defineConfig, logHandlers, memoryCache } from 'astro/config';
export default defineConfig({
logger: logHandlers.json({ pretty: true }),
cache: { provider: memoryCache() },
routeRules: {
'/blog/[...path]': { maxAge: 300, swr: 60 },
},
});
```
---
## 7. Post-Migration Validation
Run these steps after upgrading:
```bash
# 1. Install dependencies
npm install
# 2. Run the dev server — check for compiler errors
npm run dev
# 3. Run a full production build
npm run build
# 4. Preview the production build
npm run preview
# 5. Check for visual regressions (especially whitespace issues from compressHTML)
# Open key pages and inspect inline element spacing
# 6. Run tests if you have them
npm test
# 7. Check TypeScript
npx astro check
```
**What to look for:**
- ❌ Compiler errors about unclosed tags → add missing closing tags
- ❌ Layout shifts or broken nesting → fix invalid HTML (block elements inside `<p>`, etc.)
- ❌ Missing spaces between inline elements → add `{' '}` where needed
- ❌ Markdown rendering issues → install `@astrojs/markdown-remark` if using remark/rehype plugins
- ❌ Build errors mentioning `src/fetch.ts` → rename or set `fetchFile: null`
- ❌ Import errors for `@astrojs/db` → replace with alternative DB solution
- ❌ Import errors for `TRANSITION_*` constants → use event name strings directly
---
## Quick Reference: Search & Replace
| Find | Replace with |
|---|---|
| `from '@astrojs/react'` (for getContainerRenderer) | `from '@astrojs/react/container-renderer'` |
| `TRANSITION_BEFORE_PREPARATION` | `'astro:before-preparation'` |
| `TRANSITION_AFTER_PREPARATION` | `'astro:after-preparation'` |
| `TRANSITION_BEFORE_SWAP` | `'astro:before-swap'` |
| `TRANSITION_AFTER_SWAP` | `'astro:after-swap'` |
| `TRANSITION_PAGE_LOAD` | `'astro:page-load'` |
| `isTransitionBeforePreparationEvent(e)` | `e.type === 'astro:before-preparation'` |
| `isTransitionBeforeSwapEvent(e)` | `e.type === 'astro:before-swap'` |
| `createAnimationScope` | (remove entirely) |
| `experimental.rustCompiler` | (remove) |
| `experimental.queuedRendering` | (remove) |
| `experimental.advancedRouting` | (remove) |
---
## Ecosystem Compatibility (learned from real upgrades)
### Starlight 0.40+ Sidebar Schema Change
Starlight 0.40 (required for Astro v7) changed the sidebar schema. `autogenerate` can no longer be a direct property of a sidebar group — it must be inside `items`:
```javascript
// BEFORE (Starlight 0.38):
{ label: 'Reference', autogenerate: { directory: 'reference' } }
// AFTER (Starlight 0.40):
{ label: 'Reference', items: [{ autogenerate: { directory: 'reference' } }] }
```
### astro-mermaid — Incompatible with v7
`astro-mermaid` (all versions through 2.0.4) uses `isUnifiedProcessor()` which was removed in Astro v7. Replace with Mermaid CDN client-side script:
```javascript
// In Starlight head config or Layout.astro:
{
tag: 'script',
attrs: { type: 'module' },
content: `
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: false });
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('pre > code.language-mermaid').forEach((el) => {
const pre = el.parentElement;
const div = document.createElement('div');
div.className = 'mermaid';
div.textContent = el.textContent;
pre.replaceWith(div);
});
mermaid.run();
});
`,
}
```
### Docker Alpine — Sätteri native binding missing
Sätteri only ships `linux-x64-gnu` (glibc). Alpine uses musl. Build stage MUST use `node:22-slim`:
```dockerfile
FROM node:22-slim AS build # NOT alpine
```
### .npmrc required in Dockerfile
Astro v7 with Starlight plugins causes peer dependency conflicts. The `.npmrc` with `legacy-peer-deps=true` must be copied into Docker:
```dockerfile
COPY package*.json .npmrc ./
RUN npm ci
```
### @astrojs/node Must Be v11 for Astro v7
`@astrojs/node@10` builds fine but **crashes at runtime** with Astro v7:
```
TypeError: app.getAdapterLogger is not a function
at createAppHandler (dist/server/entry.mjs)
```
The build succeeds, the image is created, the container starts — then immediately exits. Coolify shows `restarting:unknown` or `exited:unhealthy`.
**Fix:** Always upgrade `@astrojs/node` alongside Astro:
```bash
npm install astro@latest @astrojs/node@latest @astrojs/mdx@latest
```
**Required versions for v7:**
| Package | Minimum |
|---------|---------|
| `astro` | `^7.0.0` |
| `@astrojs/node` | `^11.0.0` |
| `@astrojs/mdx` | `^7.0.0` |
| `@astrojs/sitemap` | `^3.7.2` (unchanged) |
### pnpm 11.9+ — approve-builds Required in Docker
pnpm 11.9 blocks postinstall scripts by default. `esbuild` and `sharp` need native compilation after install. Without approval the Docker build fails:
```
[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: esbuild@0.28.1, sharp@0.34.5
```
**Fix:** Run `pnpm approve-builds` locally (generates `pnpm-workspace.yaml`), then copy it in Docker:
```dockerfile
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
RUN pnpm install --frozen-lockfile
```

View File

@@ -0,0 +1,394 @@
# Related Content com Vector Embeddings
Conteúdo relacionado semântico para Astro content collections usando `@philnash/astro-related-content`. Gera sugestões de posts relacionados via vector embeddings locais (transformers.js) sem depender de APIs externas em runtime.
---
## Conceito
A integração calcula similaridade semântica entre posts usando embeddings (vetores numéricos que representam o "significado" do texto). Posts com vetores próximos são semanticamente similares. Tudo roda em build time — zero impacto no visitante.
---
## Instalação
```bash
npm install @philnash/astro-related-content
```
---
## Configuração Básica
```typescript
// astro.config.ts
import astroRelatedContent from '@philnash/astro-related-content'
export default defineConfig({
integrations: [
astroRelatedContent({
collections: ['blog'],
generation: {
limit: 4, // posts relacionados por item
watch: false, // não regenerar em dev mode (economiza CPU)
},
embeddings: {
model: 'onnx-community/embeddinggemma-300m-ONNX',
dtype: 'fp32',
pooling: 'mean',
batchSize: 1,
},
}),
],
})
```
---
## Escolha de Modelo
| Modelo | Idiomas | Context | Tamanho | Pooling | Uso |
|--------|---------|---------|---------|---------|-----|
| `Xenova/all-MiniLM-L6-v2` | EN only | 256 tokens | ~22MB | `mean` | Default, ruim para PT-BR |
| `onnx-community/embeddinggemma-300m-ONNX` | Multilingual | 2048 tokens | ~300MB | `mean` | **Recomendado para PT-BR** |
| `onnx-community/Qwen3-Embedding-0.6B-ONNX` | Multilingual | 32k tokens | ~600MB | `last_token` | Posts muito longos |
| `onnx-community/granite-embedding-small-english-r2-ONNX` | EN | 8192 tokens | ~130MB | `cls` | EN com context longo |
**Regra:** Para conteúdo em português, NUNCA usar o modelo default (`all-MiniLM-L6-v2`). Use `embeddinggemma-300m-ONNX` ou superior.
---
## Custom Provider (LiteLLM, OpenAI, etc.)
A integração aceita custom providers via interface `EmbeddingProvider`:
```typescript
// litellm-provider.ts
import { createEmbeddingProvider } from '@philnash/astro-related-content/providers'
export const litellmProvider = createEmbeddingProvider({
name: 'litellm',
version: '1.0.0',
async embed(texts, options) {
const response = await fetch(`${options.baseUrl}/embeddings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${options.apiKey}`,
},
body: JSON.stringify({ model: options.model, input: texts }),
})
const data = await response.json()
return data.data.map((item: any) => item.embedding)
},
getMetadata(options) {
return { model: options.model, baseUrl: options.baseUrl }
},
})
```
```typescript
// astro.config.ts
import { litellmProvider } from './litellm-provider'
astroRelatedContent({
collections: ['blog'],
embeddings: {
provider: litellmProvider,
baseUrl: 'http://localhost:4000',
apiKey: 'sk-...',
model: 'text-embedding-3-small',
batchSize: 10,
},
})
```
---
## Artefatos Gerados
A integração gera na pasta `.astro-related-content/`:
| Arquivo | Conteúdo | Tamanho típico (32 posts) |
|---------|----------|---------------------------|
| `data.json` | Rankings (top N related por post) | ~14KB |
| `vectors.json` | Cache de embeddings + metadata | ~736KB |
O modelo ONNX é cacheado em `.astro/astro-related-content/models/` (não vai pro repo — `.astro/` está no `.gitignore`).
---
## Uso no Componente
### Bug de compatibilidade Astro v7
O `getRelatedContent()` do virtual module não funciona com Astro v7 glob loader. O motivo: a integração gera IDs como `slug/index` no `data.json`, mas o Astro v7 usa `slug` (sem `/index`) como `entry.id`.
**Workaround:** Usar `getRelatedContentMatches()` + lookup manual:
```astro
---
// RelatedPosts.astro
import { getPostRoute } from '@/lib/data-utils'
import { formatDate } from '@/lib/utils'
import { Icon } from 'astro-icon/components'
import { Image } from 'astro:assets'
import { getCollection, type CollectionEntry } from 'astro:content'
import { getRelatedContentMatches } from 'virtual:astro-related-content'
import Link from './Link.astro'
interface Props {
postId: string
}
const { postId } = Astro.props
const matches = getRelatedContentMatches('blog', `${postId}/index`)
let relatedContent: { entry: CollectionEntry<'blog'>; score: number }[] = []
if (matches.length > 0) {
const allEntries = await getCollection('blog')
const entryById = new Map(allEntries.map((e) => [e.id, e]))
relatedContent = matches.flatMap((match) => {
// match.id = "slug/index", entry.id no Astro v7 = "slug"
const normalizedId = match.id.replace(/\/index$/, '')
const entry = entryById.get(normalizedId) || entryById.get(match.id)
return entry ? [{ entry, score: match.score }] : []
})
}
---
{
relatedContent.length > 0 && (
<section class="mt-12 border-t pt-8">
<h2 class="mb-6 flex items-center gap-2 text-xl font-medium">
<Icon name="lucide:sparkles" class="size-5" />
Leitura Relacionada
</h2>
<div class="grid gap-4 sm:grid-cols-2">
{relatedContent.map((item) => (
<Link
href={getPostRoute(item.entry)}
class="hover:bg-muted/50 flex gap-3 rounded-xl border p-3 transition-colors duration-300"
>
{item.entry.data.image && (
<div class="hidden w-16 shrink-0 sm:block">
<Image
src={item.entry.data.image}
alt={item.entry.data.title}
width={128}
height={67}
class="rounded-md object-cover"
/>
</div>
)}
<div class="min-w-0">
<h3 class="mb-1 truncate text-sm font-medium">
{item.entry.data.title}
</h3>
<p class="text-muted-foreground text-xs">
{formatDate(item.entry.data.date)}
</p>
</div>
</Link>
))}
</div>
</section>
)
}
```
---
## Deploy no Coolify — Sem Baixar Modelo em Produção
### O Problema
Na primeira build Docker, a integração baixa o modelo ONNX (~300MB) e processa todos os embeddings. Em um servidor com bandwidth limitada, isso pode levar 20+ minutos e esgotar disco.
### A Solução: Dual-Mode (Local + CI)
**Princípio:** Gerar embeddings localmente, commitar o cache, e em CI usar apenas o `data.json` pré-gerado via Vite plugin leve (sem modelo, sem transformers.js).
#### 1. Commitar os artefatos
Garantir que `.astro-related-content/` **NÃO** está no `.gitignore`:
```bash
# Verificar
grep "astro-related-content" .gitignore
# Se aparecer, remover a linha
# Commitar cache
git add .astro-related-content/
git commit -m "chore: cache embeddings related content"
```
#### 2. Configuração condicional no astro.config.ts
```typescript
import { existsSync } from 'node:fs'
import { resolve } from 'node:path'
import astroRelatedContent from '@philnash/astro-related-content'
// Em CI: usa data.json pré-gerado sem baixar modelo
// Local: roda integração completa com embeddings
const isCI = Boolean(process.env.CI || process.env.DOCKER)
const dataJsonPath = resolve('.astro-related-content/data.json')
const hasPrebuiltData = existsSync(dataJsonPath)
const relatedContentIntegrations = isCI && hasPrebuiltData
? [] // Virtual module vem do Vite plugin abaixo
: [
astroRelatedContent({
collections: ['blog'],
generation: { limit: 4 },
embeddings: {
model: 'onnx-community/embeddinggemma-300m-ONNX',
dtype: 'fp32',
pooling: 'mean',
batchSize: 1,
},
}),
]
// Plugin Vite leve para CI — serve virtual module do data.json commitado
function relatedContentVitePlugin() {
const VIRTUAL_ID = 'virtual:astro-related-content'
const RESOLVED_ID = '\0' + VIRTUAL_ID
return {
name: 'related-content-prebuilt',
resolveId(id: string) {
if (id === VIRTUAL_ID) return RESOLVED_ID
},
load(id: string) {
if (id !== RESOLVED_ID) return
const absPath = resolve('.astro-related-content/data.json')
return `
import { getCollection } from "astro:content";
import relatedContentData from ${JSON.stringify(`/@fs/${absPath}`)};
export function getRelatedContentMatches(collection, id) {
const collectionData = relatedContentData[collection];
if (!collectionData) return [];
const matches = collectionData[id];
return Array.isArray(matches) ? matches.map((m) => ({ ...m })) : [];
}
export function getRelatedContentIds(collection, id) {
return getRelatedContentMatches(collection, id).map((m) => m.id);
}
export async function getRelatedContent(collection, id) {
const matches = getRelatedContentMatches(collection, id);
const entries = await getCollection(collection);
const entryById = new Map(
entries.flatMap((entry) => {
const normalizedId = String(entry.id).replace(/\\.(md|mdx)$/, "");
return normalizedId === entry.id
? [[entry.id, entry]]
: [[entry.id, entry], [normalizedId, entry]];
}),
);
return matches.flatMap((match) => {
const entry = entryById.get(match.id);
return entry ? [{ entry, score: match.score }] : [];
});
}
`
},
}
}
export default defineConfig({
integrations: [
// ... outras integrações
...relatedContentIntegrations,
],
vite: {
plugins: [
// ... outros plugins
...(isCI && hasPrebuiltData ? [relatedContentVitePlugin()] : []),
],
},
})
```
#### 3. Dockerfile com `ENV CI=true`
```dockerfile
FROM node:22-slim AS build
WORKDIR /app
ENV CI=true
ENV NODE_OPTIONS="--max-old-space-size=512"
COPY package*.json .npmrc ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
```
O `ENV CI=true` ativa o Vite plugin leve. Nenhum modelo é baixado. Build completa em ~10-30s.
---
## Workflow Operacional
| Ação | Onde | O que acontece |
|------|------|----------------|
| Novo post | Local | `astro build` → regenera embedding só do post novo → commit cache → push |
| Editar post | Local | `astro build` → recalcula embedding do editado → commit cache → push |
| Deploy | Coolify | Usa `data.json` pré-commitado → build rápido (~30s) |
| Primeiro setup | Local | Download modelo (~300MB) + embeddings de todos os posts (1-20min) |
### Tempos Reais (32 posts, EmbeddingGemma 300m)
| Etapa | Tempo |
|-------|-------|
| Primeira geração (download modelo + 32 embeddings) | ~22 min |
| Build subsequente local (cache hit) | ~10 s |
| Build CI com data.json pré-gerado | ~10 s |
| Build CI sem cache (modelo baixando) | ~22+ min ❌ |
---
## Checklist de Implementação
- [ ] `npm install @philnash/astro-related-content`
- [ ] Configurar integração no `astro.config.ts` (com lógica CI/local)
- [ ] Criar componente `RelatedPosts.astro` (com workaround Astro v7)
- [ ] Integrar componente no template de post (`[...id].astro` ou similar)
- [ ] Rodar `astro build` localmente para gerar embeddings
- [ ] Verificar `.astro-related-content/` NÃO está no `.gitignore`
- [ ] Commitar `data.json` + `vectors.json`
- [ ] Setar `ENV CI=true` no Dockerfile
- [ ] Deploy e validar no Coolify
---
## Troubleshooting
### Build no Coolify demora 20+ minutos
**Causa:** `CI=true` não setado no Dockerfile, ou `.astro-related-content/data.json` não commitado. A integração completa está rodando e baixando o modelo.
**Fix:** Setar `ENV CI=true` no Dockerfile E commitar a pasta `.astro-related-content/`.
### Related posts não renderizam (array vazio)
**Causa:** Bug de ID entre integração e Astro v7. O `getRelatedContent()` do virtual module não faz match porque IDs diferem.
**Fix:** Usar `getRelatedContentMatches()` + lookup manual com `normalizedId = match.id.replace(/\/index$/, '')`.
### Embeddings ruins para português
**Causa:** Usando modelo default (`all-MiniLM-L6-v2`) que é English-only.
**Fix:** Usar `onnx-community/embeddinggemma-300m-ONNX` (multilingual).
### Cache invalidado a cada build
**Causa:** A metadata do provider (model, dtype, pooling, version) mudou entre builds. A integração invalida todo o cache quando metadata difere.
**Fix:** Não alterar configuração de embeddings após gerar o cache. Se precisar mudar modelo, regenerar tudo localmente e re-commitar.
### `Cannot find module '@huggingface/transformers'` em CI
**Causa:** O pacote `@huggingface/transformers` é dependência transitiva só necessária quando a integração completa roda. Em CI com o Vite plugin, não é necessário.
**Fix:** Se usar o dual-mode (CI plugin), isso não acontece. Se rodar integração em CI, garantir que `npm ci` instala todas deps.

View File

@@ -0,0 +1,712 @@
# SEO Full Stack for Astro
Complete reference for implementing technical SEO, structured data, agent discovery, and performance in Astro sites. Based on the `@jdevalk/astro-seo-graph` stack + complementary patterns.
> **Sources:** [Astro SEO: the definitive guide](https://joost.blog/astro-seo-complete-guide/) by Joost de Valk + official [astro-seo-graph](https://github.com/jdevalk/seo-graph/tree/main/packages/astro-seo-graph) documentation.
---
## 1. Installation
```bash
pnpm add @jdevalk/astro-seo-graph @jdevalk/seo-graph-core
```
`@jdevalk/seo-graph-core` is a transitive dep, but depending on it explicitly lets you pin the version and import piece builders directly.
---
## 2. `<Seo>` Component — Unified Head Metadata
A single component replaces all manual `<head>` management:
```astro
---
import Seo from '@jdevalk/astro-seo-graph/Seo.astro';
---
<Seo
title="My Post | My Site"
description="A concise description for search engines."
canonical="https://example.com/my-post/"
ogType="article"
ogImage="https://example.com/og/my-post.jpg"
ogImageAlt="My Post"
ogImageWidth={1200}
ogImageHeight={675}
siteName="My Site"
twitter={{ card: 'summary_large_image', site: '@handle' }}
article={{ publishedTime: publishDate, tags: ['Astro', 'SEO'] }}
graph={graph}
extraLinks={[
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
{ rel: 'sitemap', href: '/sitemap-index.xml' },
{ rel: 'alternate', type: 'application/rss+xml', href: '/feed.xml', title: 'RSS' },
]}
/>
```
### Automatic behaviors
- **Canonical** derived from Astro's `site` config, query params stripped by default (UTMs don't create duplicates)
- **Robots** always includes `max-snippet:-1`, `max-image-preview:large`, `max-video-preview:-1`
- **Canonical omitted when `noindex: true`** (per Google's recommendation)
- **Duplicate Twitter tags suppressed** — Twitter falls back to OG automatically
- **hreflang alternates** with BCP 47 normalization and automatic `x-default`
- **`og:locale:alternate`** emitted automatically from the `alternates` prop
---
## 3. Connected JSON-LD Graph (`@graph`)
A standalone `BlogPosting` isn't enough. The goal is an interlinked graph via `@id`:
```typescript
// src/utils/schema.ts
import {
buildWebSite, buildBlog, buildPerson,
buildWebPage, buildArticle, buildBreadcrumbList,
makeIds,
} from '@jdevalk/seo-graph-core';
const SITE_URL = 'https://example.com';
const ids = makeIds({ siteUrl: SITE_URL });
export function buildBlogPostGraph(post: { title: string; url: string; publishDate: Date; description: string }) {
return {
'@context': 'https://schema.org',
'@graph': [
buildWebSite({
url: SITE_URL,
name: 'My Site',
publisher: { '@id': ids.person },
potentialAction: {
'@type': 'SearchAction',
target: { '@type': 'EntryPoint', urlTemplate: `${SITE_URL}/search?q={search_term_string}` },
'query-input': 'required name=search_term_string',
},
}, ids),
buildBlog({ url: `${SITE_URL}/blog/`, name: 'Blog', publisher: { '@id': ids.person } }, ids),
buildPerson({
url: SITE_URL,
name: 'Your Name',
knowsAbout: ['Astro', 'SEO', 'Web Development'],
sameAs: ['https://github.com/your-user', 'https://linkedin.com/in/your-user'],
}, ids),
buildWebPage({
url: post.url,
name: post.title,
isPartOf: { '@id': ids.website },
breadcrumb: { '@id': ids.breadcrumb(post.url) },
datePublished: post.publishDate,
}, ids),
buildArticle({
url: post.url,
isPartOf: { '@id': ids.webPage(post.url) },
author: { '@id': ids.person },
publisher: { '@id': ids.person },
headline: post.title,
description: post.description,
datePublished: post.publishDate,
}, ids, 'BlogPosting'),
],
};
}
```
### Trust Signals in the Schema
Include these to strengthen authority:
| Property | Where | Purpose |
|---|---|---|
| `publishingPrinciples` | `WebSite` / `Person` | Editorial policy |
| `copyrightHolder` + `copyrightYear` | `WebPage` | Copyright ownership |
| `knowsAbout` | `Person` | Topical authority |
| `SearchAction` | `WebSite` | Tells agents how to search the site |
| `sameAs` | `Person` / `Organization` | Social profiles = identity verification |
### `articleBody` in Schema
Include full text (up to 10K chars) so agents can access content via structured data without scraping:
```typescript
buildArticle({
// ...
articleBody: post.bodyText.slice(0, 10000),
}, ids, 'BlogPosting'),
```
---
## 4. Breadcrumbs Linked to the Graph
```typescript
import { breadcrumbsFromUrl } from '@jdevalk/astro-seo-graph';
import { buildBreadcrumbList, makeIds } from '@jdevalk/seo-graph-core';
const ids = makeIds({ siteUrl: 'https://example.com' });
const items = breadcrumbsFromUrl({
url: Astro.url,
siteUrl: 'https://example.com',
pageName: post.data.title,
names: { blog: 'Blog', category: 'Category' },
});
const breadcrumb = buildBreadcrumbList({ url: Astro.url.href, items }, ids);
```
Each breadcrumb item can reference a graph entity via `@id`, communicating the structural relationship between page and section.
---
## 5. Content Schema Validation (Zod)
```typescript
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { seoSchema, imageSchema } from '@jdevalk/astro-seo-graph';
const blog = defineCollection({
schema: ({ image }) => z.object({
title: z.string(),
publishDate: z.coerce.date(),
featureImage: imageSchema(image).optional(),
seo: seoSchema(image).optional(),
}),
});
```
- `seoSchema` validates title (5120 chars) and description (15160 chars) — build fails if outside limits
- `imageSchema` requires `alt` — image without alt won't compile
---
## 6. Build-Time Validation
```typescript
// astro.config.mjs
import seoGraph from '@jdevalk/astro-seo-graph/integration';
export default defineConfig({
integrations: [
seoGraph({
// All enabled by default:
validateH1: true, // 0 or >1 H1 = warning
validateUniqueMetadata: true, // Duplicate title/desc across pages
validateImageAlt: true, // <img> without alt
validateMetadataLength: { // SERP-safe bounds
title: { min: 30, max: 65 },
description: { min: 70, max: 200 },
},
validateInternalLinks: { // Broken internal links or missing trailing slash
skip: (href) => href.startsWith('/api/'),
},
}),
],
});
```
### What each validation catches:
- **H1**: Templates with duplicate or missing H1
- **Duplicates**: Paginated pages sharing the same title (corpus-level bug)
- **Alt text**: Images missed over the years
- **Meta length**: Titles truncated in SERP or invisible descriptions
- **Internal links**: `/about-me` without trailing slash that works via 301 but wastes a round-trip
### CI: External Broken Link Checker
For external links (internal validation doesn't cover), use [lychee](https://github.com/lycheeverse/lychee-action) in GitHub Actions:
```yaml
# .github/workflows/links.yml
name: Check Links
on:
push:
paths: ['src/content/**']
schedule:
- cron: '0 6 * * 1' # Weekly for link rot
jobs:
links:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: lycheeverse/lychee-action@v2
with:
args: --verbose --no-progress 'src/content/**/*.md'
```
---
## 7. Advanced Sitemaps
### Per-Collection with chunks
```typescript
import sitemap from '@astrojs/sitemap';
sitemap({
entryLimit: 1000,
chunks: {
posts: (item) => {
if (/^\/blog\/[^/]+/.test(new URL(item.url).pathname)) return item;
},
pages: (item) => item, // default bucket
},
});
```
Produces: `sitemap-posts-0.xml`, `sitemap-pages-0.xml` — makes debugging easier in Google Search Console.
### Git-based lastmod
```typescript
import { gitLastmod } from '@jdevalk/astro-seo-graph';
// In the sitemap serialize callback:
serialize(item) {
const filePath = urlToFilePath(item.url); // your logic
const lastmod = gitLastmod(filePath, {
excludeCommits: ['abc1234'], // bulk imports that don't count
});
return { ...item, lastmod: lastmod ?? item.lastmod };
}
```
`gitLastmod` uses `git log` for the real timestamp of the last commit that touched the file — doesn't depend on filesystem `mtime` (which resets on CI).
---
## 8. IndexNow — Active Notification
IndexNow notifies Bing, Yandex, and others that URLs changed, instead of waiting for passive crawl.
### Configuration
```typescript
// astro.config.mjs
seoGraph({
indexNow: {
key: process.env.INDEXNOW_KEY!,
host: 'example.com',
siteUrl: 'https://example.com',
filter: (url) => !/^\/blog\/\d+\/$/.test(new URL(url).pathname), // Exclude pagination
},
});
```
### Key Route (ownership verification)
```typescript
// src/pages/[your-key-here].txt.ts
import { createIndexNowKeyRoute } from '@jdevalk/astro-seo-graph';
export const GET = createIndexNowKeyRoute({ key: 'your-key-here' });
```
### Deploy order matters
1. Deploy the key route first
2. Confirm `https://example.com/your-key.txt` returns 200
3. Only then enable `indexNow` in the integration
> Submissions before the key is reachable = HTTP 403 and key permanently invalidated.
### Direct IndexNow API
For manual or custom submission:
```bash
# Single URL
curl "https://api.indexnow.org/indexnow?url=https://example.com/new-post/&key=YOUR_KEY"
# Batch (up to 10,000 URLs per POST)
curl -X POST https://api.indexnow.org/indexnow \
-H "Content-Type: application/json" \
-d '{
"host": "example.com",
"key": "YOUR_KEY",
"urlList": [
"https://example.com/post-1/",
"https://example.com/post-2/"
]
}'
```
---
## 9. Auto-Generated OG Images
Pipeline: **satori** (JSX → SVG) → **sharp** (SVG → JPEG)
```typescript
// src/pages/og/[...slug].jpg.ts
import satori from 'satori';
import sharp from 'sharp';
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((p) => ({ params: { slug: p.id } }));
}
export async function GET({ params }) {
const posts = await getCollection('blog');
const post = posts.find((p) => p.id === params.slug);
if (!post) return new Response('Not found', { status: 404 });
const fontData = await fetch('https://cdn.example.com/fonts/Inter-Bold.ttf')
.then((r) => r.arrayBuffer());
const svg = await satori(
{
type: 'div',
props: {
style: {
width: '100%', height: '100%',
display: 'flex', flexDirection: 'column',
justifyContent: 'center', padding: '60px',
background: 'linear-gradient(135deg, #1a1a2e, #16213e)',
color: '#ffffff', fontFamily: 'Inter',
},
children: [
{ type: 'div', props: { style: { fontSize: '48px', fontWeight: 700, lineHeight: 1.2 }, children: post.data.title } },
{ type: 'div', props: { style: { fontSize: '24px', marginTop: '20px', opacity: 0.8 }, children: 'example.com' } },
],
},
},
{ width: 1200, height: 675, fonts: [{ name: 'Inter', data: fontData, weight: 700 }] },
);
const jpeg = await sharp(Buffer.from(svg)).jpeg({ quality: 80 }).toBuffer();
return new Response(jpeg, {
headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'public, max-age=31536000, immutable' },
});
}
```
**Why JPEG and not WebP/AVIF?** Social platforms don't reliably support modern formats yet.
**Size: 1200×675** — Google Discover requires ≥1200px width, and 16:9 works well cross-platform.
The `<Seo>` component derives the OG image URL from the slug automatically:
```typescript
const slug = Astro.url.pathname.replace(/^\/|\/$/g, '');
const ogImage = new URL(`/og/${slug || 'index'}.jpg`, SITE_URL).toString();
```
---
## 10. Agent Discovery
### Schema Endpoints (corpus-wide JSON-LD)
```typescript
// src/pages/schema/post.json.ts
import { getCollection } from 'astro:content';
import { createSchemaEndpoint } from '@jdevalk/astro-seo-graph';
import { buildArticle, buildWebPage, makeIds } from '@jdevalk/seo-graph-core';
const ids = makeIds({ siteUrl: 'https://example.com' });
export const GET = createSchemaEndpoint({
entries: () => getCollection('blog'),
mapper: (post) => {
const url = `https://example.com/${post.id}/`;
return [
buildWebPage({ url, name: post.data.title, isPartOf: { '@id': ids.website }, datePublished: post.data.publishDate }, ids),
buildArticle({ url, isPartOf: { '@id': ids.webPage(url) }, author: { '@id': ids.person }, headline: post.data.title, description: post.data.description ?? '', datePublished: post.data.publishDate }, ids, 'BlogPosting'),
];
},
});
```
### Schema Map (`/schemamap.xml`)
```typescript
// src/pages/schemamap.xml.ts
import { createSchemaMap } from '@jdevalk/astro-seo-graph';
export const GET = createSchemaMap({
siteUrl: 'https://example.com',
entries: [
{ path: '/schema/post.json', lastModified: new Date() },
{ path: '/schema/page.json', lastModified: new Date() },
],
});
```
### API Catalog (RFC 9727)
```typescript
// src/pages/.well-known/api-catalog.ts
import { createApiCatalog } from '@jdevalk/astro-seo-graph';
export const GET = createApiCatalog({
siteUrl: 'https://example.com',
schemaEndpoints: [
{ path: '/schema/post.json', schemaType: 'BlogPosting', serviceDoc: '/about/' },
],
schemaMap: { path: '/schemamap.xml' },
});
```
### Markdown Alternates
Serve a `.md` version of every page so agents can consume content without HTML parsing:
```typescript
// src/pages/blog/[...slug].md.ts
import { getCollection } from 'astro:content';
import { createMarkdownEndpoint } from '@jdevalk/astro-seo-graph';
export const getStaticPaths = async () => {
const posts = await getCollection('blog');
return posts.map((p) => ({ params: { slug: p.id } }));
};
export const GET = createMarkdownEndpoint({
entries: () => getCollection('blog'),
mapper: (post, slug) =>
post.id !== slug ? null : {
frontmatter: { title: post.data.title, canonical: `https://example.com/blog/${post.id}/`, pubDate: post.data.publishDate },
body: post.body ?? '',
},
});
```
Enable the discovery link:
```typescript
// astro.config.mjs
seoGraph({ markdownAlternate: true });
```
Emits `<link rel="alternate" type="text/markdown" href="…">` on every page.
### Content Negotiation via Cloudflare (no SSR)
Transform Rule in the dashboard (works on free plan):
```
When: http.request.headers["accept"][0] contains "text/markdown"
AND ends_with(http.request.uri.path, "/")
AND NOT starts_with(http.request.uri.path, "/_")
Rewrite URI path (dynamic): wildcard_replace(http.request.uri.path, "*/", "${1}.md")
```
Turns `/blog/post/``/blog/post.md` before cache lookup. No need for `Vary: Accept` header — Cloudflare strips custom Vary values.
### llms.txt
```typescript
seoGraph({
llmsTxt: {
title: 'My Site',
siteUrl: 'https://example.com',
summary: 'A blog about web development, Astro, and SEO.',
},
});
```
Generates `/llms.txt` automatically at build time listing all pages.
### NLWeb Discovery
`<link>` tag for conversational endpoint (Microsoft protocol):
```html
<link rel="nlweb" href="https://example.com/api/nlweb" />
```
NLWeb allows AI agents to make conversational queries against site content via schema.org structured data. Still early days but the setup is trivial.
---
## 11. Performance SEO
### No-Vary-Search
UTM params break caching: `?utm_source=linkedin` and `?utm_source=email` are different resources to the browser. Header that fixes it:
```
No-Vary-Search: key-order, params=("utm_source" "utm_medium" "utm_campaign" "utm_content" "utm_term")
```
**Status:** IETF draft (`draft-ietf-httpbis-no-vary-search`), supported in Chrome, degrades gracefully elsewhere.
Configure in `_headers` (Cloudflare Pages / Netlify):
```
/*
No-Vary-Search: key-order, params=("utm_source" "utm_medium" "utm_campaign" "utm_content" "utm_term")
```
### CDN Cache Headers
```
# _headers (Cloudflare Pages)
/_astro/*
Cache-Control: public, max-age=31536000, immutable
/og/*
Cache-Control: public, max-age=31536000, immutable
```
Hashed assets under `/_astro/` never need revalidation — the filename changes when content changes.
### View Transitions Prefetch
```astro
---
// src/layouts/Base.astro
import { ClientRouter } from 'astro:transitions';
---
<head>
<ClientRouter defaultStrategy="viewport" />
</head>
```
`defaultStrategy: 'viewport'` prefetches links as they scroll into view, making navigation feel instant while keeping initial load minimal.
### Font Preloading
```html
<link rel="preload" href="/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin />
```
---
## 12. Redirects
### Per platform
| Platform | File | Format |
|---|---|---|
| Cloudflare Pages | `public/_redirects` | `/old /new 301` |
| Netlify | `public/_redirects` or `netlify.toml` | Same format |
| Vercel | `vercel.json` | `{ "source": "/old", "destination": "/new", "permanent": true }` |
### FuzzyRedirect on 404
Safety net for URLs that slip through redirect tables:
```astro
---
// src/pages/404.astro
import FuzzyRedirect from '@jdevalk/astro-seo-graph/FuzzyRedirect.astro';
---
<html lang="en">
<head><title>Page not found</title></head>
<body>
<h1>Page not found</h1>
<p>The page you're looking for doesn't exist.</p>
<FuzzyRedirect />
<p><a href="/">Go to the homepage</a></p>
</body>
</html>
```
Behavior:
- Fetches `/sitemap-index.xml`, computes Levenshtein similarity
- **0.60.85 similarity**: shows "Did you mean /correct-path/?"
- **>0.85**: auto-redirects with `window.location.replace`
- **<0.6**: does nothing
---
## 13. RSS with Full Content
```typescript
// src/pages/rss.xml.ts
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
export async function GET(context) {
const posts = await getCollection('blog');
return rss({
title: 'My Blog',
description: 'Latest posts',
site: context.site,
items: posts.map((post) => ({
title: post.data.title,
pubDate: post.data.publishDate,
description: post.data.description,
link: `/blog/${post.id}/`,
content: post.body, // Full content, not excerpts
})),
});
}
```
**Include full content in the feed** — truncated feeds frustrate readers and give AI systems less to work with.
---
## 14. Dynamic robots.txt
```typescript
// src/pages/robots.txt.ts
export function GET() {
return new Response(
`User-agent: *
Allow: /
Sitemap: https://example.com/sitemap-index.xml
Schemamap: https://example.com/schemamap.xml
`,
{ headers: { 'Content-Type': 'text/plain' } },
);
}
```
The `Schemamap:` directive points agents to the schema map — similar to `Sitemap:` but for structured data.
---
## 15. Implementation Checklist
- [ ] `@jdevalk/astro-seo-graph` installed and `<Seo>` in all layouts
- [ ] JSON-LD `@graph` with full entities (WebSite, Person, WebPage, Article, BreadcrumbList)
- [ ] Trust signals: `publishingPrinciples`, `knowsAbout`, `SearchAction`
- [ ] `seoSchema` in content collection with title/desc validation
- [ ] `seoGraph()` integration with all validations enabled
- [ ] Per-collection sitemaps with `gitLastmod`
- [ ] IndexNow configured and key route deployed
- [ ] Auto-generated OG images (1200×675 JPEG)
- [ ] Schema endpoints + `/schemamap.xml`
- [ ] Markdown alternates with `<link rel="alternate" type="text/markdown">`
- [ ] `llms.txt` generated automatically
- [ ] `<link rel="nlweb">` (when endpoint available)
- [ ] `No-Vary-Search` header for UTM params
- [ ] CDN cache: immutable for `/_astro/*`
- [ ] View Transitions with viewport prefetch
- [ ] FuzzyRedirect on 404
- [ ] RSS with full content
- [ ] `robots.txt` with Sitemap + Schemamap
- [ ] Lychee in CI for broken external links
- [ ] `/.well-known/api-catalog` (RFC 9727)
---
## References
- [astro-seo-graph README](https://github.com/jdevalk/seo-graph/tree/main/packages/astro-seo-graph)
- [astro-seo-graph AGENTS.md](https://github.com/jdevalk/seo-graph/blob/main/AGENTS.md) — 3000+ lines with recipes for 14 site types
- [seo-graph-core](https://github.com/jdevalk/seo-graph/tree/main/packages/seo-graph-core)
- [IndexNow documentation](https://www.indexnow.org/documentation)
- [NLWeb protocol](https://github.com/nlweb-ai/NLWeb)
- [satori](https://github.com/vercel/satori) — JSX → SVG
- [sharp](https://sharp.pixelplumbing.com/) — SVG → JPEG/PNG
- [No-Vary-Search (MDN)](https://developer.mozilla.org/docs/Web/HTTP/Reference/Headers/No-Vary-Search)
- [RFC 9727 — API Catalog](https://www.rfc-editor.org/rfc/rfc9727)
- [llms.txt standard](https://llmstxt.org)
- [Joost: Astro SEO definitive guide](https://joost.blog/astro-seo-complete-guide/)
- [Joost: Agent-ready static blog](https://joost.blog/agent-ready/)

View File

@@ -0,0 +1,604 @@
# Starlight & Common Patterns
## 1. Starlight Documentation Sites
### Setup
```bash
npm create astro@latest -- --template starlight
```
Or add to an existing Astro project:
```bash
npx astro add starlight
```
### Configuration
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
export default defineConfig({
site: 'https://docs.example.com',
integrations: [
starlight({
title: 'My Docs',
defaultLocale: 'en',
locales: {
en: { label: 'English' },
pt: { label: 'Português', lang: 'pt-BR' },
},
sidebar: [
{ label: 'Home', link: '/' },
{
label: 'Guides',
items: [
{ slug: 'guides/getting-started' },
{ slug: 'guides/configuration' },
],
},
{
label: 'Reference',
autogenerate: { directory: 'reference' },
},
],
customCss: ['./src/styles/custom.css'],
}),
],
});
```
### Sidebar Gotchas
**`link` and `items` are mutually exclusive.** A sidebar item is ONE of:
- `link` — a single URL (requires `label`)
- `slug` — reference to internal page (uses page title as label)
- `items` — array of child links/groups (requires `label`)
- `autogenerate` — auto-generates from a directory
```ts
// ❌ WRONG — cannot mix link with items
{ label: 'Guides', link: '/guides/', items: [...] }
// ✅ CORRECT — group with items
{ label: 'Guides', items: [{ slug: 'guides/intro' }] }
// ✅ CORRECT — single link
{ label: 'Guides', link: '/guides/' }
```
**Autogenerate limitations:**
- Only generates from files in `src/content/docs/<directory>/`
- Sorted alphabetically by filename (use numeric prefixes like `01-intro.md` to control order)
- Cannot filter files — all `.md`/`.mdx` in the directory are included
- Subfolders become nested groups automatically
### Built-in Components: Card vs LinkCard
| Component | Purpose | Required Props | Has `href`? | Accepts children? |
|-----------|---------|---------------|-------------|-------------------|
| `Card` | Display content in a styled box | `title` | ❌ NO | ✅ Yes |
| `LinkCard` | Prominent clickable link | `title`, `href` | ✅ YES | ❌ No |
```mdx
import { Card, LinkCard, CardGrid } from '@astrojs/starlight/components';
{/* Card — displays content, NOT a link */}
<Card title="Feature A" icon="star">
Description of feature A goes here.
</Card>
{/* LinkCard — entire card is a clickable link */}
<LinkCard
title="Getting Started"
href="/guides/getting-started/"
description="Learn how to set up your project."
/>
{/* Group in a grid */}
<CardGrid stagger>
<Card title="Fast" icon="rocket">Built for speed.</Card>
<Card title="Simple" icon="pencil">Easy to use.</Card>
</CardGrid>
```
### Component Overrides
Override any built-in Starlight UI component:
```js
// astro.config.mjs
starlight({
components: {
// Replace the SocialIcons component
SocialIcons: './src/components/MyLinks.astro',
// Replace the Header
Header: './src/components/CustomHeader.astro',
},
});
```
Reuse the built-in component inside your override:
```astro
---
// src/components/CustomHeader.astro
import Default from '@astrojs/starlight/components/Header.astro';
---
<Default><slot /></Default>
<div class="announcement-bar">New release available!</div>
```
Full list of overridable components: see [Overrides Reference](https://starlight.astro.build/reference/overrides/).
### Theming
Starlight uses a semantic color system via CSS custom properties. The naming is **counter-intuitive**:
| Variable | Meaning |
|----------|---------|
| `--sl-color-white` | **Foreground** (text) color |
| `--sl-color-black` | **Background** color |
| `--sl-color-gray-1` to `--sl-color-gray-6` | Gray scale (1 = lightest in dark mode) |
| `--sl-color-accent-low` | Accent background |
| `--sl-color-accent` | Accent mid (links, highlights) |
| `--sl-color-accent-high` | Accent foreground |
**You MUST define both `:root` (dark) and `:root[data-theme='light']` (light):**
```css
/* src/styles/custom.css */
/* Dark mode (default) */
:root {
--sl-color-white: #ffffff;
--sl-color-black: #181818;
--sl-color-gray-1: #eee;
--sl-color-gray-2: #c2c2c2;
--sl-color-gray-3: #8b8b8b;
--sl-color-gray-4: #585858;
--sl-color-gray-5: #383838;
--sl-color-gray-6: #272727;
--sl-color-accent-low: #1a1047;
--sl-color-accent: #8b5cf6;
--sl-color-accent-high: #c4b5fd;
}
/* Light mode — invert the logic */
:root[data-theme='light'] {
--sl-color-white: #181818;
--sl-color-black: #ffffff;
--sl-color-gray-1: #272727;
--sl-color-gray-2: #383838;
--sl-color-gray-3: #585858;
--sl-color-gray-4: #8b8b8b;
--sl-color-gray-5: #c2c2c2;
--sl-color-gray-6: #eee;
--sl-color-accent-low: #c4b5fd;
--sl-color-accent: #6d28d9;
--sl-color-accent-high: #1a1047;
}
```
**CSS Layer:** Starlight uses `@layer starlight` internally. Unlayered custom CSS automatically overrides it. For explicit layer control:
```css
@layer my-reset, starlight, my-overrides;
@layer my-overrides {
:root {
--sl-content-width: 50rem;
}
}
```
### Versioned Docs with starlight-utils multiSidebar
```bash
npm install @lorenzo_lewis/starlight-utils
```
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import starlightUtils from '@lorenzo_lewis/starlight-utils';
export default defineConfig({
integrations: [
starlight({
title: 'My Docs',
plugins: [
starlightUtils({
multiSidebar: {
switcherStyle: 'dropdown',
},
}),
],
sidebar: [
// Each top-level group becomes a separate sidebar
{
label: 'v2',
items: [{ autogenerate: { directory: 'v2' } }],
},
{
label: 'v1',
items: [{ autogenerate: { directory: 'v1' } }],
},
],
}),
],
});
```
---
## 2. Search (Pagefind)
### Install and Build
Pagefind indexes static HTML after build. Starlight includes Pagefind by default. For non-Starlight Astro sites:
```bash
npm install -D pagefind
```
Add to your build script in `package.json`:
```json
{
"scripts": {
"build": "astro build && npx pagefind --site dist"
}
}
```
### Indexing Controls
```html
<!-- Only index content inside this element -->
<main data-pagefind-body>
<h1>Indexed heading</h1>
<p>This paragraph is searchable.</p>
<!-- Exclude specific elements -->
<nav data-pagefind-ignore>
<p>This won't appear in search results.</p>
</nav>
<!-- Boost heading weight in results -->
<h2 data-pagefind-weight="2">Important Section</h2>
</main>
```
| Attribute | Effect |
|-----------|--------|
| `data-pagefind-body` | Only index inside this element (page-level) |
| `data-pagefind-ignore` | Exclude element from indexing |
| `data-pagefind-ignore="all"` | Exclude element and all descendants |
| `data-pagefind-weight="N"` | Boost ranking (default: 1, higher = more relevant) |
| `data-pagefind-meta="key:value"` | Add metadata to search results |
### UI Component Integration
```astro
---
// src/pages/search.astro
---
<html>
<head>
<link href="/pagefind/pagefind-ui.css" rel="stylesheet" />
</head>
<body>
<div id="search"></div>
<script>
import '/pagefind/pagefind-ui.js';
new PagefindUI({ element: '#search', showSubResults: true });
</script>
</body>
</html>
```
### Pagefind vs Fuse.js Decision Table
| Criteria | Pagefind | Fuse.js |
|----------|----------|---------|
| Index size | Pre-built, loads fragments on demand | Entire index in memory |
| Best for | Static sites with 50+ pages | Small datasets (<100 items), dynamic data |
| Setup | Build step required | No build step, works at runtime |
| Fuzzy matching | Limited (typo tolerance) | Excellent (configurable threshold) |
| Performance | O(1) per query chunk (WASM) | Degrades with data size |
| Works offline | ✅ Yes | ✅ Yes |
| SSR compatible | ❌ No (needs static HTML) | ✅ Yes |
| Custom data | Indexes HTML only | Indexes any JSON array |
| Bundle size | ~50KB (WASM) + on-demand chunks | ~25KB + full index |
**Rule of thumb:** Use Pagefind for documentation/blog search. Use Fuse.js for in-page filtering (command palettes, dropdown search, dynamic lists).
---
## 3. SEO
> **Full reference:** see [SEO Full Stack](seo-full-stack.md) — covers `@jdevalk/astro-seo-graph`, JSON-LD graph, IndexNow, auto-generated OG images, agent discovery, performance SEO, and build-time validation.
Below is just the minimal setup for Starlight (which already includes automatic sitemap):
### Starlight SEO Basics
Starlight generates a sitemap automatically — just set `site` in your config. For RSS and custom meta tags in non-Starlight sites, see the full reference file.
```js
// astro.config.mjs — minimum for Starlight SEO
export default defineConfig({
site: 'https://docs.example.com', // Required for sitemap and canonical
integrations: [starlight({ title: 'My Docs' })],
});
```
---
## 4. i18n Patterns
### Configuration
```js
// astro.config.mjs
export default defineConfig({
i18n: {
defaultLocale: 'en',
locales: ['en', 'pt-br', 'es'],
routing: {
prefixDefaultLocale: false, // /about (en), /pt-br/about, /es/about
},
fallback: {
'pt-br': 'en',
es: 'en',
},
},
});
```
For Starlight, i18n is configured inside the integration:
```js
starlight({
defaultLocale: 'root',
locales: {
root: { label: 'English', lang: 'en' },
'pt-br': { label: 'Português', lang: 'pt-BR' },
},
});
```
### Content Collections per Locale
```
src/content/docs/
├── index.md ← English (root locale)
├── guides/
│ └── intro.md
└── pt-br/
├── index.md ← Portuguese
└── guides/
└── intro.md
```
### Fallback Strategy
Show default locale content with a banner when translation is missing:
```astro
---
// src/components/TranslationBanner.astro
import { getEntry } from 'astro:content';
const currentLocale = Astro.currentLocale ?? 'en';
const slug = Astro.params.slug;
// Check if translation exists
const localizedEntry = await getEntry('docs', `${currentLocale}/${slug}`);
const isFallback = !localizedEntry && currentLocale !== 'en';
---
{isFallback && (
<aside class="translation-banner" role="alert">
⚠️ This page is not yet translated to {currentLocale}.
Showing English version.
</aside>
)}
```
In Starlight, fallback is automatic — missing translations show the `defaultLocale` content with a built-in notice.
### getRelativeLocaleUrl Helper
```astro
---
import { getRelativeLocaleUrl } from 'astro:i18n';
const locale = Astro.currentLocale ?? 'en';
---
<nav>
<a href={getRelativeLocaleUrl(locale, 'about')}>About</a>
<a href={getRelativeLocaleUrl(locale, 'guides/intro')}>Guide</a>
</nav>
```
---
## 5. Common Recipes
### Pagination
```astro
---
// src/pages/blog/[...page].astro
import { getCollection } from 'astro:content';
import type { GetStaticPaths } from 'astro';
const POSTS_PER_PAGE = 10;
export const getStaticPaths: GetStaticPaths = async ({ paginate }) => {
const allPosts = await getCollection('blog');
const sorted = allPosts.sort(
(a, b) => b.data.publishDate.valueOf() - a.data.publishDate.valueOf()
);
return paginate(sorted, { pageSize: POSTS_PER_PAGE });
};
const { page } = Astro.props;
---
<h1>Blog — Page {page.currentPage}</h1>
<ul>
{page.data.map((post) => (
<li>
<a href={`/blog/${post.id}/`}>{post.data.title}</a>
</li>
))}
</ul>
<nav>
{page.url.prev && <a href={page.url.prev}>← Previous</a>}
<span>Page {page.currentPage} of {page.lastPage}</span>
{page.url.next && <a href={page.url.next}>Next →</a>}
</nav>
```
### Tag/Category Archives
```astro
---
// src/pages/tags/[tag]/[...page].astro
import { getCollection } from 'astro:content';
export async function getStaticPaths({ paginate }) {
const allPosts = await getCollection('blog');
const allTags = [...new Set(allPosts.flatMap((post) => post.data.tags))];
return allTags.flatMap((tag) => {
const filtered = allPosts.filter((post) => post.data.tags.includes(tag));
return paginate(filtered, {
params: { tag },
pageSize: 10,
});
});
}
const { page } = Astro.props;
const { tag } = Astro.params;
---
<h1>Posts tagged "{tag}"</h1>
<ul>
{page.data.map((post) => (
<li><a href={`/blog/${post.id}/`}>{post.data.title}</a></li>
))}
</ul>
```
Tag index page:
```astro
---
// src/pages/tags/index.astro
import { getCollection } from 'astro:content';
const allPosts = await getCollection('blog');
const tags = [...new Set(allPosts.flatMap((post) => post.data.tags))].sort();
---
<h1>All Tags</h1>
<ul>
{tags.map((tag) => (
<li><a href={`/tags/${tag}/1/`}>{tag}</a></li>
))}
</ul>
```
### Static Forms
**Formspree:**
```astro
<form action="https://formspree.io/f/{form_id}" method="POST">
<label>
Email
<input type="email" name="email" required />
</label>
<label>
Message
<textarea name="message" required></textarea>
</label>
<button type="submit">Send</button>
</form>
```
**Netlify Forms:**
```astro
<form name="contact" method="POST" data-netlify="true" netlify-honeypot="bot-field">
<input type="hidden" name="form-name" value="contact" />
<p class="hidden"><input name="bot-field" /></p>
<label>
Email
<input type="email" name="email" required />
</label>
<label>
Message
<textarea name="message" required></textarea>
</label>
<button type="submit">Send</button>
</form>
```
### Dark Mode Toggle
```astro
---
// src/components/ThemeToggle.astro
---
<button id="theme-toggle" aria-label="Toggle dark mode" type="button">
<span class="sun">☀️</span>
<span class="moon">🌙</span>
</button>
<script>
const toggle = document.getElementById('theme-toggle')!;
function getTheme(): 'light' | 'dark' {
return (
(localStorage.getItem('theme') as 'light' | 'dark') ??
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
);
}
function setTheme(theme: 'light' | 'dark') {
document.documentElement.dataset.theme = theme;
localStorage.setItem('theme', theme);
}
// Apply on load
setTheme(getTheme());
toggle.addEventListener('click', () => {
setTheme(getTheme() === 'dark' ? 'light' : 'dark');
});
</script>
<style>
#theme-toggle {
background: none;
border: none;
cursor: pointer;
font-size: 1.25rem;
}
:root[data-theme='dark'] .sun { display: none; }
:root[data-theme='light'] .moon { display: none; }
</style>
```
> **Note:** Starlight includes a built-in theme toggle. This pattern is for custom Astro sites.

View File

@@ -0,0 +1,387 @@
# Testing Astro Projects
Complete guide for testing Astro projects — from unit/component tests to E2E, link checking, type safety, and CI pipelines.
---
## 1. Component Testing with Vitest
### Setup
```bash
npm install -D vitest @vitest/ui
```
### vitest.config.ts
```ts
/// <reference types="vitest" />
import { getViteConfig } from 'astro/config';
export default getViteConfig({
test: {
include: ['tests/**/*.{test,spec}.{js,ts}'],
},
});
```
### AstroContainer API
The `AstroContainer` API renders Astro components in isolation without a full dev server.
```ts
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { expect, test } from 'vitest';
import Greeting from '../src/components/Greeting.astro';
test('renders greeting with name prop', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Greeting, {
props: { name: 'World' },
});
expect(result).toContain('Hello, World');
});
```
### Testing Props
```ts
test('renders default when no name provided', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Greeting, {
props: {},
});
expect(result).toContain('Hello, stranger');
});
```
### Testing Slots
```ts
import Card from '../src/components/Card.astro';
test('renders slot content', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Card, {
slots: { default: '<p>Slot content here</p>' },
});
expect(result).toContain('Slot content here');
});
```
### Testing Conditional Rendering
```ts
import Alert from '../src/components/Alert.astro';
test('renders error variant', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Alert, {
props: { type: 'error', message: 'Something failed' },
});
expect(result).toContain('class="alert-error"');
expect(result).toContain('Something failed');
});
test('does not render when hidden', async () => {
const container = await AstroContainer.create();
const result = await container.renderToString(Alert, {
props: { type: 'info', message: 'Hidden', visible: false },
});
expect(result).not.toContain('Hidden');
});
```
### Run Tests
```bash
npx vitest
npx vitest --ui # browser UI
```
---
## 2. E2E Testing with Playwright
### Setup
```bash
npm install -D @playwright/test
npx playwright install
```
### playwright.config.ts
```ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
webServer: {
command: 'npm run preview',
port: 4321,
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://localhost:4321',
},
});
```
> **Note:** Run `astro build` before E2E tests so `preview` has something to serve.
### Example: Page Load
```ts
import { test, expect } from '@playwright/test';
test('homepage loads correctly', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/My Site/);
await expect(page.locator('h1')).toBeVisible();
});
```
### Example: Navigation
```ts
test('navigates to about page', async ({ page }) => {
await page.goto('/');
await page.click('a[href="/about"]');
await expect(page).toHaveURL('/about');
await expect(page.locator('h1')).toContainText('About');
});
```
### Example: Dynamic Routes
```ts
test('blog post renders from content collection', async ({ page }) => {
await page.goto('/blog/first-post');
await expect(page.locator('article h1')).toBeVisible();
await expect(page.locator('article')).not.toBeEmpty();
});
```
### Testing View Transitions
```ts
test('view transitions work between pages', async ({ page }) => {
await page.goto('/');
const transitionPromise = page.waitForEvent('load');
await page.click('a[href="/about"]');
await transitionPromise;
await expect(page).toHaveURL('/about');
});
```
### Run E2E Tests
```bash
npx astro build
npx playwright test
npx playwright test --ui # interactive mode
```
---
## 3. Link Checking
### linkinator
Checks all links in the built output for broken references.
```bash
npx astro build
npx linkinator dist --recurse
```
Options:
```bash
npx linkinator dist --recurse --skip "^https://external-site.com"
```
### CI Integration (GitHub Actions)
```yaml
- name: Check links
run: npx linkinator dist --recurse --retry --retry-errors
```
---
## 4. Type Checking
### Astro Template Validation
```bash
npx astro check
```
Validates `.astro` files for type errors in expressions, prop types, and component usage.
### TypeScript Checking
```bash
npx tsc --noEmit
```
Validates all `.ts` and `.tsx` files without emitting output.
### package.json Scripts
```json
{
"scripts": {
"check": "astro check && tsc --noEmit"
}
}
```
---
## 5. Content Collection Validation
### Schema Enforcement
Content collections validate against Zod schemas at build time. Invalid content **fails the build automatically**:
```ts
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
date: z.date(),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
```
A frontmatter error produces:
```
[ERROR] blog → "bad-post.md" frontmatter does not match schema.
"title" is required.
```
### Draft Filtering
Filter drafts in production queries:
```astro
---
import { getCollection } from 'astro:content';
const posts = await getCollection('blog', ({ data }) => {
return import.meta.env.PROD ? !data.draft : true;
});
---
```
Test that drafts are excluded by checking the built output does not contain draft post URLs.
---
## 6. Pre-Deploy Verification Script
Save as `scripts/verify.sh`:
```bash
#!/bin/bash
set -e
echo "→ Type checking..."
npx astro check
echo "→ Building..."
npx astro build
echo "→ Checking links..."
npx linkinator dist --recurse
echo "→ Running E2E tests..."
npx playwright test
echo "✓ All checks passed"
```
```bash
chmod +x scripts/verify.sh
./scripts/verify.sh
```
---
## 7. CI Pipeline (GitHub Actions)
Save as `.github/workflows/test.yml`:
```yaml
name: Test
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Type check
run: npx astro check && npx tsc --noEmit
- name: Build
run: npx astro build
- name: Component tests
run: npx vitest run
- name: Install Playwright
run: npx playwright install --with-deps chromium
- name: E2E tests
run: npx playwright test
- name: Link check
run: npx linkinator dist --recurse --retry
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
```
---
## Quick Reference
| Task | Command |
|------|---------|
| Component tests | `npx vitest` |
| E2E tests | `npx playwright test` |
| Type check | `npx astro check && tsc --noEmit` |
| Link check | `npx linkinator dist --recurse` |
| Full verification | `./scripts/verify.sh` |

View File

@@ -0,0 +1,271 @@
# Astro v6 Features (still current in v7)
These features were introduced or stabilized in Astro v6 and remain fully supported in v7.
---
## 1. Content Collections v2
Type-safe content management with Zod schemas and flexible data loaders.
```ts
// content.config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
date: z.date(),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
```
**Loaders:**
- `file()` — single file (JSON, YAML)
- `glob()` — match files by pattern
- Custom loaders — fetch from CMS at build or request time (live collections)
**Querying:**
```ts
import { getCollection, getEntry } from 'astro:content';
const posts = await getCollection('blog', ({ data }) => !data.draft);
const post = await getEntry('blog', 'my-post');
```
---
## 2. Server Actions
Type-safe RPC endpoints with Zod validation.
```ts
// src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';
export const server = {
subscribe: defineAction({
input: z.object({ email: z.string().email() }),
handler: async ({ email }) => {
// process subscription
return { success: true };
},
}),
};
```
**Usage in components:**
```ts
import { actions } from 'astro:actions';
const result = await actions.subscribe({ email: 'user@example.com' });
```
**Form integration with progressive enhancement:**
```astro
<form method="POST" action={actions.subscribe}>
<input type="email" name="email" />
<button type="submit">Subscribe</button>
</form>
```
---
## 3. Sessions
Server-side session management with pluggable drivers.
**Config:**
```js
// astro.config.mjs
export default defineConfig({
session: {
driver: 'cookie', // also: node-fs, redis, etc.
},
});
```
**Usage:**
```ts
// In pages/endpoints
const user = await Astro.session.get('user');
await Astro.session.set('user', { name: 'Alice' });
// In middleware
const user = await context.session.get('user');
```
---
## 4. Server Islands
Defer component rendering to request time while keeping the page static.
```astro
---
import UserGreeting from '../components/UserGreeting.astro';
---
<UserGreeting server:defer />
```
- Placeholder rendered at build time
- Component fetched and rendered at request time
- Perfect for personalized content in otherwise static pages
---
## 5. Environment Variables (astro:env)
Type-safe, validated environment variables.
```ts
import { MY_SECRET } from 'astro:env/server';
import { PUBLIC_API_URL } from 'astro:env/client';
```
**Schema definition:**
```js
// astro.config.mjs
export default defineConfig({
env: {
schema: {
MY_SECRET: envField.string({ context: 'server', access: 'secret' }),
PUBLIC_API_URL: envField.string({ context: 'client', access: 'public' }),
},
},
});
```
Variables are validated at build time — missing or invalid values cause build failures.
---
## 6. On-Demand Rendering
Hybrid static/SSR on a per-page basis.
```astro
---
// This page renders on every request
export const prerender = false;
---
```
**Adapters:**
- `@astrojs/node`
- `@astrojs/cloudflare`
- `@astrojs/netlify`
- `@astrojs/vercel`
**Hybrid mode:** static by default, opt individual pages into SSR with `prerender = false`.
---
## 7. View Transitions
Client-side navigation with animated transitions between pages.
```astro
---
import { ViewTransitions } from 'astro:transitions';
---
<head>
<ViewTransitions />
</head>
<h1 transition:name="title" transition:animate="slide">Hello</h1>
<div transition:persist>
<!-- State preserved across navigation -->
</div>
```
**Lifecycle events:**
- `astro:before-preparation`
- `astro:after-swap`
- `astro:page-load`
---
## 8. Middleware
Request/response pipeline with access to context.
```ts
// src/middleware.ts
import { defineMiddleware, sequence } from 'astro:middleware';
const auth = defineMiddleware(async (context, next) => {
const token = context.cookies.get('token');
context.locals.user = await validateToken(token?.value);
return next();
});
const logging = defineMiddleware(async (context, next) => {
console.log(context.url.pathname);
return next();
});
export const onRequest = sequence(auth, logging);
```
Access to `context.locals`, `context.cookies`, `context.redirect()`.
---
## 9. Image Optimization
Built-in image processing with automatic optimization.
```astro
---
import { Image } from 'astro:assets';
import hero from '../assets/hero.png';
---
<Image src={hero} alt="Hero" width={800} />
```
- Automatic format conversion, lazy loading, responsive sizes
- Remote images configured via `image.domains` and `image.remotePatterns`:
```js
// astro.config.mjs
export default defineConfig({
image: {
domains: ['cdn.example.com'],
remotePatterns: [{ protocol: 'https', hostname: '**.example.com' }],
},
});
```
---
## 10. Internationalization (i18n)
Built-in i18n routing with locale-aware URL generation.
```js
// astro.config.mjs
export default defineConfig({
i18n: {
defaultLocale: 'en',
locales: ['en', 'pt-br', 'es'],
routing: {
prefixDefaultLocale: false,
},
},
});
```
**URL generation:**
```ts
import { getRelativeLocaleUrl } from 'astro:i18n';
getRelativeLocaleUrl('pt-br', '/about'); // → /pt-br/about
```
**Strategies:** pathname prefixes or domain-based routing.

View File

@@ -0,0 +1,491 @@
# Astro v7 Features
Complete reference for all major features introduced in Astro v7.
---
## 1. Vite 8 + Rolldown
Astro v7 ships with **Vite 8**, which replaces the previous esbuild + Rollup bundling pipeline with **Rolldown** — a Rust-based bundler.
### Key Points
- **Rust-based bundler** replacing both esbuild (transform) and Rollup (bundling) in a single tool
- **10-30x faster** than Rollup for production builds
- **Same plugin API** — fully backwards compatible with existing Vite/Rollup plugins
- **Compatibility layer** auto-converts `build.rollupOptions` and esbuild-specific options to their Rolldown equivalents
### Migration
No changes required for most projects. If you use `vite.build.rollupOptions` in `astro.config.mjs`, the compatibility layer handles the conversion automatically. Warnings are emitted for any options that cannot be directly mapped.
```js
// astro.config.mjs — works as before
import { defineConfig } from 'astro/config';
export default defineConfig({
vite: {
build: {
// Automatically converted to Rolldown equivalents
rollupOptions: {
output: {
manualChunks: { vendor: ['react', 'react-dom'] }
}
}
}
}
});
```
---
## 2. Rust Compiler
The Astro template compiler has been rewritten in **Rust**, replacing the previous Go-based compiler. Built on **oxc** (JavaScript/TypeScript parser) and **Lightning CSS**.
### Key Points
- **Native binaries** for all major platforms with **WASM fallback** for unsupported architectures
- **Strict parsing**: unclosed tags are now errors (no HTML auto-correction)
- **JSX whitespace rules**: newlines between inline elements produce no whitespace in output
- **CSS differences**: minor cosmetic changes to color serialization and `url()` quoting (output-only, no behavioral change)
### Breaking Changes
#### Strict HTML Parsing
```astro
<!-- ❌ Error in v7 — unclosed tag -->
<div>
<p>Hello world
</div>
<!-- ✅ Correct -->
<div>
<p>Hello world</p>
</div>
```
#### JSX Whitespace Rules
```astro
<!-- In v7, newline between inline elements = no space in output -->
<span>Hello</span>
<span>World</span>
<!-- Renders: "HelloWorld" -->
<!-- Add explicit space -->
<span>Hello</span>{' '}
<span>World</span>
<!-- Renders: "Hello World" -->
```
#### CSS Cosmetic Differences
```css
/* v6 output */
background: url(image.png);
color: #ff0000;
/* v7 output (functionally identical) */
background: url("image.png");
color: red;
```
---
## 3. Sätteri (Markdown/MDX in Rust)
**Sätteri** is Astro v7's default Markdown and MDX processor, replacing the unified/remark/rehype pipeline with a Rust-native implementation.
### Key Points
- Built on **pulldown-cmark** (Markdown parsing) + **oxc** (MDX/JSX)
- Default processor — no configuration needed for standard usage
- Replaces unified/remark/rehype with dramatically faster processing
### Built-in Features
| Feature | Description |
|---------|-------------|
| GFM | Tables, strikethrough, task lists, autolinks |
| Smart punctuation | Curly quotes, em/en dashes |
| Heading IDs | Auto-generated anchor IDs |
| Directives | Container/leaf/text directives (`::: note`, etc.) |
| Math | LaTeX math blocks (`$$...$$`) and inline (`$...$`) |
| Frontmatter | YAML frontmatter parsing |
| Superscript/Subscript | `^super^` and `~sub~` syntax |
| Wikilinks | `[[page]]` and `[[page|text]]` syntax |
### Configuration
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import { satteri } from '@astrojs/markdown-satteri';
export default defineConfig({
markdown: {
processor: satteri({
gfm: true,
smartPunctuation: true,
headingIds: true,
math: true,
wikilinks: true,
directives: true,
})
}
});
```
### Plugin API
Sätteri plugins declare which node types they handle, skipping all others. This is significantly cheaper than unified's visitor pattern.
```js
// my-satteri-plugin.js
export default function myPlugin() {
return {
name: 'my-plugin',
nodes: ['heading', 'paragraph'], // only visit these types
transform(node, context) {
if (node.type === 'heading') {
// transform heading nodes
}
}
};
}
```
### Fallback to unified/remark/rehype
For projects relying on existing remark/rehype plugins:
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import { unified } from '@astrojs/markdown-remark';
import remarkToc from 'remark-toc';
import rehypePrism from 'rehype-prism';
export default defineConfig({
markdown: {
processor: unified({
remarkPlugins: [remarkToc],
rehypePlugins: [rehypePrism],
})
}
});
```
### Docker Deployment Note
Sätteri ships native bindings only for **glibc** (`@bruits/satteri-linux-x64-gnu`). Alpine Linux uses musl — there is no musl binding. Docker build stages MUST use `node:22-slim` (Debian/glibc), not `node:22-alpine`. This affects all Astro v7 projects using Sätteri (the default), including Starlight sites.
Projects using `unified()` explicitly are NOT affected (they bypass Sätteri entirely).
---
## 4. Queued Rendering
Astro v7's rendering engine uses a **queue/stack-based** approach instead of recursive rendering.
### Key Points
- **~2.4x faster** for expression-dense pages (many dynamic expressions, loops, conditionals)
- **Now stable and default** — no configuration needed
- Eliminates deep call-stack issues on complex component trees
- Reduces memory pressure through iterative processing
### Migration
No action required. This is an internal engine change that is fully transparent to user code.
---
## 5. Advanced Routing (`src/fetch.ts`)
Astro v7 introduces a **standard fetch handler pattern** for advanced routing control, following the same conventions as Cloudflare Workers, Deno, and Bun.
### Key Points
- Define a `src/fetch.ts` file to take full control of the request pipeline
- Compose individual pieces: `i18n()`, `actions()`, `middleware()`, `pages()`
- Full control over request pipeline order
- Compatible with Hono for complex routing scenarios
### Basic Routing
```ts
// src/fetch.ts
import { astro, FetchState } from 'astro/fetch';
export default astro((request: Request, state: FetchState) => {
// Compose the pipeline in your preferred order
return state.pipeline(
i18n(),
middleware(),
actions(),
pages()
);
});
```
### Hono Integration
```ts
// src/fetch.ts
import { astro } from 'astro/hono';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
const app = new Hono();
app.use('*', logger());
app.use('/api/*', cors());
app.get('/api/health', (c) => c.json({ status: 'ok' }));
// Hand off to Astro for everything else
export default astro(app);
```
### Composing Middleware
```ts
// src/fetch.ts
import { astro, FetchState } from 'astro/fetch';
import { i18n, actions, middleware, pages } from 'astro/fetch';
export default astro((request: Request, state: FetchState) => {
const url = new URL(request.url);
// Custom routing logic
if (url.pathname.startsWith('/api/')) {
return state.pipeline(
actions()
);
}
// Full pipeline for pages
return state.pipeline(
i18n(),
middleware(),
actions(),
pages()
);
});
```
---
## 6. Route Caching (Stable)
Route-level caching is now **stable** in Astro v7, providing fine-grained control over page caching with tag-based invalidation.
### Key Points
- In-memory cache available out of the box
- Per-page caching with `Astro.cache.set()`
- Declarative `routeRules` in config
- Tag-based invalidation
- Integration with live content collections
### Config-Level Setup
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import { memoryCache } from 'astro/config';
export default defineConfig({
cache: memoryCache(),
routeRules: {
'/blog/**': { cache: { maxAge: 3600, swr: 86400, tags: ['blog'] } },
'/products/**': { cache: { maxAge: 600, tags: ['products'] } },
'/about': { cache: { maxAge: 86400 } },
}
});
```
### Per-Page Caching
```astro
---
// src/pages/blog/[slug].astro
const { slug } = Astro.params;
const post = await getEntry('blog', slug);
Astro.cache.set({
maxAge: 3600, // 1 hour
swr: 86400, // stale-while-revalidate: 24 hours
tags: ['blog', `post:${slug}`]
});
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
```
### Webhook Invalidation
```ts
// src/pages/api/revalidate.ts
import type { APIRoute } from 'astro';
import { cache } from 'astro:cache';
export const POST: APIRoute = async ({ request }) => {
const { secret, tags, path } = await request.json();
if (secret !== import.meta.env.REVALIDATION_SECRET) {
return new Response('Unauthorized', { status: 401 });
}
// Invalidate by tags
if (tags) {
await cache.invalidate({ tags });
}
// Invalidate by path
if (path) {
await cache.invalidate({ path });
}
return new Response(JSON.stringify({ revalidated: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
};
```
### Live Content Collections Integration
```js
// astro.config.mjs
import { defineConfig } from 'astro/config';
import { memoryCache } from 'astro/config';
export default defineConfig({
cache: memoryCache(),
content: {
collections: {
blog: {
// When content changes, invalidate matching cache tags
onUpdate: (entry) => cache.invalidate({ tags: ['blog', `post:${entry.slug}`] })
}
}
}
});
```
---
## 7. CDN Cache Providers (Experimental)
CDN-level cache providers push cache directives to the edge, allowing cached responses to be served **without invoking the server**.
### Key Points
- Edge-level caching — hits never reach your application server
- Platform-specific providers for Netlify, Vercel, and Cloudflare
- Works with the same `routeRules` and `Astro.cache.set()` API
### Providers
```js
// Netlify
import { cacheNetlify } from '@astrojs/netlify/cache';
export default defineConfig({
cache: cacheNetlify(),
});
```
```js
// Vercel
import { cacheVercel } from '@astrojs/vercel/cache';
export default defineConfig({
cache: cacheVercel(),
});
```
```js
// Cloudflare (private beta)
import { cacheCloudflare } from '@astrojs/cloudflare/cache';
export default defineConfig({
cache: cacheCloudflare(),
});
```
### How It Works
1. On first request: server renders the page, cache provider stores response at the edge
2. On subsequent requests: CDN serves cached response directly (no server invocation)
3. On invalidation: cache is purged via provider API, next request triggers fresh render
---
## 8. AI Enhancements
Astro v7 includes first-class support for AI-assisted development workflows.
### Key Points
- **Background dev server**: auto-detects when running inside AI agents and optimizes output
- **JSON logging**: configurable, composable log output for machine consumption
- See `ai-dev-server.md` for full details
### Background Dev Server
When Astro detects it's running inside an AI agent environment, it automatically:
- Switches to structured JSON log output
- Suppresses interactive UI elements (progress bars, spinners)
- Provides machine-readable error messages with file/line references
- Exposes a lightweight status API for agent polling
### JSON Logging
```js
// astro.config.mjs
export default defineConfig({
devToolbar: { enabled: false },
logging: {
format: 'json', // 'pretty' | 'json' | 'minimal'
level: 'info',
}
});
```
---
## 9. Performance Benchmarks
Real-world build time improvements measured on production sites (Astro v6 → v7):
| Site | v6 | v7 | Improvement |
|------|----|----|-------------|
| docs.astro.build | 114s | 73s | **36% faster** |
| astro.build | 62s | 24s | **61% faster** |
| biomejs.dev | 176s | 150s | **15% faster** |
| developers.cloudflare.com | 387s | 262s | **32% faster** |
### Contributing Factors
- **Rolldown bundler**: 10-30x faster than Rollup for the bundling phase
- **Rust compiler**: eliminates Go→WASM overhead, native binary execution
- **Sätteri**: Markdown/MDX processing in Rust vs. JavaScript-based unified pipeline
- **Queued rendering**: 2.4x faster for expression-dense templates
### Impact by Project Size
- Small sites (< 100 pages): 15-25% faster builds
- Medium sites (100-1000 pages): 30-45% faster builds
- Large sites (1000+ pages): 40-60% faster builds
The largest gains are seen in content-heavy sites with extensive Markdown processing and complex component trees.

View File

@@ -0,0 +1,210 @@
# Validation Checklist
Complete checklist for validating an Astro installation/upgrade. Run these checks in the project root.
---
## 1. Build Validation
```bash
# Full production build — must exit 0 with no errors
npx astro build
# Type checking (requires @astrojs/check)
npx astro check
# Verify no unclosed tags (Rust compiler is strict about this)
find src -name "*.astro" -exec grep -Pn '<(img|br|hr|input|meta|link|source|area|base|col|embed|param|track|wbr)[^/]*[^/]>' {} +
# Check for HTML nesting issues (div/section/article inside p)
grep -rPn '<p[^>]*>[\s\S]*?<(div|section|article|ul|ol|table|blockquote|h[1-6])' src/**/*.astro
```
**Expected:** All commands pass with no errors or matches.
---
## 2. Breaking Pattern Detection
### Unclosed HTML tags
```bash
# Find self-closing tags that are NOT void elements (common breakage)
grep -rPn '<(div|span|p|a|section|main|footer|header|nav|ul|li)\s[^>]*/>' src/ --include="*.astro"
```
### Block elements inside `<p>`
```bash
grep -rPn '<p[^>]*>[\s\S]*?<(div|section|article|ul|ol|dl|table|blockquote|pre|h[1-6]|form|fieldset|hr)' src/ --include="*.astro"
```
### Whitespace-dependent inline layouts
```bash
# Look for adjacent inline elements that rely on whitespace rendering
grep -rPn '</(span|a|strong|em|code)>\s*<(span|a|strong|em|code)' src/ --include="*.astro"
```
### src/fetch.ts conflict
```bash
# Astro reserves src/fetch.ts — check if it exists
find src -maxdepth 1 -name "fetch.ts" -o -name "fetch.js"
```
### @astrojs/db usage (removed in Astro 5+)
```bash
grep -rn "@astrojs/db" package.json src/ --include="*.{ts,js,astro}"
```
### Deprecated transition imports
```bash
# TRANSITION_* named exports removed
grep -rPn 'TRANSITION_[A-Z_]+' src/ --include="*.{ts,js,astro}"
# isTransition*() helpers removed
grep -rPn 'isTransition\w+\(' src/ --include="*.{ts,js,astro}"
```
### getContainerRenderer() from package root
```bash
# Must now import from /container subpath
grep -rn "getContainerRenderer" src/ --include="*.{ts,js}" | grep -v "/container"
```
### Experimental flags that should be removed
```bash
# Check astro.config for experimental flags that graduated to stable
grep -A 20 'experimental:' astro.config.{mjs,ts,js} 2>/dev/null | grep -P '(contentLayer|serverIslands|actions|env|fonts|responsiveImages|svg)'
```
---
## 3. Deprecated Pattern Detection
| Pattern | grep command | Fix |
|---------|-------------|-----|
| `Astro.glob()` | `grep -rn "Astro.glob" src/ --include="*.astro"` | Replace with `import.meta.glob()` or Content Collections |
| `Astro.fetchContent()` | `grep -rn "Astro.fetchContent" src/ --include="*.astro"` | Replace with Content Collections |
| `getStaticPaths` without `paginate` import | `grep -rn "getStaticPaths" src/ --include="*.astro"` | Verify using new pagination API |
| Legacy content collections (`src/content/config.ts` with `defineCollection` using `schema` only) | `grep -rn "defineCollection" src/content/config.ts` | Migrate to `type: 'content_layer'` or new loader API |
| `@astrojs/image` | `grep -rn "@astrojs/image" package.json` | Use built-in `astro:assets` |
| `integrations: [image()]` | `grep -rn "image()" astro.config.*` | Remove — use built-in `<Image>` component |
| `<Markdown>` component | `grep -rn "<Markdown" src/ --include="*.astro"` | Use MDX or Content Collections |
| `set:html` on component | `grep -rPn 'set:html' src/ --include="*.astro"` | Verify it's on HTML elements only |
| `class:list` with nested arrays | `grep -rPn 'class:list=\{.*\[.*\[' src/ --include="*.astro"` | Flatten to single array |
---
## 4. Markdown/MDX Validation
### Remark/Rehype plugin migration
```bash
# Check if custom remark/rehype plugins are configured
grep -Pn '(remarkPlugins|rehypePlugins)' astro.config.{mjs,ts,js} 2>/dev/null
# If found, verify @astrojs/markdown-remark is installed
grep -n "@astrojs/markdown-remark" package.json
```
**Fix:** If custom plugins exist but `@astrojs/markdown-remark` is missing:
```bash
npx astro add @astrojs/markdown-remark
```
### Shiki (syntax highlighting) compatibility
```bash
# Check for custom Shiki config — API may have changed
grep -A 10 'shikiConfig' astro.config.{mjs,ts,js} 2>/dev/null
```
### GFM features (tables, strikethrough, autolinks)
```bash
# GFM is built-in — check there's no redundant remark-gfm
grep -rn "remark-gfm" package.json astro.config.{mjs,ts,js} 2>/dev/null
```
**Fix:** Remove `remark-gfm` from plugins — GFM is included by default.
### Test MDX rendering
```bash
# Verify MDX integration is present if .mdx files exist
find src -name "*.mdx" | head -1 && grep -n "@astrojs/mdx" package.json
```
---
## 5. Performance Validation
### Compare build times
```bash
# Time the build (run before and after upgrade)
time npx astro build 2>&1 | tail -5
```
### Verify queued rendering is active
```bash
# Queued rendering should be default in Astro 5+ — check it's not disabled
grep -n "queuedRendering" astro.config.{mjs,ts,js} 2>/dev/null
```
**Expected:** No results (uses default) or `true`. If set to `false`, remove it.
### Check Vite 6+ bundle output
```bash
# Verify build output structure
ls -la dist/ 2>/dev/null || ls -la dist/_astro/ 2>/dev/null
# Check chunk sizes
find dist -name "*.js" -exec wc -c {} + | sort -n | tail -10
# Verify no duplicate framework chunks
find dist -name "*.js" | xargs grep -l "react" 2>/dev/null | wc -l
```
### Verify no dev-only code in production build
```bash
grep -rn "import.meta.env.DEV" dist/ 2>/dev/null
```
---
## Quick Full Validation Script
```bash
#!/usr/bin/env bash
set -e
echo "=== Astro Validation ==="
echo "[1/5] Build..."
npx astro build
echo "[2/5] Type check..."
npx astro check || echo "WARN: astro check failed"
echo "[3/5] Breaking patterns..."
grep -rn "@astrojs/db" src/ --include="*.{ts,js,astro}" && echo "FAIL: @astrojs/db found" || true
grep -rPn 'TRANSITION_[A-Z_]+' src/ --include="*.{ts,js,astro}" && echo "FAIL: deprecated transitions" || true
find src -maxdepth 1 -name "fetch.ts" -o -name "fetch.js" | grep . && echo "FAIL: src/fetch conflict" || true
echo "[4/5] Deprecated APIs..."
grep -rn "Astro.glob\|Astro.fetchContent\|@astrojs/image" src/ package.json && echo "FAIL: deprecated APIs" || true
echo "[5/5] Performance..."
time npx astro build 2>&1 | tail -3
echo "=== Done ==="
```