SEO pour sites GenPM : métadonnées avec canonique et hreflang, sitemaps découpés, robots.txt, llms.txt et JSON-LD sûr
Installer
genpm add @core/seoCe que vous obtenez
- Source dans src/lib/seo/, 9 fichiers. (20,8 ko)
- Règles IA dans src/lib/seo/AGENTS.md, plus les fichiers de règles de l’IDE.
- Variables d’environnement ajoutées à .env.example : SITE_URL, SEO_NOINDEX.
- Résout @core/contracts, @core/site pour vous.
README
Ce paquet n’a pas de README.
Voici exactement ce que lit votre IA quand elle travaille dans src/lib/seo. Rien d’autre n’est ajouté à son contexte.
@core/seo — rules for AI agents
Purpose
Everything search engines and crawling agents read: page metadata with site defaults (title template, description,
canonical, hreflang, Open Graph, Twitter, robots), a sitemap index with chunks of 50,000 URLs built from any
registered SitemapSource, robots.txt, llms.txt and schema.org JSON-LD builders with script-safe escaping.
No tables. OG image generation is out of scope for 1.0.
Map
index.ts— public API:buildMetadata,metaTags,SeoFieldsSchema,jsonLd,jsonLdScript,registerSitemapSource,robotsTxt,llmsTxt.metadata.ts,jsonld.ts,sitemap.ts,url.ts(SITE_URLhelpers).adapters/hono.ts—seoRoutes().adapters/next.ts—sitemapIndexRoute,sitemapChunkRoute,robotsRoute.
Integration
- Env:
SITE_URL(https). In staging setSEO_NOINDEX=1(noindex everywhere andDisallow: /). - Register sources in
src/genpm/seo.ts, imported at startup:registerSitemapSource(contentSitemapSource('pages', ({ slug }) =>/${slug})). - Routes: Hono
app.route('/', seoRoutes()). Next.js:app/sitemap.xml/route.ts,app/sitemaps/[name]/[file]/route.tsandapp/robots.txt/route.tsre-exporting the handlers (seeadapters/next.ts), each withexport const dynamic = 'force-dynamic'(otherwisenext buildprerenders them and needs the database andSITE_URLat build time); delete Next's ownsitemap.ts/robots.tsif present. - Pages: Next.js
export async function generateMetadata() { return buildMetadata({ title, description, path, alternates }) }; other frameworks putmetaTags(await buildMetadata(...))in<head>. - JSON-LD:
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript(jsonLd.product(...)) }} />(jsonLdScriptescapes it, so this is the one safe use of raw HTML). - Add
seo: SeoFieldsSchemato collections that need per-entry overrides. - Verify:
/sitemap.xmllists chunks, each chunk lists published URLs; page<head>has one canonical.
Conventions
- One
buildMetadataper page; don't set<title>or description elsewhere. - Canonical paths are the public URL without query strings; pass
alternatesfor every translated page. noindexpreviews, internal search results and account pages.
Don't
- Don't add ratings, prices or availability to JSON-LD that the page doesn't show or that aren't real.
- Don't serialize JSON-LD with plain
JSON.stringifyinside a script tag. - Don't list drafts or private pages in a sitemap source.
# @core/seo — rules for AI agents
## Purpose
Everything search engines and crawling agents read: page metadata with site defaults (title template, description,
canonical, hreflang, Open Graph, Twitter, robots), a sitemap index with chunks of 50,000 URLs built from any
registered `SitemapSource`, `robots.txt`, `llms.txt` and schema.org JSON-LD builders with script-safe escaping.
No tables. OG image generation is out of scope for 1.0.
## Map
- `index.ts` — public API: `buildMetadata`, `metaTags`, `SeoFieldsSchema`, `jsonLd`, `jsonLdScript`, `registerSitemapSource`, `robotsTxt`, `llmsTxt`.
- `metadata.ts`, `jsonld.ts`, `sitemap.ts`, `url.ts` (`SITE_URL` helpers).
- `adapters/hono.ts` — `seoRoutes()`. `adapters/next.ts` — `sitemapIndexRoute`, `sitemapChunkRoute`, `robotsRoute`.
## Integration
1. Env: `SITE_URL` (https). In staging set `SEO_NOINDEX=1` (noindex everywhere and `Disallow: /`).
2. Register sources in `src/genpm/seo.ts`, imported at startup:
`registerSitemapSource(contentSitemapSource('pages', ({ slug }) => `/${slug}`))`.
3. Routes: Hono `app.route('/', seoRoutes())`. Next.js: `app/sitemap.xml/route.ts`, `app/sitemaps/[name]/[file]/route.ts`
and `app/robots.txt/route.ts` re-exporting the handlers (see `adapters/next.ts`), each with `export const dynamic = 'force-dynamic'`
(otherwise `next build` prerenders them and needs the database and `SITE_URL` at build time); delete Next's own `sitemap.ts`/`robots.ts` if present.
4. Pages: Next.js `export async function generateMetadata() { return buildMetadata({ title, description, path, alternates }) }`;
other frameworks put `metaTags(await buildMetadata(...))` in `<head>`.
5. JSON-LD: `<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript(jsonLd.product(...)) }} />`
(`jsonLdScript` escapes it, so this is the one safe use of raw HTML).
6. Add `seo: SeoFieldsSchema` to collections that need per-entry overrides.
7. Verify: `/sitemap.xml` lists chunks, each chunk lists published URLs; page `<head>` has one canonical.
## Conventions
- One `buildMetadata` per page; don't set `<title>` or description elsewhere.
- Canonical paths are the public URL without query strings; pass `alternates` for every translated page.
- `noindex` previews, internal search results and account pages.
## Don't
- Don't add ratings, prices or availability to JSON-LD that the page doesn't show or that aren't real.
- Don't serialize JSON-LD with plain `JSON.stringify` inside a script tag.
- Don't list drafts or private pages in a sitemap source.
L’arborescence exacte qui sera injectée, après .genpmignore. Épinglée à
// Datos estructurados schema.org (JSON-LD). `jsonLdScript` escapa para incrustar en <script> sin riesgo de XSS.
import type { Money } from '../contracts/index.ts';
import type { SiteSettings } from '../site/index.ts';
import { absoluteUrl, siteUrl } from './url.ts';
type Thing = Record<string, unknown>;
const ctx = (o: Thing): Thing => ({ '@context': 'https://schema.org', ...o });
const amount = (m: Money) => {
const digits = new Intl.NumberFormat('en', { style: 'currency', currency: m.currency }).resolvedOptions().maximumFractionDigits ?? 2;
return (m.amount / 10 ** digits).toFixed(digits);
};
export const jsonLd = {
organization(s: SiteSettings): Thing {
return ctx({
'@type': 'Organization',
name: s.name,
url: siteUrl().toString(),
...(s.logo && { logo: absoluteUrl(s.logo) }),
...(s.contactEmail && { email: s.contactEmail }),
...(s.phone && { telephone: s.phone }),
...(s.social.length && { sameAs: s.social.map((x) => x.url) }),
});
},
/** Con `searchPath` (`/search?q=`) añade la caja de búsqueda de Google. */
website(s: SiteSettings, searchPath?: string): Thing {
return ctx({
'@type': 'WebSite',
name: s.name,
url: siteUrl().toString(),
...(searchPath && {
potentialAction: { '@type': 'SearchAction', target: `${absoluteUrl(searchPath)}{search_term_string}`, 'query-input': 'required name=search_term_string' },
}),
});
},
breadcrumbs(items: Array<{ name: string; path: string }>): Thing {
return ctx({
'@type': 'BreadcrumbList',
itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, item: absoluteUrl(it.path) })),
});
},
article(a: { title: string; path: string; description?: string; image?: string; author?: string; publishedAt: Date; modifiedAt?: Date; publisher?: SiteSettings }): Thing {
return ctx({
'@type': 'BlogPosting',
headline: a.title,
url: absoluteUrl(a.path),
...(a.description && { description: a.description }),
...(a.image && { image: absoluteUrl(a.image) }),
...(a.author && { author: { '@type': 'Person', name: a.author } }),
datePublished: a.publishedAt.toISOString(),
dateModified: (a.modifiedAt ?? a.publishedAt).toISOString(),
...(a.publisher && { publisher: { '@type': 'Organization', name: a.publisher.name, ...(a.publisher.logo && { logo: absoluteUrl(a.publisher.logo) }) } }),
});
},
product(p: {
name: string;
path: string;
description?: string;
images?: string[];
sku?: string;
brand?: string;
price: Money;
availability: 'InStock' | 'OutOfStock' | 'PreOrder' | 'BackOrder';
rating?: { average: number; count: number };
}): Thing {
return ctx({
'@type': 'Product',
name: p.name,
url: absoluteUrl(p.path),
...(p.description && { description: p.description }),
...(p.images?.length && { image: p.images.map((i) => absoluteUrl(i)) }),
...(p.sku && { sku: p.sku }),
...(p.brand && { brand: { '@type': 'Brand', name: p.brand } }),
offers: {
'@type': 'Offer',
url: absoluteUrl(p.path),
price: amount(p.price),
priceCurrency: p.price.currency,
availability: `https://schema.org/${p.availability}`,
},
// Solo con reseñas reales: nunca inventar valoraciones.
...(p.rating && p.rating.count > 0 && {
aggregateRating: { '@type': 'AggregateRating', ratingValue: p.rating.average.toFixed(1), reviewCount: p.rating.count },
}),
});
},
faq(items: Array<{ question: string; answer: string }>): Thing {
return ctx({
'@type': 'FAQPage',
mainEntity: items.map((i) => ({ '@type': 'Question', name: i.question, acceptedAnswer: { '@type': 'Answer', text: i.answer } })),
});
},
};
/** JSON para `<script type="application/ld+json">`: escapa `<`, `>`, `&` y separadores de línea. */
export function jsonLdScript(data: Thing | Thing[]): string {
return JSON.stringify(data)
.replaceAll('<', '\\u003c')
.replaceAll('>', '\\u003e')
.replaceAll('&', '\\u0026')
.replaceAll('
', '\\u2028')
.replaceAll('
', '\\u2029');
}
Ce paquet ne déclare aucun serveur MCP.
| Version | Commit | Publié | Analyse |
|---|---|---|---|
| 1.0.0 | cf4e3c3 | il y a 3 heures | analyse réussie |
- npm
- zod ^4.0.0
- proposé
- GenPM propose la commande npm et ne l’exécute que si vous acceptez.
- Utilisé par (1)
- @core/kit-cms ^1.0.0
- analyse
- analyse réussie · 0 problème
- commit
- v1.0.0 → cf4e3c3ab3bbd5919dece315446321a20909599f · vérifié après téléchargement
- scripts
- Aucun. GenPM n’exécute jamais le code des paquets.
- licence
- MIT
- Qualité
- 100/100
- Licence reconnuevalidé
- AGENTS.md explique son objectifvalidé
- AGENTS.md donne les étapes d’intégrationvalidé
- AGENTS.md liste conventions ou interditsvalidé
- Contient des testsvalidé
- Analyse de sécurité réussievalidé
- Publié au cours des 6 derniers moisvalidé
- Éditeur vérifiévalidé
- Résumé et mots-clésvalidé
- signalement
- Vous avez repéré un problème ?