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.
713 lines
21 KiB
Markdown
713 lines
21 KiB
Markdown
# 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 (5–120 chars) and description (15–160 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.6–0.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/)
|