Blog domain on @core/content: posts, authors, categories, tags, series, related posts, archive and RSS/Atom/JSON feeds
Install
genpm add @core/blogWhat you get
- Source in src/lib/blog/, 7 files. (20 kB)
- AI rules in src/lib/blog/AGENTS.md, plus IDE rule files.
- Env vars added to .env.example: SITE_URL.
- Resolves @core/content, @core/contracts, @core/db, @core/rich-text for you.
README
This package has no README.
This is exactly what your AI reads when it works in src/lib/blog. Nothing else is added to its context.
@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.
The exact tree that will be injected, after .genpmignore. Pinned to
// Colecciones del blog sobre @core/content. Las relaciones se guardan por slug (autores, categorías, etiquetas, serie).
import { z } from 'zod';
import { defineCollection } from '../content/index.ts';
import type { AdminField } from '../contracts/index.ts';
import { RichTextSchema } from '../rich-text/index.ts';
const Slug = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
const Seo = z.object({ title: z.string().max(70).optional(), description: z.string().max(170).optional(), noindex: z.boolean().optional() }).default({});
export const PostSchema = z.object({
title: z.string().min(1).max(200),
/** Resumen para listados y meta description; si falta se genera del cuerpo. */
excerpt: z.string().max(300).optional(),
body: RichTextSchema,
/** Id de @core/media. */
cover: z.string().max(64).optional(),
coverAlt: z.string().max(300).optional(),
authors: z.array(Slug).max(5).default([]),
categories: z.array(Slug).max(5).default([]),
tags: z.array(Slug).max(20).default([]),
// Sin serie elegida en el panel llega `{ slug: null }`: se trata como "sin serie".
series: z.preprocess(
(v) => (v && typeof v === 'object' && (v as { slug?: unknown }).slug ? v : undefined),
z.object({ slug: Slug, order: z.coerce.number().int().min(1).default(1) }).optional(),
),
featured: z.boolean().default(false),
/** Solo para suscriptores (con @core/billing: `hasEntitlement`). La web decide cómo ocultarlo. */
membersOnly: z.boolean().default(false),
seo: Seo,
});
export const AuthorSchema = z.object({
name: z.string().min(1).max(120),
bio: z.string().max(1000).optional(),
avatar: z.string().max(64).optional(),
links: z.array(z.object({ label: z.string().max(40), url: z.url() })).max(10).default([]),
/** Usuario de @core/auth asociado (permisos `:own`). */
userId: z.string().max(64).optional(),
});
export const TaxonomySchema = z.object({ name: z.string().min(1).max(80), description: z.string().max(500).optional() });
const postFields: AdminField[] = [
{ name: 'title', label: 'Title', type: 'text', required: true, list: true },
{ name: 'excerpt', label: 'Excerpt', type: 'textarea' },
{ name: 'body', label: 'Body', type: 'richText', required: true },
{ name: 'cover', label: 'Cover image', type: 'media' },
{ name: 'coverAlt', label: 'Cover alt text', type: 'text' },
{ name: 'authors', label: 'Authors', type: 'relation', relationTo: 'authors', relationKey: 'slug', many: true },
{ name: 'categories', label: 'Categories', type: 'relation', relationTo: 'categories', relationKey: 'slug', many: true, list: true },
{ name: 'tags', label: 'Tags', type: 'relation', relationTo: 'tags', relationKey: 'slug', many: true },
{ name: 'series.slug', label: 'Series', type: 'relation', relationTo: 'series', relationKey: 'slug' },
{ name: 'series.order', label: 'Part number in the series', type: 'number' },
{ name: 'featured', label: 'Featured', type: 'boolean', list: true },
{ name: 'membersOnly', label: 'Members only', type: 'boolean' },
{ name: 'seo.title', label: 'SEO title (≤ 70)', type: 'text' },
{ name: 'seo.description', label: 'SEO description (≤ 170)', type: 'textarea' },
{ name: 'seo.noindex', label: 'Hide from search engines', type: 'boolean' },
];
export const posts = defineCollection('posts', { schema: PostSchema, label: { singular: 'Post', plural: 'Posts' }, fields: postFields });
export const authors = defineCollection('authors', { schema: AuthorSchema, label: { singular: 'Author', plural: 'Authors' }, titleField: 'name' });
export const categories = defineCollection('categories', { schema: TaxonomySchema, label: { singular: 'Category', plural: 'Categories' }, titleField: 'name' });
export const tags = defineCollection('tags', { schema: TaxonomySchema, label: { singular: 'Tag', plural: 'Tags' }, titleField: 'name' });
export const series = defineCollection('series', { schema: TaxonomySchema, label: { singular: 'Series', plural: 'Series' }, titleField: 'name' });
export type PostData = z.infer<typeof PostSchema>;
export type AuthorData = z.infer<typeof AuthorSchema>;
export type TaxonomyData = z.infer<typeof TaxonomySchema>;
export const BLOG_COLLECTIONS = ['posts', 'authors', 'categories', 'tags', 'series'] as const;
This package declares no MCP servers.
| Version | Commit | Published | Scan |
|---|---|---|---|
| 1.0.1 | 8aaf3ee | 2 hours ago | scan passed |
- npm
- zod ^4.0.0
- proposed
- GenPM proposes the npm command and runs it only if you say yes.
- Used by (0)
- No public package depends on it yet.
- scan
- scan passed · 0 findings
- commit
- v1.0.1 → 8aaf3ee41bc0e822456e2ca2de743f11eee88f20 · verified after fetch
- scripts
- None. GenPM never runs package code.
- license
- MIT
- Quality
- 100/100
- Recognized licensepassed
- AGENTS.md explains its purposepassed
- AGENTS.md has integration stepspassed
- AGENTS.md lists conventions or don'tspassed
- Includes testspassed
- Security scan passedpassed
- Released in the last 6 monthspassed
- Verified publisherpassed
- Summary and keywordspassed
- report
- See something wrong?