Núcleo de CMS headless: colecciones y singletons tipados con borradores, revisiones, programación, idiomas y vista previa
Instalar
genpm add @core/contentQué obtienes
- Código en src/lib/content/, 11 archivos. (46,4 kB)
- Reglas de IA en src/lib/content/AGENTS.md, más archivos de reglas para tu IDE.
- Variables añadidas a .env.example: CONTENT_PREVIEW_SECRET, CONTENT_DEFAULT_LOCALE.
- Resuelve @core/contracts, @core/db, @core/jobs por ti.
README
Este paquete no tiene README.
Esto es exactamente lo que lee tu IA cuando trabaja en src/lib/content. No se añade nada más a su contexto.
@core/content — rules for AI agents
Purpose
Content model of the CMS: collections (pages, posts, FAQs) and singletons (home, footer, settings) defined with zod,
stored in Postgres with drafts separate from published data, revisions, scheduled publishing (@core/jobs), locales
with fallback and signed preview. Implements ContentSource, sitemap/search sources and AdminResource
(@core/contracts). No UI: @core/admin renders the editor from contentAdminResource().
Map
index.ts— public API.registry.ts—defineCollection,defineSingleton.entries.ts— create,saveDraft,publish,schedulePublish,unpublish,archive, revisions,seedEntry,migrateEntries.read.ts—getEntry,listEntries,getSingleton,reader(collection)(typed).preview.ts— preview tokens.listEntries/countEntriesalso take nestedwherekeys ({ 'series.slug': 'basics' }) and apublishedAtrange (publishedFrominclusive,publishedBeforeexclusive): filter in SQL instead of fetching and filtering in memory.sources.ts—contentSitemapSource,contentSearchSource.admin.ts—contentAdminResource.adapters/next.ts—previewRoute(draftMode, cookies),exitPreviewRoute(draftMode).
Integration
- Env:
CONTENT_PREVIEW_SECRET(≥ 32 chars), optionalCONTENT_DEFAULT_LOCALE(defaulten). Migrations as insrc/lib/db/AGENTS.md. - Define the site's content in one project file imported everywhere,
src/genpm/content.ts:export const pages = defineCollection('pages', { schema: z.object({ title: z.string(), body: z.string() }) }). - Read in pages:
const page = await reader(pages).get(slug, { locale, draft: isDraftMode })(404 if null). - Preview:
app/api/preview/route.tswithexport const GET = previewRoute(draftMode, cookies)(both fromnext/headers) andapp/api/exit-preview/route.tswithexport const GET = exitPreviewRoute(draftMode). Passingcookiesmakes Next's draft-mode cookie expire with the token (admin links: 600 s); without it, draft mode lasts the whole browser session. - Scheduled publishing needs the @core/jobs cron endpoint running.
- Register
contentAdminResource('pages')insrc/genpm/admin.ts, and the sources insrc/genpm/seo.ts/search.ts. - Verify: create, publish, and read an entry; drafts must not appear without draft mode.
Retrofit: make an existing site editable
Work page by page, one commit per page, without changing markup or styles:
- List every visible literal (texts, image URLs, links) of the page.
- Group them: one singleton per page (
home,about). Repeated items of one page (features, prices, FAQs) are alistfield inside that singleton (ordered, one edit screen); use a collection only when the items have their own pages or are shared by several pages. - Define the schema with the current values' shape and call
seedEntrywith the current values (idempotent; it only creates, so it never overwrites editor changes — and a field added later to the seed does not reach an already-seeded database: set it in the admin or withsaveDraft+publish), e.g. from a seed script. - Replace literals with typed reads; keep a fallback only where a value is optional.
- Render before/after and compare the HTML: it must be identical. Next 15 streams metadata into
<body>for browsers oncegenerateMetadataawaits data;htmlLimitedBots: /.*/innext.config(kit-cms Integration 1) keeps it in<head>. - Grant editors the new singletons:
defineRolewith<singleton>:*(e.g.home:*,site_settings:*) — the defaulteditorrole only covers the kit's collections.
Conventions
- Field names in admin resources are
data.<field>; slugs are lowercasea-z0-9-with up to 6/segments. - Changing a schema incompatibly requires
migrateEntries; never edit jsonb by hand. - Permissions:
<collection>:read|create|update|delete|publish,:ownfor authors (owner =authorId). - Public reads never pass
draft: trueunless draft mode came from a verified preview token. - A preview link is not bound to its entry: Next's draft mode is browser-wide, so while it lasts (until the token
expires, when
cookiesis passed) that browser sees the drafts of every entry of every collection. Only share preview links with people who may see unpublished content;/api/exit-previewends it early. - Admin
updaterenames and saves the draft in one transaction: invalid data never leaves the entry renamed.
Don't
- Don't write to
content_entriesdirectly; publishing must go throughpublish()(validation + revision). - Don't put secrets or personal data in content; it is public once published.
- Don't redirect previews to absolute URLs;
safePathrejects them.
# @core/content — rules for AI agents
## Purpose
Content model of the CMS: collections (pages, posts, FAQs) and singletons (home, footer, settings) defined with zod,
stored in Postgres with drafts separate from published data, revisions, scheduled publishing (@core/jobs), locales
with fallback and signed preview. Implements `ContentSource`, sitemap/search sources and `AdminResource`
(@core/contracts). No UI: @core/admin renders the editor from `contentAdminResource()`.
## Map
- `index.ts` — public API. `registry.ts` — `defineCollection`, `defineSingleton`. `entries.ts` — create, `saveDraft`,
`publish`, `schedulePublish`, `unpublish`, `archive`, revisions, `seedEntry`, `migrateEntries`.
- `read.ts` — `getEntry`, `listEntries`, `getSingleton`, `reader(collection)` (typed). `preview.ts` — preview tokens.
`listEntries`/`countEntries` also take nested `where` keys (`{ 'series.slug': 'basics' }`) and a `publishedAt` range
(`publishedFrom` inclusive, `publishedBefore` exclusive): filter in SQL instead of fetching and filtering in memory.
- `sources.ts` — `contentSitemapSource`, `contentSearchSource`. `admin.ts` — `contentAdminResource`.
- `adapters/next.ts` — `previewRoute(draftMode, cookies)`, `exitPreviewRoute(draftMode)`.
## Integration
1. Env: `CONTENT_PREVIEW_SECRET` (≥ 32 chars), optional `CONTENT_DEFAULT_LOCALE` (default `en`). Migrations as in `src/lib/db/AGENTS.md`.
2. Define the site's content in one project file imported everywhere, `src/genpm/content.ts`:
`export const pages = defineCollection('pages', { schema: z.object({ title: z.string(), body: z.string() }) })`.
3. Read in pages: `const page = await reader(pages).get(slug, { locale, draft: isDraftMode })` (404 if null).
4. Preview: `app/api/preview/route.ts` with `export const GET = previewRoute(draftMode, cookies)` (both from
`next/headers`) and `app/api/exit-preview/route.ts` with `export const GET = exitPreviewRoute(draftMode)`.
Passing `cookies` makes Next's draft-mode cookie expire with the token (admin links: 600 s); without it, draft mode
lasts the whole browser session.
5. Scheduled publishing needs the @core/jobs cron endpoint running.
6. Register `contentAdminResource('pages')` in `src/genpm/admin.ts`, and the sources in `src/genpm/seo.ts` / `search.ts`.
7. Verify: create, publish, and read an entry; drafts must not appear without draft mode.
### Retrofit: make an existing site editable
Work page by page, one commit per page, without changing markup or styles:
1. List every visible literal (texts, image URLs, links) of the page.
2. Group them: one singleton per page (`home`, `about`). Repeated items of one page (features, prices, FAQs) are a
`list` field inside that singleton (ordered, one edit screen); use a collection only when the items have their own
pages or are shared by several pages.
3. Define the schema with the current values' shape and call `seedEntry` with the current values (idempotent; it
only creates, so it never overwrites editor changes — and a field added later to the seed does not reach an
already-seeded database: set it in the admin or with `saveDraft` + `publish`), e.g. from a seed script.
4. Replace literals with typed reads; keep a fallback only where a value is optional.
5. Render before/after and compare the HTML: it must be identical. Next 15 streams metadata into `<body>` for browsers
once `generateMetadata` awaits data; `htmlLimitedBots: /.*/` in `next.config` (kit-cms Integration 1) keeps it in `<head>`.
6. Grant editors the new singletons: `defineRole` with `<singleton>:*` (e.g. `home:*`, `site_settings:*`) — the
default `editor` role only covers the kit's collections.
## Conventions
- Field names in admin resources are `data.<field>`; slugs are lowercase `a-z0-9-` with up to 6 `/` segments.
- Changing a schema incompatibly requires `migrateEntries`; never edit jsonb by hand.
- Permissions: `<collection>:read|create|update|delete|publish`, `:own` for authors (owner = `authorId`).
- Public reads never pass `draft: true` unless draft mode came from a verified preview token.
- A preview link is not bound to its entry: Next's draft mode is browser-wide, so while it lasts (until the token
expires, when `cookies` is passed) that browser sees the drafts of every entry of every collection. Only share
preview links with people who may see unpublished content; `/api/exit-preview` ends it early.
- Admin `update` renames and saves the draft in one transaction: invalid data never leaves the entry renamed.
## Don't
- Don't write to `content_entries` directly; publishing must go through `publish()` (validation + revision).
- Don't put secrets or personal data in content; it is public once published.
- Don't redirect previews to absolute URLs; `safePath` rejects them.
El árbol exacto que se inyectará, tras aplicar .genpmignore. Anclado a
// Escritura: borradores, publicación, programación, archivo, revisiones y migraciones de datos.
import { and, asc, desc, eq, gt } from 'drizzle-orm';
import { type Executor, getDb, newId, withTransaction } from '../db/index.ts';
import { defineJob } from '../jobs/index.ts';
import { z } from 'zod';
import {
assertSlug,
ContentError,
defaultLocale,
getCollection,
SINGLETON_SLUG,
validateData,
} from './registry.ts';
import { type ContentRevision, type ContentRow, contentEntries, contentRevisions } from './schema.ts';
type Actor = { authorId?: string | null };
export type CreateEntryInput = { collection: string; slug?: string; locale?: string; data: unknown; translationOf?: string } & Actor;
async function byId(id: string, db: Executor): Promise<ContentRow> {
const [row] = await db.select().from(contentEntries).where(eq(contentEntries.id, id));
if (!row) throw new ContentError('not_found', `entry ${id} not found`);
return row;
}
export const getEntryById = (id: string, db: Executor = getDb()) => byId(id, db);
/** Crea una entrada en borrador. Para singletons el slug es siempre `_`. */
export async function createEntry(input: CreateEntryInput, db: Executor = getDb()): Promise<ContentRow> {
const c = getCollection(input.collection);
const slug = assertSlug(c.kind === 'singleton' ? SINGLETON_SLUG : (input.slug ?? ''));
const locale = c.localized ? (input.locale ?? defaultLocale()) : defaultLocale();
const draft = validateData(c, input.data, true);
let groupId = newId('grp');
if (input.translationOf) {
const source = await byId(input.translationOf, db);
if (source.collection !== c.name) throw new ContentError('invalid_state', 'translation of another collection');
groupId = source.groupId;
}
const [row] = await db
.insert(contentEntries)
.values({ collection: c.name, slug, locale, groupId, draft, authorId: input.authorId ?? null })
.onConflictDoNothing()
.returning();
if (!row) throw new ContentError('slug_taken', `${c.name}/${slug} (${locale}) already exists`);
return row;
}
/** Guarda cambios sin publicar (validación parcial: se permiten borradores incompletos). */
export async function saveDraft(id: string, data: unknown, db: Executor = getDb()): Promise<ContentRow> {
const row = await byId(id, db);
const draft = validateData(getCollection(row.collection), data, true);
const [updated] = await db.update(contentEntries).set({ draft }).where(eq(contentEntries.id, id)).returning();
return updated!;
}
/** Cambia el slug (comprueba que no esté ocupado en ese idioma). */
export async function renameEntry(id: string, slug: string, db: Executor = getDb()): Promise<ContentRow> {
const row = await byId(id, db);
if (getCollection(row.collection).kind === 'singleton') throw new ContentError('invalid_state', 'singletons have no slug');
assertSlug(slug);
try {
const [updated] = await db.update(contentEntries).set({ slug }).where(eq(contentEntries.id, id)).returning();
return updated!;
} catch {
throw new ContentError('slug_taken', `${row.collection}/${slug} already exists`);
}
}
/** Publica el borrador (o republica lo publicado) con validación completa y deja revisión. */
export async function publish(
id: string,
opts: Actor & { message?: string; now?: Date } = {},
db: Executor = getDb(),
): Promise<ContentRow> {
return tx(db, async (t) => {
const row = await byId(id, t);
if (row.status === 'archived') throw new ContentError('invalid_state', 'archived entries must be restored first');
const data = validateData(getCollection(row.collection), row.draft ?? row.data ?? {}, false);
const now = opts.now ?? new Date();
const [updated] = await t
.update(contentEntries)
.set({ data, draft: null, status: 'published', scheduledAt: null, publishedAt: row.publishedAt ?? now })
.where(eq(contentEntries.id, id))
.returning();
await t.insert(contentRevisions).values({ entryId: id, data, authorId: opts.authorId ?? null, message: opts.message ?? null });
return updated!;
});
}
/** Tarea que publica las entradas programadas (requiere el cron de @core/jobs). */
export const publishScheduledJob = defineJob(
'content.publish-scheduled',
z.object({ entryId: z.string() }),
async ({ entryId }) => {
const row = await getEntryById(entryId).catch(() => null);
// Desprogramada, reprogramada a más tarde o ya publicada: no hace nada (idempotente).
if (!row?.scheduledAt || row.scheduledAt.getTime() > Date.now() + 1000) return;
await publish(entryId, { message: 'scheduled' });
},
);
/**
* Programa la publicación del borrador actual para `at`. Una entrada ya publicada sigue visible con su versión
* actual (`status` = published) hasta esa hora; una nueva queda `scheduled` (invisible).
*/
export async function schedulePublish(id: string, at: Date, db: Executor = getDb()): Promise<ContentRow> {
if (at.getTime() <= Date.now()) throw new ContentError('invalid_state', 'schedule date must be in the future');
const row = await byId(id, db);
if (row.status === 'archived') throw new ContentError('invalid_state', 'archived entries must be restored first');
validateData(getCollection(row.collection), row.draft ?? row.data ?? {}, false);
const [updated] = await db
.update(contentEntries)
.set({ status: row.status === 'published' ? 'published' : 'scheduled', scheduledAt: at })
.where(eq(contentEntries.id, id))
.returning();
await publishScheduledJob.enqueue({ entryId: id }, { runAt: at, dedupeKey: `content.publish:${id}:${at.getTime()}` }, db);
return updated!;
}
export async function unschedule(id: string, db: Executor = getDb()): Promise<ContentRow> {
const row = await byId(id, db);
const [updated] = await db
.update(contentEntries)
.set({ scheduledAt: null, status: row.status === 'scheduled' ? 'draft' : row.status })
.where(eq(contentEntries.id, id))
.returning();
return updated!;
}
/** Retira de la web: lo publicado vuelve a ser borrador. */
export async function unpublish(id: string, db: Executor = getDb()): Promise<ContentRow> {
const row = await byId(id, db);
const [updated] = await db
.update(contentEntries)
.set({ status: 'draft', draft: row.draft ?? row.data, data: null, scheduledAt: null })
.where(eq(contentEntries.id, id))
.returning();
return updated!;
}
export async function archive(id: string, db: Executor = getDb()): Promise<ContentRow> {
await byId(id, db);
const [updated] = await db
.update(contentEntries)
.set({ status: 'archived', scheduledAt: null })
.where(eq(contentEntries.id, id))
.returning();
return updated!;
}
/** Saca del archivo como borrador. */
export async function restore(id: string, db: Executor = getDb()): Promise<ContentRow> {
const row = await byId(id, db);
if (row.status !== 'archived') return row;
const [updated] = await db
.update(contentEntries)
.set({ status: 'draft', draft: row.draft ?? row.data, data: null })
.where(eq(contentEntries.id, id))
.returning();
return updated!;
}
export async function deleteEntry(id: string, db: Executor = getDb()): Promise<void> {
await db.delete(contentEntries).where(eq(contentEntries.id, id));
}
export async function listRevisions(entryId: string, db: Executor = getDb()): Promise<ContentRevision[]> {
return db.select().from(contentRevisions).where(eq(contentRevisions.entryId, entryId)).orderBy(desc(contentRevisions.createdAt), desc(contentRevisions.id));
}
/** Copia una revisión al borrador (hay que publicar después). */
export async function restoreRevision(revisionId: string, db: Executor = getDb()): Promise<ContentRow> {
const [rev] = await db.select().from(contentRevisions).where(eq(contentRevisions.id, revisionId));
if (!rev) throw new ContentError('not_found', `revision ${revisionId} not found`);
return saveDraft(rev.entryId, rev.data, db);
}
/**
* Crea y publica una entrada si no existe (idempotente). Para semillas y para el modo retrofit: guarda los textos
* actuales de una página como contenido sin pisar lo que el editor haya cambiado después.
*/
export async function seedEntry(
input: Omit<CreateEntryInput, 'translationOf'>,
db: Executor = getDb(),
): Promise<ContentRow> {
const c = getCollection(input.collection);
const slug = c.kind === 'singleton' ? SINGLETON_SLUG : (input.slug ?? '');
const locale = c.localized ? (input.locale ?? defaultLocale()) : defaultLocale();
const [existing] = await db
.select()
.from(contentEntries)
.where(and(eq(contentEntries.collection, c.name), eq(contentEntries.slug, slug), eq(contentEntries.locale, locale)));
if (existing) return existing;
const row = await createEntry({ ...input, slug, locale }, db);
return publish(row.id, { authorId: input.authorId, message: 'seed' }, db);
}
/**
* Migra los datos de una colección tras cambiar su esquema: aplica `fn` a `data` y `draft` de cada entrada en
* lotes, y valida el resultado con el esquema nuevo.
*/
export async function migrateEntries(
collection: string,
fn: (data: Record<string, unknown>) => Record<string, unknown>,
db: Executor = getDb(),
): Promise<number> {
const c = getCollection(collection);
let n = 0;
let after = '';
for (;;) {
const batch = await db
.select()
.from(contentEntries)
.where(and(eq(contentEntries.collection, collection), gt(contentEntries.id, after)))
.orderBy(asc(contentEntries.id))
.limit(200);
for (const r of batch) {
await db
.update(contentEntries)
.set({
data: r.data ? validateData(c, fn(r.data), false) : null,
draft: r.draft ? validateData(c, fn(r.draft), true) : null,
})
.where(eq(contentEntries.id, r.id));
n++;
}
if (batch.length < 200) break;
after = batch.at(-1)!.id;
}
return n;
}
function tx<T>(db: Executor, fn: (t: Executor) => Promise<T>): Promise<T> {
return 'rollback' in db ? fn(db) : withTransaction((t) => fn(t), db as Parameters<typeof withTransaction>[1]);
}
Este paquete no declara servidores MCP.
| Versión | Commit | Publicado | Escaneo |
|---|---|---|---|
| 1.1.0 | 23978fe | hace 4 horas | escaneo superado |
- npm
- zod ^4.0.0
- propuesta
- GenPM propone el comando npm y solo lo ejecuta si dices que sí.
- escaneo
- escaneo superado · 0 hallazgos
- commit
- v1.1.0 → 23978fee0b532853e3112d070817a3e274c4ed49 · verificado tras la descarga
- scripts
- Ninguno. GenPM nunca ejecuta código del paquete.
- licencia
- MIT
- Calidad
- 100/100
- Licencia reconocidacumplido
- AGENTS.md explica su propósitocumplido
- AGENTS.md tiene pasos de integracióncumplido
- AGENTS.md lista convenciones o prohibicionescumplido
- Incluye testscumplido
- Escaneo de seguridad superadocumplido
- Publicado en los últimos 6 mesescumplido
- Publicador verificadocumplido
- Resumen y palabras clavecumplido
- reporte
- ¿Ves algo raro?