Domínio de blog sobre @core/content: posts, autores, categorias, tags, séries, relacionados, arquivo e feeds RSS/Atom/JSON
Instalar
genpm add @core/blogO que você recebe
- Código em src/lib/blog/, 7 arquivos. (20 kB)
- Regras de IA em src/lib/blog/AGENTS.md, mais arquivos de regras da IDE.
- Variáveis de ambiente adicionadas ao .env.example: SITE_URL.
- Resolve @core/content, @core/contracts, @core/db, @core/rich-text para você.
README
Este pacote não tem README.
Isto é exatamente o que sua IA lê quando trabalha em src/lib/blog. Nada mais é adicionado ao contexto dela.
@core/blog — rules for AI agents
Purpose
Blog domain built only from @core/content collections (posts, authors, categories, tags, series; no tables of
its own): filtered and paginated listings, a post with its relations resolved, related posts, monthly archive, series
order, RSS 2.0 / Atom / JSON Feed, sitemap and search sources and admin resources. No routes or templates (those
come from the app or @core/kit-blog), no comments or newsletter (separate modules).
Map
index.ts— public API:listPosts,getPost,relatedPosts,archive,postsInMonth,rssFeed,atomFeed,jsonFeed,blogSitemapSource,blogSearchSource,blogAdminResources.collections.ts— schemas (PostSchemausesRichTextSchema).posts.ts— reads.feeds.ts— feeds.sources.ts— sitemap/search/admin.
Integration
- Import this module at startup (it registers the collections) and set
SITE_URL. - Routes (Next.js example):
/blog→listPosts({ page, locale });/blog/[slug]→getPost(slug, { locale, draft })(404 when null) +relatedPosts(post);/blog/category/[slug]→listPosts({ category }); same fortag,author,series. - Feeds:
/blog/feed.xml→rssFeed({ title, description })(application/rss+xml),/blog/atom.xml,/blog/feed.json. - Register
blogSitemapSource()insrc/genpm/seo.ts,blogSearchSource()insrc/genpm/search.tsand...blogAdminResources()insrc/genpm/admin.ts. - Render the body with
<RichText doc={post.data.body} />(@core/rich-text) and addjsonLd.article(...)(@core/seo). - Verify: publish a post, it appears in
/blog, the feed and the sitemap; a scheduled post does not.
Conventions
- Relations are slugs: create the author/category/tag entries with the same slug.
membersOnlyposts: the page must hide the body unless the user is entitled; feeds and search only expose the excerpt.- Paths default to
/blog/<slug>; pass your ownpath/postPathif the blog lives elsewhere.
Don't
- Don't query
content_entriesdirectly for posts; uselistPosts(it respects publication and locale fallback). - Don't put the full body of members-only posts in feeds, search or meta descriptions.
# @core/blog — rules for AI agents
## Purpose
Blog domain built only from @core/content collections (`posts`, `authors`, `categories`, `tags`, `series`; no tables of
its own): filtered and paginated listings, a post with its relations resolved, related posts, monthly archive, series
order, RSS 2.0 / Atom / JSON Feed, sitemap and search sources and admin resources. No routes or templates (those
come from the app or @core/kit-blog), no comments or newsletter (separate modules).
## Map
- `index.ts` — public API: `listPosts`, `getPost`, `relatedPosts`, `archive`, `postsInMonth`, `rssFeed`, `atomFeed`, `jsonFeed`, `blogSitemapSource`, `blogSearchSource`, `blogAdminResources`.
- `collections.ts` — schemas (`PostSchema` uses `RichTextSchema`). `posts.ts` — reads. `feeds.ts` — feeds. `sources.ts` — sitemap/search/admin.
## Integration
1. Import this module at startup (it registers the collections) and set `SITE_URL`.
2. Routes (Next.js example): `/blog` → `listPosts({ page, locale })`; `/blog/[slug]` → `getPost(slug, { locale, draft })`
(404 when null) + `relatedPosts(post)`; `/blog/category/[slug]` → `listPosts({ category })`; same for `tag`, `author`, `series`.
3. Feeds: `/blog/feed.xml` → `rssFeed({ title, description })` (`application/rss+xml`), `/blog/atom.xml`, `/blog/feed.json`.
4. Register `blogSitemapSource()` in `src/genpm/seo.ts`, `blogSearchSource()` in `src/genpm/search.ts` and `...blogAdminResources()` in `src/genpm/admin.ts`.
5. Render the body with `<RichText doc={post.data.body} />` (@core/rich-text) and add `jsonLd.article(...)` (@core/seo).
6. Verify: publish a post, it appears in `/blog`, the feed and the sitemap; a scheduled post does not.
## Conventions
- Relations are slugs: create the author/category/tag entries with the same slug.
- `membersOnly` posts: the page must hide the body unless the user is entitled; feeds and search only expose the excerpt.
- Paths default to `/blog/<slug>`; pass your own `path`/`postPath` if the blog lives elsewhere.
## Don't
- Don't query `content_entries` directly for posts; use `listPosts` (it respects publication and locale fallback).
- Don't put the full body of members-only posts in feeds, search or meta descriptions.
A árvore exata que será injetada, após o .genpmignore. Fixada em
// Lectura del blog: listados filtrados y paginados, post con relaciones resueltas, relacionados y archivo.
import { and, eq, sql } from 'drizzle-orm';
import { contentEntries, countEntries, getEntry, listEntries } from '../content/index.ts';
import type { ContentEntry, ContentListQuery } from '../contracts/index.ts';
import { getDb } from '../db/index.ts';
import { excerpt, readingTime } from '../rich-text/index.ts';
import type { AuthorData, PostData, TaxonomyData } from './collections.ts';
export type PostSummary = {
id: string;
slug: string;
locale: string;
title: string;
excerpt: string;
cover?: string;
coverAlt?: string;
authors: string[];
categories: string[];
tags: string[];
featured: boolean;
membersOnly: boolean;
readingMinutes: number;
publishedAt: Date | null;
updatedAt: Date;
};
export type Named = { slug: string; name: string };
export type Post = PostSummary & {
data: PostData;
authorProfiles: Array<{ slug: string } & AuthorData>;
categoryList: Named[];
tagList: Named[];
};
export const summarize = (e: ContentEntry<PostData>): PostSummary => ({
id: e.id,
slug: e.slug,
locale: e.locale,
title: e.data.title,
excerpt: e.data.excerpt ?? excerpt(e.data.body, 200),
...(e.data.cover && { cover: e.data.cover }),
...(e.data.coverAlt && { coverAlt: e.data.coverAlt }),
authors: e.data.authors ?? [],
categories: e.data.categories ?? [],
tags: e.data.tags ?? [],
featured: e.data.featured ?? false,
membersOnly: e.data.membersOnly ?? false,
readingMinutes: readingTime(e.data.body),
publishedAt: e.publishedAt,
updatedAt: e.updatedAt,
});
export type ListPostsOptions = {
locale?: string;
category?: string;
tag?: string;
author?: string;
series?: string;
featured?: boolean;
page?: number;
perPage?: number;
draft?: boolean;
};
export async function listPosts(o: ListPostsOptions = {}): Promise<{ posts: PostSummary[]; total: number; page: number; pages: number }> {
const perPage = Math.min(Math.max(o.perPage ?? 10, 1), 100);
const page = Math.max(Math.floor(o.page ?? 1), 1);
const contains: Record<string, string> = {};
if (o.category) contains.categories = o.category;
if (o.tag) contains.tags = o.tag;
if (o.author) contains.authors = o.author;
const where: ContentListQuery['where'] = {};
if (o.featured !== undefined) where.featured = o.featured;
const q: ContentListQuery = { locale: o.locale, draft: o.draft, contains, where };
if (o.series) {
// Filtra en SQL (no entre los N más recientes) y ordena por número de capítulo.
const all = await listEntries<PostData>('posts', { ...q, where: { ...where, 'series.slug': o.series }, limit: 500, orderBy: 'publishedAt' });
const inSeries = all.sort((a, b) => (a.data.series?.order ?? 0) - (b.data.series?.order ?? 0));
return { posts: inSeries.slice((page - 1) * perPage, page * perPage).map(summarize), total: inSeries.length, page, pages: Math.max(1, Math.ceil(inSeries.length / perPage)) };
}
const [rows, total] = await Promise.all([
listEntries<PostData>('posts', { ...q, orderBy: '-publishedAt', limit: perPage, offset: (page - 1) * perPage }),
countEntries('posts', q),
]);
return { posts: rows.map(summarize), total, page, pages: Math.max(1, Math.ceil(total / perPage)) };
}
async function named(collection: string, slugs: string[], locale?: string): Promise<Named[]> {
const out: Named[] = [];
for (const slug of slugs) {
const e = await getEntry<TaxonomyData>(collection, slug, { locale });
out.push({ slug, name: e?.data.name ?? slug });
}
return out;
}
/** Post publicado con autores, categorías y etiquetas resueltos (o null). */
export async function getPost(slug: string, opts: { locale?: string; draft?: boolean } = {}): Promise<Post | null> {
const e = await getEntry<PostData>('posts', slug, opts);
if (!e) return null;
const s = summarize(e);
const authorProfiles: Post['authorProfiles'] = [];
for (const a of s.authors) {
const author = await getEntry<AuthorData>('authors', a, { locale: opts.locale });
if (author) authorProfiles.push({ slug: a, ...author.data });
}
return { ...s, data: e.data, authorProfiles, categoryList: await named('categories', s.categories, opts.locale), tagList: await named('tags', s.tags, opts.locale) };
}
/** Posts que comparten más etiquetas/categorías con `post` (entre los 200 más recientes). */
export async function relatedPosts(post: Pick<PostSummary, 'slug' | 'tags' | 'categories' | 'locale'>, limit = 3): Promise<PostSummary[]> {
const recent = await listEntries<PostData>('posts', { locale: post.locale, orderBy: '-publishedAt', limit: 200 });
const score = (e: ContentEntry<PostData>) =>
(e.data.tags ?? []).filter((t) => post.tags.includes(t)).length * 2 + (e.data.categories ?? []).filter((c) => post.categories.includes(c)).length;
return recent
.filter((e) => e.slug !== post.slug)
.map((e) => ({ e, s: score(e) }))
.filter((x) => x.s > 0)
.sort((a, b) => b.s - a.s || (b.e.publishedAt?.getTime() ?? 0) - (a.e.publishedAt?.getTime() ?? 0))
.slice(0, limit)
.map((x) => summarize(x.e));
}
/** Número de posts publicados por año y mes (zona UTC), del más reciente al más antiguo. */
export async function archive(opts: { locale?: string } = {}): Promise<Array<{ year: number; month: number; count: number }>> {
const month = sql<string>`to_char(${contentEntries.publishedAt} at time zone 'UTC', 'YYYY-MM')`;
const rows = await getDb()
.select({ ym: month, n: sql<number>`count(distinct ${contentEntries.groupId})`.mapWith(Number) })
.from(contentEntries)
.where(and(eq(contentEntries.collection, 'posts'), eq(contentEntries.status, 'published'), opts.locale ? eq(contentEntries.locale, opts.locale) : undefined))
.groupBy(month)
.orderBy(sql`1 desc`);
return rows.map((r) => ({ year: Number(r.ym.slice(0, 4)), month: Number(r.ym.slice(5, 7)), count: r.n }));
}
/** Posts publicados en un mes. */
export async function postsInMonth(year: number, month: number, opts: { locale?: string } = {}): Promise<PostSummary[]> {
const from = Date.UTC(year, month - 1, 1);
const to = Date.UTC(year, month, 1);
const rows = await listEntries<PostData>('posts', { locale: opts.locale, orderBy: '-publishedAt', limit: 500, publishedFrom: new Date(from), publishedBefore: new Date(to) });
return rows.map(summarize);
}
Este pacote não declara servidores MCP.
| Versão | Commit | Publicado | Análise |
|---|---|---|---|
| 1.0.1 | 8aaf3ee | há 4 horas | análise aprovada |
- npm
- zod ^4.0.0
- proposto
- O GenPM propõe o comando npm e só o executa se você disser sim.
- Usado por (1)
- @core/kit-blog ^1.0.0
- análise
- análise aprovada · 0 achados
- commit
- v1.0.1 → 8aaf3ee41bc0e822456e2ca2de743f11eee88f20 · verificado após o download
- scripts
- Nenhum. O GenPM nunca executa código de pacotes.
- licença
- MIT
- Qualidade
- 100/100
- Licença reconhecidacumprido
- AGENTS.md explica o propósitocumprido
- AGENTS.md tem passos de integraçãocumprido
- AGENTS.md lista convenções ou proibiçõescumprido
- Inclui testescumprido
- Escaneamento de segurança aprovadocumprido
- Publicado nos últimos 6 mesescumprido
- Publicador verificadocumprido
- Resumo e palavras-chavecumprido
- denúncia
- Viu algo errado?