skills / astro-seo
AGENTS.md100 starsChanged 7 months agoMITView on GitHub
Detailed code recipes for Phase 2 (Improve). Read this file when you need to implement a specific fix flagged by the audit. The parent SKILL.md has the workflow and audit checklist. If not installed: If installed but behind the latest npm version (checked in Phase 0): Read the package's changelog between the installed and latest version before upgrading — new defaults may need explicit opt-out if the project relied on old behavior. 1.0.0 migration note. <Seo> is now a first-party…
- Reads credentials
- Installs packages
Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.
Of the AGENTS.md files we read, the 31,774 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.
In this repository17 other files it gives its agents.
- github-profile/AGENTS.mdAGENTS.md
- github-repo/AGENTS.mdAGENTS.md
- static-seo/AGENTS.mdAGENTS.md
- wp-readme-optimizer/AGENTS.mdAGENTS.md
- wp-static-clone/AGENTS.mdAGENTS.md
- astro-github-actionsSkill
- astro-seoSkill
- content-seoSkill
- emdash-github-actionsSkill
- github-profileSkill
- github-repoSkill
- metadata-checkSkill
- readability-checkSkill
- static-seoSkill
- wp-github-actionsSkill
- wp-readme-optimizerSkill
- wp-static-cloneSkill
The file
Show its rules and text
We split this AGENTS.md into the rules it gives an agent
13 rules. About 0 are common, 3 are distinctive, and 10 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
Distinctive · estimate
This author's own choices. Worth a look.
10
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 13
If installed but behind the latest npm version (checked in Phase 0):
- Distinctive: line 19
Read the package's changelog between the installed and latest version before upgrading — new defaults may need explicit opt-out if the …
- Very specific: line 97
Import gitLastmod from @jdevalk/astro-seo-graph (≥ 1.4.0) — don't hand-roll it.
- Very specific: line 226
If the project still has a hand-written api-catalog.ts returning JSON literally, replace it.
- Very specific: line 230
Add a Content-Signal: directive declaring AI training, search grounding, and AI input preferences independently of crawl access.
- Very specific: line 242
Only publish these if the site actually exposes the corresponding endpoint.
- Very specific: line 272
Add CORS headers in _headers so cross-origin agents can fetch them:
- Very specific: line 339
Only list resources the site actually serves.
- Very specific: line 343
If no feed exists, add @astrojs/rss:
- Very specific: line 349
Create src/pages/feed.xml.ts that pulls the blog collection and renders full post bodies (not excerpts) — truncated feeds frustrate readers …
- Very specific: line 355
If migrating from WordPress or another CMS, export the old URL list from the source (WP-CLI wp post list, database dump, or the old sitemap …
- Distinctive: line 357
Commit the table once, maintain it going forward whenever you change a slug.
- Very specific: line 402
Add a lychee workflow at .github/workflows/link-check.yml:
≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Astro SEO — Implementation recipes
Detailed code recipes for Phase 2 (Improve). Read this file when you need to implement a specific fix flagged by the audit. The parent SKILL.md has the workflow and audit checklist.
Install or upgrade @jdevalk/astro-seo-graph
If not installed:
npm install @jdevalk/astro-seo-graphIf installed but behind the latest npm version (checked in Phase 0):
npm install @jdevalk/astro-seo-graph@latestRead the package's changelog between the installed and latest version before upgrading — new defaults may need explicit opt-out if the project relied on old behavior.
1.0.0 migration note. <Seo> is now a first-party component (drops the astro-seo dep) and buildAstroSeoProps / the AstroSeoProps type were removed. If the project called buildAstroSeoProps directly, migrate to buildSeoContext (returns a flat SeoContext). Sites using <Seo> through the normal prop interface need no code change — the upgrade also fixes three silent bugs (articlePublisher now renders, canonical no longer leaks on noindex pages, single og:image and single <meta name="robots"> per page).
Integration config
// astro.config.mjs
import seoGraph from '@jdevalk/astro-seo-graph/integration';
// Only submit to IndexNow on the production host. Local `npm run build`
// and preview deploys should not ping the endpoint with URLs the host
// hasn't served yet — that gets the key rejected (403) and forces rotation.
const isProductionBuild =
process.env.CF_PAGES === '1' && process.env.CF_PAGES_BRANCH === 'main';
// Vercel: process.env.VERCEL_ENV === 'production'
// Netlify: process.env.CONTEXT === 'production'
export default defineConfig({
site: 'https://example.com',
integrations: [
seoGraph({
validateH1: true,
validateUniqueMetadata: true,
validateImageAlt: true,
validateMetadataLength: true,
validateInternalLinks: {
// ≥ 1.1.1 recognises non-HTML files from public/ as valid
// link targets automatically. `skip` is still useful for
// SSR-only routes, wildcards, and `[slug]` params.
skip: (href) => href.startsWith('/api/'),
},
...(isProductionBuild && {
indexNow: {
key: process.env.INDEXNOW_KEY,
host: 'example.com',
siteUrl: 'https://example.com',
},
}),
llmsTxt: {
title: 'Example',
siteUrl: 'https://example.com',
},
// Emit <link rel="alternate" type="text/markdown"> on every
// page. Only enable once `createMarkdownEndpoint` is wired at
// the matching path (see § Markdown alternates below),
// otherwise the link 404s. Requires ≥ 1.2.0.
markdownAlternate: true,
}),
],
});BaseHead.astro
Replace whatever head metadata the project has with a single <Seo> call. The component handles title, description, canonical, Open Graph, Twitter, JSON-LD graph, and extra links in one place.
Content collection schema
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
import { seoSchema } from '@jdevalk/astro-seo-graph';
export const collections = {
blog: defineCollection({
schema: ({ image }) => z.object({
title: z.string(),
excerpt: z.string(),
publishDate: z.coerce.date(),
seo: seoSchema(image).optional(),
}),
}),
};Per-collection sitemap + git lastmod
Import gitLastmod from @jdevalk/astro-seo-graph (≥ 1.4.0) — don't hand-roll it. The export skips bulk commits (imports, reformats, mass renames) via excludeCommits, which a plain git log -1 can't do, and returns null cleanly when git is unavailable so the caller can fall back to publishDate.
import sitemap from '@astrojs/sitemap';
import { gitLastmod } from '@jdevalk/astro-seo-graph';
// Short SHAs of bulk commits that shouldn't count as content updates.
const BULK_COMMITS = ['52130a9', '989dc47'];
function urlToSourceFile(url) {
// map the public URL back to the markdown file that produced it
const slug = new URL(url).pathname.replace(/^\/blog\/|\/$/g, '');
return `src/content/blog/${slug}/index.md`;
}
sitemap({
entryLimit: 1000,
chunks: {
posts: (item) => isBlogPost(new URL(item.url).pathname) ? item : undefined,
},
serialize: (item) => {
const last = gitLastmod(urlToSourceFile(item.url), { excludeCommits: BULK_COMMITS });
if (last) item.lastmod = last.toISOString();
return item;
},
});gitLastmod shells out to git, so it only runs at build time on Node — not in browser bundles or edge runtimes. Resolve filePath relative to the build CWD. The same export feeds dateModified on JSON-LD pieces if you need consistent "updated" dates across sitemap and schema.
OG image route
The goal: every page has a 1200×675 JPEG OG image with a deterministic URL derived from the slug, generated at build time. No manual upload step, no runtime rendering.
Pick the renderer.
- Text-heavy cards (title + subtitle + site name over a solid or gradient background) — use
satorito render JSX/HTML to SVG, thensharpto rasterize to JPEG. This is the common case. Satori doesn't support every CSS feature — stick to flexbox, basic typography, absolute positioning. - Photo-heavy cards (hero image with a title overlay) — skip satori. Use
sharp's composite pipeline: load the hero frompublic/, resize to 1200×675, composite an SVG overlay for the title band, encode JPEG. Faster and avoids satori's CSS limits.
Build-time route vs. build script.
A route at src/pages/og/[...slug].jpg.ts that runs in getStaticPaths is the cleanest fit for Astro: one file, colocated with the content, no external build step, per-page URLs just work. Use a standalone Node build script that writes to dist/og/ only when you need to share the renderer across sites or run it outside Astro. Default to the route.
Fonts.
Satori needs font buffers, not CSS. Commit a woff2 (or TTF — satori accepts both) under src/fonts/ and read it with fs.readFileSync at build time. For text that might contain glyphs the primary font doesn't cover (CJK, emoji, extended Latin), pass multiple fonts in the satori({ fonts: [...] }) array — satori falls back through them in order. Don't rely on system fonts; the build environment won't have them.
Localized sites.
Include the locale in the slug (/og/en/<slug>.jpg, /og/nl/<slug>.jpg) and resolve per-locale strings (site name, tagline) inside the route. If the primary font doesn't cover the target language, add a locale-appropriate fallback font to the fonts array before rendering.
Minimal satori route sketch:
// src/pages/og/[...slug].jpg.ts
import satori from 'satori';
import sharp from 'sharp';
import fs from 'node:fs';
import { getCollection } from 'astro:content';
const font = fs.readFileSync('src/fonts/Inter-Bold.woff2');
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({ params: { slug: post.slug }, props: { post } }));
}
export async function GET({ props }) {
const svg = await satori(
{
type: 'div',
props: {
style: { /* flex, padding, colors, etc. */ },
children: [/* title + subtitle nodes */],
},
},
{ width: 1200, height: 675, fonts: [{ name: 'Inter', data: font, weight: 700, style: 'normal' }] }
);
const jpg = await sharp(Buffer.from(svg)).jpeg({ quality: 90 }).toBuffer();
return new Response(jpg, { headers: { 'Content-Type': 'image/jpeg' } });
}Wire the URL into <Seo> as the openGraph.image value.
If the project already has an OG route, verify: output is 1200×675 JPEG (not WebP/AVIF/PNG — social platforms don't all support those), the URL is deterministic from the slug, and the route runs at build time (not SSR on request — that's a per-crawl cost).
Schema endpoints and schema map
Each endpoint collects every entry in a content collection, builds the JSON-LD graph per entry, and serves the combined result as application/ld+json. Don't hand-write the mapper — the full implementation with entity builders (buildWebPage, buildArticle, etc.) and their expected arguments is documented in the astro-seo-graph schema endpoints docs. Copy from there. Then add /schemamap.xml listing every endpoint, and a Schemamap: directive in robots.txt.
llms.txt
Pass an options object to llmsTxt on the seoGraph() integration (requires @jdevalk/astro-seo-graph ≥ 0.9.0). Required fields: title (the H1 for the file) and siteUrl (used to resolve crawled HTML paths). Optional: summary (rendered as a blockquote), details (extra paragraphs), sections (user-supplied sections; when given, no pages are auto-collected), filter / autoSectionName / outputPath to tune the auto-generated output. For rendering outside the integration hook, import renderLlmsTxt from the package.
Markdown alternates
Serve clean markdown copies of every page at a parallel .md URL for AI agents (Claude, ChatGPT, Perplexity, Cloudflare's crawlers) to consume without HTML parsing. Requires @jdevalk/astro-seo-graph ≥ 1.2.0; ≥ 1.3.0 adds build-output verification of the discovery link. Two pieces:
- The route. Create
src/pages/[...slug].md.ts(or whatever path shape you need) and exportcreateMarkdownEndpoint. It serves a YAML frontmatter block (title, canonical, pubDate, updatedDate, author, description, tags, categories) followed by the markdown body, withContent-Type: text/markdown; charset=utf-8,X-Robots-Tag: noindex, follow,X-Markdown-Tokens: <n>, and aLink: <canonical>; rel="canonical"header pointing crawlers back at the HTML. Token count defaults tochars/4; swap ingpt-tokenizeror@anthropic-ai/tokenizerviaestimateTokensfor accuracy. Slug-match guard required in themapper: returnnullwhenpost.id !== slug, otherwise the first entry whose mapper returns non-null wins for every URL — a silent 200-with-wrong-content bug. - The discovery link. Set
markdownAlternate: trueon theseoGraph()integration (see the integration config above).<Seo>will emit<link rel="alternate" type="text/markdown" href="…">on every page, derived from the canonical (/blog/post/→/blog/post.md). Only turn this on after the route is wired at the matching path — otherwise the link 404s. ≥ 1.3.0 walks the build output and strips any link whose target.mdisn't on disk (with a per-occurrencewarn), so misconfigured endpoints surface as build warnings instead of shipped 404s. SSR users whose.mdendpoints aren't prerendered should leave this off and emit the link themselves — the verification will otherwise strip every link.
Content negotiation on a static host. On Cloudflare Pages, add a Transform Rule that rewrites Accept: text/markdown requests to the .md path. Use wildcard_replace (free plan) — regex_replace is paid-only. Don't add a Vary: Accept response-header rule: Cloudflare strips custom Vary values at the edge, and the URL rewrite already separates cache entries per content type. Trailing-slash canonical URLs use wildcard_replace(http.request.uri.path, "*/", "${1}.md"); extensionless non-trailing-slash URLs need a separate rule that matches not ends_with(…, "/") and rewrites with concat(http.request.uri.path, ".md"). The astro-seo-graph README has the full rule config; copy from there.
For rendering outside the route, import renderMarkdownAlternate — pure renderer, same frontmatter + body + token-count output. For callers building their own pipelines, ≥ 1.3.0 also exports findMarkdownAlternateLink, stripMarkdownAlternateLink, and resolveMarkdownAlternatePath (the same primitives the post-build verifier uses).
API catalog
@jdevalk/astro-seo-graph ≥ 1.4.0 ships createApiCatalog, an Astro route factory that returns RFC 9727 application/linkset+json. Drop it at 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: '/seo-graph/' },
{ path: '/schema/page.json', schemaType: 'WebPage', serviceDoc: '/seo-graph/' },
],
schemaMap: { path: '/schemamap.xml', serviceDoc: '/seo-graph/' },
additional: [
{ anchor: '/ask', serviceDoc: '/ask/', type: 'https://schema.org/SearchAction' },
{ anchor: '/feed.xml', type: 'https://www.w3.org/2005/Atom' },
],
});Schema endpoints get auto-typed to https://schema.org/<schemaType> URLs; additional is for site-specific APIs where you supply anchor, optional serviceDoc, and optional type (single string or string[]). Relative paths are absolutized against siteUrl. The factory also exports CATALOG_PATH (/.well-known/api-catalog) for use in _headers Link entries (see § Link headers).
If the project still has a hand-written api-catalog.ts returning JSON literally, replace it. The factory removes the duplication where adding a schema endpoint required updating the catalog by hand.
Content Signals in robots.txt
Add a Content-Signal: directive declaring AI training, search grounding, and AI input preferences independently of crawl access. The spec is an IETF draft, but adoption costs one line in a file every site already has.
Common policies:
- "Public corpus, anything goes":
Content-Signal: ai-train=yes, search=yes, ai-input=yes - "Indexable but not for training":
Content-Signal: ai-train=no, search=yes, ai-input=yes - "Search only, no AI":
Content-Signal: ai-train=no, search=yes, ai-input=no
The directive sits alongside User-agent rules. If robots.txt is generated from a route (src/pages/robots.txt.ts), append the line to the response body. Don't gate this on user-agent; it applies site-wide.
MCP and A2A discovery cards
Only publish these if the site actually exposes the corresponding endpoint. They're static JSON files in public/.well-known/.
MCP server card at /.well-known/mcp/server-card.json per SEP-1649:
{
"name": "example-mcp",
"version": "1.0.0",
"transport": { "type": "streamable-http", "url": "https://example.com/mcp" },
"capabilities": { "tools": true, "resources": false, "prompts": false }
}A2A agent card at /.well-known/agent-card.json per the A2A protocol:
{
"name": "example-agent",
"url": "https://example.com",
"skills": [
{
"id": "ask",
"name": "Ask",
"description": "Answer questions about the corpus",
"service_endpoint": "https://example.com/ask"
}
]
}Add CORS headers in _headers so cross-origin agents can fetch them:
/.well-known/*
Access-Control-Allow-Origin: *
Cache-Control: public, max-age=3600Wire both into the sitewide Link header (next section).
ARD catalog and OKF bundle
Both are v0.9 drafts — optional, not recommended. Publish ARD once the site has more than one agent-facing surface worth listing; publish an OKF bundle when the content collection is worth shipping as a packaged knowledge base.
ARD catalog (Agentic Resource Discovery) — a static JSON file at public/.well-known/ai-catalog.json listing the domain's agent-facing resources. Each entry carries both type and mediaType with the same value: the base spec (Agent-Card/ai-catalog) reads mediaType, the ARD layer (ards-project/ard-spec) reads type, and both ignore unknown keys, so the dual-field entry validates either way. representativeQueries is an optional array of sample prompts.
{
"version": "0.9",
"entries": [
{
"name": "OKF bundle",
"type": "application/okf-bundle+gzip",
"mediaType": "application/okf-bundle+gzip",
"url": "https://example.com/okf.tar.gz",
"representativeQueries": ["What does this site document?"]
},
{
"name": "MCP server",
"type": "application/json",
"mediaType": "application/json",
"url": "https://example.com/.well-known/mcp/server-card.json"
}
]
}OKF bundle (Open Knowledge Format) — a tree of typed Markdown concept files (one per page, paths mirroring canonical URLs) plus index files, served as a single okf.tar.gz. Generate it as one more output of the build that already produces sitemap / llms.txt / markdown alternates, not a second copy to keep in sync. There is no registered media type yet; ship application/okf-bundle+gzip as a single documented constant marked interim (tracked in knowledge-catalog#111 and ard-spec#27). Reuse the same CORS / cache _headers block as the other .well-known files, and add the catalog to the sitewide Link header (next section) as rel="ai-catalog".
Link headers for agent discovery
A single Link header on /* consolidates the discovery surface: sitemap, llms.txt, api-catalog, schemamap, and any MCP / A2A cards. Agents reading response headers don't have to load HTML to find any of them.
Cloudflare Pages / Netlify. Append to public/_headers:
/*
Link: </sitemap-index.xml>; rel="sitemap", </llms.txt>; rel="alternate"; type="text/plain", </.well-known/api-catalog>; rel="api-catalog", </schemamap.xml>; rel="schemamap"Append , </.well-known/mcp/server-card.json>; rel="mcp-server-card" and , </.well-known/agent-card.json>; rel="agent-card" if those cards exist.
Vercel. Set in vercel.json:
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "Link", "value": "</sitemap-index.xml>; rel=\"sitemap\", </llms.txt>; rel=\"alternate\"; type=\"text/plain\", </.well-known/api-catalog>; rel=\"api-catalog\", </schemamap.xml>; rel=\"schemamap\"" }
]
}
]
}Only list resources the site actually serves. A Link entry pointing at a 404 is worse than no entry.
RSS feed
If no feed exists, add @astrojs/rss:
npm install @astrojs/rssCreate src/pages/feed.xml.ts that pulls the blog collection and renders full post bodies (not excerpts) — truncated feeds frustrate readers and give AI agents less to work with. Advertise the feed in the <Seo> component's extraLinks with rel="alternate" and type="application/rss+xml".
Redirects and FuzzyRedirect
Seeding _redirects from scratch is the unpleasant part. Practical approach:
- If migrating from WordPress or another CMS, export the old URL list from the source (WP-CLI
wp post list, database dump, or the old sitemap via Wayback Machine). - Diff old URLs against the current sitemap; every URL in the old set not in the new set needs a redirect entry.
- Commit the table once, maintain it going forward whenever you change a slug.
Syntax depends on the host:
- Cloudflare Pages / Netlify:
public/_redirects—/old-path /new-path 301per line. - Vercel:
vercel.jsonwith aredirectsarray. - Other hosts: server config (nginx, Apache, etc.).
Then add <FuzzyRedirect> to the 404 page. Confirm the 404 page returns a 404 status, not 200 — platform-specific behavior, check the deployed response.
Performance headers
Syntax depends on the host. Pick the one matching Phase 0's detected deployment target.
Cloudflare Pages / Netlify — public/_headers:
/_astro/*
Cache-Control: public, max-age=31536000, immutable
/*
No-Vary-Search: key-order, params=("utm_source" "utm_medium" "utm_campaign" "utm_content" "utm_term")Vercel — vercel.json:
{
"headers": [
{
"source": "/_astro/(.*)",
"headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
},
{
"source": "/(.*)",
"headers": [{ "key": "No-Vary-Search", "value": "key-order, params=(\"utm_source\" \"utm_medium\" \"utm_campaign\" \"utm_content\" \"utm_term\")" }]
}
]
}Other hosts — configure equivalents in server config; syntax varies.
Broken link checker in CI
Add a lychee workflow at .github/workflows/link-check.yml:
name: Link Check
on:
push:
paths:
- 'src/content/**'
- 'src/pages/**'
- '*.md'
schedule:
- cron: '0 9 * * 1' # Weekly, Mondays 09:00 UTC — catches link rot
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: lycheeverse/lychee-action@v2
with:
args: --no-progress './**/*.md' './**/*.astro' './**/*.mdx'
fail: truePush-triggered runs block broken links from shipping. The weekly run catches external link rot.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

