GenPM 사이트 SEO: canonical·hreflang 메타데이터, 분할 사이트맵, robots.txt, llms.txt, 안전한 JSON-LD
코드파일 9개컨텍스트약 639토큰검사 통과
설치
$
genpm add @core/seo포함 내용
- src/lib/seo/에 소스 코드, 파일 9개. (20.8kB)
- src/lib/seo/AGENTS.md에 AI 규칙, 그리고 IDE 규칙 파일.
- .env.example에 추가되는 환경 변수: SITE_URL, SEO_NOINDEX.
- @core/contracts, @core/site을(를) 자동으로 해결합니다.
README
이 패키지에는 README가 없습니다.
약 639토큰→ src/lib/seo/AGENTS.md→ .cursor/rules/genpm-core-seo.mdc
이것이 AI가 src/lib/seo에서 작업할 때 읽는 내용 그대로입니다. 그 외에는 컨텍스트에 아무것도 추가되지 않습니다.
@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.
.genpmignore 적용 후 주입될 정확한 트리입니다. 고정 대상:
// sitemap.xml (índice) + trozos de hasta 50 000 URLs por fuente, robots.txt y llms.txt.
import type { SitemapSource, SitemapUrl } from '../contracts/index.ts';
import { absoluteUrl, siteUrl } from './url.ts';
export const SITEMAP_CHUNK = 50_000;
const sources = new Map<string, SitemapSource>();
/** Registra una fuente (en `src/genpm/seo.ts`). */
export function registerSitemapSource(source: SitemapSource): void {
if (!/^[a-z0-9][a-z0-9-_]{0,63}$/.test(source.name)) throw new Error(`invalid sitemap source name: ${source.name}`);
sources.set(source.name, source);
}
/** Solo tests. */
export const clearSitemapSources = () => sources.clear();
const xml = (s: string) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
async function countUrls(source: SitemapSource): Promise<number> {
let n = 0;
for await (const _ of source.urls()) n++;
return n;
}
/** Índice: un `<sitemap>` por trozo de cada fuente, servidos en `/sitemaps/<fuente>/<n>.xml`. */
export async function sitemapIndexXml(): Promise<string> {
const base = siteUrl();
const entries: string[] = [];
for (const s of sources.values()) {
const chunks = Math.max(1, Math.ceil((await countUrls(s)) / SITEMAP_CHUNK));
for (let i = 0; i < chunks; i++) entries.push(`<sitemap><loc>${xml(absoluteUrl(`/sitemaps/${s.name}/${i}.xml`, base))}</loc></sitemap>`);
}
return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${entries.join('')}</sitemapindex>`;
}
function urlXml(u: SitemapUrl, base: URL): string {
const parts = [`<loc>${xml(absoluteUrl(u.loc, base))}</loc>`];
if (u.lastmod) parts.push(`<lastmod>${u.lastmod.toISOString()}</lastmod>`);
if (u.changefreq) parts.push(`<changefreq>${u.changefreq}</changefreq>`);
if (u.priority !== undefined) parts.push(`<priority>${Math.min(1, Math.max(0, u.priority)).toFixed(1)}</priority>`);
for (const [lang, href] of Object.entries(u.alternates ?? {}))
parts.push(`<xhtml:link rel="alternate" hreflang="${xml(lang)}" href="${xml(absoluteUrl(href, base))}"/>`);
return `<url>${parts.join('')}</url>`;
}
/** Trozo `chunk` (desde 0) de la fuente `name`, o null si no existe. */
export async function sitemapChunkXml(name: string, chunk: number): Promise<string | null> {
const source = sources.get(name);
if (!source || !Number.isInteger(chunk) || chunk < 0) return null;
const base = siteUrl();
const from = chunk * SITEMAP_CHUNK;
const urls: string[] = [];
let i = 0;
for await (const u of source.urls()) {
if (i >= from && i < from + SITEMAP_CHUNK) urls.push(urlXml(u, base));
if (++i >= from + SITEMAP_CHUNK) break;
}
if (chunk > 0 && !urls.length) return null;
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls.join('')}</urlset>`;
}
/** robots.txt. Con `SEO_NOINDEX=1` (staging) bloquea todo. */
export function robotsTxt(opts: { disallow?: string[]; extra?: string } = {}): string {
if (process.env.SEO_NOINDEX === '1') return 'User-agent: *\nDisallow: /\n';
const disallow = opts.disallow ?? ['/admin', '/api', '/account', '/checkout', '/cart'];
return [
'User-agent: *',
...disallow.map((p) => `Disallow: ${p}`),
'',
`Sitemap: ${absoluteUrl('/sitemap.xml')}`,
...(opts.extra ? ['', opts.extra] : []),
'',
].join('\n');
}
export type LlmsSection = { title: string; links: Array<{ title: string; url: string; note?: string }> };
/** llms.txt (https://llmstxt.org): resumen del sitio en Markdown para agentes. */
export function llmsTxt(site: { name: string; summary?: string; details?: string; sections: LlmsSection[] }): string {
const line = (s: string) => s.replaceAll('\n', ' ').trim();
const out = [`# ${line(site.name)}`, ''];
if (site.summary) out.push(`> ${line(site.summary)}`, '');
if (site.details) out.push(site.details.trim(), '');
for (const sec of site.sections) {
out.push(`## ${line(sec.title)}`, '');
for (const l of sec.links) out.push(`- [${line(l.title).replaceAll(']', '\\]')}](${absoluteUrl(l.url)})${l.note ? `: ${line(l.note)}` : ''}`);
out.push('');
}
return out.join('\n');
}
이 패키지는 MCP 서버를 선언하지 않습니다.
| 버전 | 커밋 | 게시일 | 검사 |
|---|---|---|---|
| 1.0.0 | cf4e3c3 | 6시간 전 | 검사 통과 |
- npm
- zod ^4.0.0
- 제안됨
- GenPM은 npm 명령을 제안하고, 동의한 경우에만 실행합니다.
- 사용하는 패키지 (1)
- @core/kit-cms ^1.0.0
- 검사
- 검사 통과 · 문제 0건
- 커밋
- v1.0.0 → cf4e3c3ab3bbd5919dece315446321a20909599f · 가져온 뒤 검증됨
- 스크립트
- 없음. GenPM은 패키지 코드를 절대 실행하지 않습니다.
- 라이선스
- MIT
- 품질
- 100/100
- 인정된 라이선스충족
- AGENTS.md에 목적 설명충족
- AGENTS.md에 통합 단계충족
- AGENTS.md에 규칙 또는 금지 사항충족
- 테스트 포함충족
- 보안 검사 통과충족
- 최근 6개월 내 게시충족
- 인증된 게시자충족
- 요약과 키워드충족
- 신고
- 문제가 있나요?