SEO für GenPM-Seiten: Metadaten mit Canonical und hreflang, geteilte Sitemaps, robots.txt, llms.txt, sicheres JSON-LD
Installieren
genpm add @core/seoWas du bekommst
- Quellcode in src/lib/seo/, 9 Dateien. (20,8 kB)
- KI-Regeln in src/lib/seo/AGENTS.md, dazu Regeldateien für die IDE.
- Umgebungsvariablen in .env.example ergänzt: SITE_URL, SEO_NOINDEX.
- Löst @core/contracts, @core/site für dich auf.
README
Dieses Paket hat keine README.
Genau das liest deine KI, wenn sie in src/lib/seo arbeitet. Sonst wird ihrem Kontext nichts hinzugefügt.
@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.
Der genaue Baum, der nach .genpmignore eingebunden wird. Gepinnt an
// Metadatos de página con valores por defecto del sitio. Devuelve un objeto compatible con `Metadata` de Next.js
// (sin importar `next`) y, para otros frameworks, `metaTags()` genera las etiquetas.
import { z } from 'zod';
import type { AdminField } from '../contracts/index.ts';
import { getSiteSettings, type SiteSettings } from '../site/index.ts';
import { absoluteUrl, siteUrl } from './url.ts';
/** Campos SEO que cualquier colección puede añadir a su esquema: `schema: Page.extend({ seo: SeoFieldsSchema })`. */
export const SeoFieldsSchema = z
.object({
title: z.string().max(70).optional(),
description: z.string().max(170).optional(),
image: z.string().max(500).optional(),
noindex: z.boolean().optional(),
})
.default({});
export const seoAdminFields: AdminField[] = [
{ name: 'seo.title', label: 'SEO title (≤ 70)', type: 'text' },
{ name: 'seo.description', label: 'SEO description (≤ 170)', type: 'textarea' },
{ name: 'seo.image', label: 'Social image (URL or path)', type: 'text' },
{ name: 'seo.noindex', label: 'Hide from search engines', type: 'boolean' },
];
export type PageSeo = {
title?: string;
description?: string;
/** Ruta canónica de la página (`/blog/x`). */
path: string;
/** Imagen social (URL o ruta). Por defecto la de los ajustes. */
image?: string;
locale?: string;
/** Versiones en otros idiomas: locale → ruta. Genera `hreflang` (incluye `x-default` con el idioma por defecto). */
alternates?: Record<string, string>;
defaultLocale?: string;
type?: 'website' | 'article' | 'product';
publishedTime?: Date;
modifiedTime?: Date;
noindex?: boolean;
};
export type SeoMetadata = {
title: string;
description?: string;
alternates: { canonical: string; languages?: Record<string, string> };
openGraph: {
title: string;
description?: string;
url: string;
siteName?: string;
locale?: string;
type: 'website' | 'article';
images?: Array<{ url: string }>;
publishedTime?: string;
modifiedTime?: string;
};
twitter: { card: 'summary' | 'summary_large_image'; title: string; description?: string; images?: string[] };
robots?: { index: boolean; follow: boolean };
};
/** Une la página con los ajustes del sitio: título `Página · Sitio`, imagen y descripción por defecto. */
export function buildMetadataWith(page: PageSeo, settings: SiteSettings | null, base: URL = siteUrl()): SeoMetadata {
const name = settings?.name;
const title = page.title ? (name && page.title !== name ? `${page.title} · ${name}` : page.title) : (name ?? '');
const description = page.description ?? settings?.tagline;
const canonical = absoluteUrl(page.path, base);
const image = page.image ?? settings?.ogImage;
const languages = page.alternates
? Object.fromEntries([
...Object.entries(page.alternates).map(([l, p]) => [l, absoluteUrl(p, base)]),
...(page.defaultLocale && page.alternates[page.defaultLocale] ? [['x-default', absoluteUrl(page.alternates[page.defaultLocale]!, base)]] : []),
])
: undefined;
const noindex = page.noindex || process.env.SEO_NOINDEX === '1';
return {
title,
...(description && { description }),
alternates: { canonical, ...(languages && { languages }) },
openGraph: {
title,
...(description && { description }),
url: canonical,
...(name && { siteName: name }),
...(page.locale && { locale: page.locale.replace('-', '_') }),
type: page.type === 'article' ? 'article' : 'website',
...(image && { images: [{ url: absoluteUrl(image, base) }] }),
...(page.publishedTime && { publishedTime: page.publishedTime.toISOString() }),
...(page.modifiedTime && { modifiedTime: page.modifiedTime.toISOString() }),
},
twitter: {
card: image ? 'summary_large_image' : 'summary',
title,
...(description && { description }),
...(image && { images: [absoluteUrl(image, base)] }),
},
...(noindex && { robots: { index: false, follow: true } }),
};
}
/** Como `buildMetadataWith`, leyendo los ajustes publicados de @core/site. */
export async function buildMetadata(page: PageSeo): Promise<SeoMetadata> {
return buildMetadataWith(page, await getSiteSettings({ locale: page.locale }));
}
const esc = (s: string) => s.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>');
/** Etiquetas `<title>`/`<meta>`/`<link>` en HTML para frameworks sin API de metadatos (Hono JSX, plantillas). */
export function metaTags(m: SeoMetadata): string {
const tags = [`<title>${esc(m.title)}</title>`, `<link rel="canonical" href="${esc(m.alternates.canonical)}">`];
const meta = (attr: 'name' | 'property', key: string, value?: string) => value && tags.push(`<meta ${attr}="${key}" content="${esc(value)}">`);
meta('name', 'description', m.description);
for (const [lang, href] of Object.entries(m.alternates.languages ?? {})) tags.push(`<link rel="alternate" hreflang="${esc(lang)}" href="${esc(href)}">`);
meta('property', 'og:title', m.openGraph.title);
meta('property', 'og:description', m.openGraph.description);
meta('property', 'og:url', m.openGraph.url);
meta('property', 'og:site_name', m.openGraph.siteName);
meta('property', 'og:locale', m.openGraph.locale);
meta('property', 'og:type', m.openGraph.type);
for (const i of m.openGraph.images ?? []) meta('property', 'og:image', i.url);
meta('name', 'twitter:card', m.twitter.card);
if (m.robots) meta('name', 'robots', `${m.robots.index ? 'index' : 'noindex'}, ${m.robots.follow ? 'follow' : 'nofollow'}`);
return tags.join('\n');
}
Dieses Paket deklariert keine MCP-Server.
| Version | Commit | Veröffentlicht | Prüfung |
|---|---|---|---|
| 1.0.0 | cf4e3c3 | vor 4 Stunden | Prüfung bestanden |
- npm
- zod ^4.0.0
- vorgeschlagen
- GenPM schlägt den npm-Befehl vor und führt ihn nur aus, wenn du zustimmst.
- Verwendet von (1)
- @core/kit-cms ^1.0.0
- Prüfung
- Prüfung bestanden · 0 Befunde
- Commit
- v1.0.0 → cf4e3c3ab3bbd5919dece315446321a20909599f · nach dem Abruf verifiziert
- Skripte
- Keine. GenPM führt niemals Paketcode aus.
- Lizenz
- MIT
- Qualität
- 100/100
- Anerkannte Lizenzerfüllt
- AGENTS.md erklärt den Zweckerfüllt
- AGENTS.md enthält Integrationsschritteerfüllt
- AGENTS.md nennt Konventionen oder Verboteerfüllt
- Enthält Testserfüllt
- Sicherheitsscan bestandenerfüllt
- In den letzten 6 Monaten veröffentlichterfüllt
- Verifizierter Herausgebererfüllt
- Zusammenfassung und Schlagwörtererfüllt
- Meldung
- Stimmt etwas nicht?