无头 CMS 核心:类型化集合与单例,支持草稿、修订、定时发布、多语言和预览
代码11 个文件上下文约 1197 个 token扫描通过
安装
$
genpm add @core/content你将获得
- 源代码位于 src/lib/content/,共 11 个文件。 (46.4 kB)
- AI 规则位于 src/lib/content/AGENTS.md,另附 IDE 规则文件。
- 添加到 .env.example 的环境变量:CONTENT_PREVIEW_SECRET, CONTENT_DEFAULT_LOCALE。
- 自动为你解析 @core/contracts, @core/db, @core/jobs。
README
此包没有 README。
约 1197 个 token→ src/lib/content/AGENTS.md→ .cursor/rules/genpm-core-content.mdc
这正是你的 AI 在 src/lib/content 中工作时读取的内容。不会向其上下文添加其他任何内容。
@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.
应用 .genpmignore 后将被注入的确切目录树。固定于
// Recurso de panel (contrato AdminResource) por colección: lista, edición de borradores y acciones de publicación.
// Permisos: `<colección>:read|create|update|delete|publish`, con `:own` para autores (dueño = authorId).
import { and, count, eq, ilike, or, type SQL, sql } from 'drizzle-orm';
import { z } from 'zod';
import type { AdminContext, AdminResource } from '../contracts/index.ts';
import { getDb, withTransaction } from '../db/index.ts';
import {
archive,
createEntry,
deleteEntry,
getEntryById,
publish,
renameEntry,
restore,
saveDraft,
schedulePublish,
unpublish,
unschedule,
} from './entries.ts';
import { createPreviewToken } from './preview.ts';
import { ContentError, defaultLocale, getCollection, SINGLETON_SLUG } from './registry.ts';
import { type ContentRow, contentEntries } from './schema.ts';
export type ContentAdminRow = {
id: string;
slug: string;
locale: string;
status: ContentRow['status'];
/** Lo que se edita: borrador si hay, si no lo publicado. */
data: Record<string, unknown>;
hasUnpublishedChanges: boolean;
authorId: string | null;
publishedAt: Date | null;
scheduledAt: Date | null;
updatedAt: Date;
};
const toRow = (r: ContentRow): ContentAdminRow => ({
id: r.id,
slug: r.slug,
locale: r.locale,
status: r.status,
data: r.draft ?? r.data ?? {},
hasUnpublishedChanges: r.draft !== null,
authorId: r.authorId,
publishedAt: r.publishedAt,
scheduledAt: r.scheduledAt,
updatedAt: r.updatedAt,
});
const Input = z.object({ slug: z.string().optional(), locale: z.string().optional(), data: z.record(z.string(), z.unknown()) });
async function assertCan(ctx: AdminContext, collection: string, action: string, row?: ContentRow): Promise<void> {
// @core/admin resuelve `:own` pasando por ctx.can; aquí pedimos el permiso general o el propio si es el autor.
const general = await ctx.can(`${collection}:${action}`);
const own = row?.authorId === ctx.user.id && (await ctx.can(`${collection}:${action}:own`));
if (!general && !own) throw new ContentError('forbidden', `forbidden: ${collection}:${action}`);
}
/**
* Recurso del panel para una colección. Con `path`, cada entrada enlaza a su página pública y a una vista previa
* firmada del borrador (requiere `CONTENT_PREVIEW_SECRET` y la ruta `/api/preview`).
*/
export function contentAdminResource(
collection: string,
opts: { path?: (entry: { slug: string; locale: string; data: Record<string, unknown> }) => string; previewRoute?: string } = {},
): AdminResource<ContentAdminRow> {
const c = getCollection(collection);
const load = async (id: string) => {
const row = await getEntryById(id);
if (row.collection !== collection) throw new ContentError('not_found', `entry ${id} not found`);
return row;
};
const action = (name: string, label: string, permission: 'publish' | 'update', fn: (id: string, input: unknown) => Promise<ContentRow>, extra: Partial<{ confirm: boolean; input: z.ZodType; available: (r: ContentAdminRow) => boolean }> = {}) => ({
name,
label,
permission: `${collection}:${permission}`,
...extra,
async run(id: string, input: unknown, ctx: AdminContext) {
await assertCan(ctx, collection, permission, await load(id));
return toRow(await fn(id, input));
},
});
return {
name: collection,
label: c.label,
group: 'Content',
fields: [
...(c.kind === 'collection' ? [{ name: 'slug', label: 'Slug', type: 'slug' as const, required: true, list: true }] : []),
...c.fields.map((f) => ({ ...f, name: `data.${f.name}` })),
],
input: Input,
title: (r) => String((c.titleField && r.data[c.titleField]) || (r.slug === SINGLETON_SLUG ? c.label.singular : r.slug)),
async list(q, ctx) {
const own = !(await ctx.can(`${collection}:read`));
if (own && !(await ctx.can(`${collection}:read:own`))) throw new ContentError('forbidden', `forbidden: ${collection}:read`);
const conds: SQL[] = [eq(contentEntries.collection, collection)];
if (own) conds.push(eq(contentEntries.authorId, ctx.user.id));
if (q.filters?.status) conds.push(sql`${contentEntries.status} = ${q.filters.status}`);
if (q.filters?.locale) conds.push(eq(contentEntries.locale, q.filters.locale));
if (q.search) {
const like = `%${q.search.replace(/[%_\\]/g, (m) => `\\${m}`)}%`;
conds.push(or(ilike(contentEntries.slug, like), sql`coalesce(${contentEntries.draft}, ${contentEntries.data})::text ilike ${like}`)!);
}
const where = and(...conds);
const [total] = await getDb().select({ n: count() }).from(contentEntries).where(where);
const rows = await getDb()
.select()
.from(contentEntries)
.where(where)
.orderBy(q.sort?.field === 'slug' ? (q.sort.dir === 'asc' ? contentEntries.slug : sql`${contentEntries.slug} desc`) : sql`${contentEntries.updatedAt} desc`)
.limit(q.pageSize)
.offset((Math.max(q.page, 1) - 1) * q.pageSize);
return { rows: rows.map(toRow), total: total?.n ?? 0 };
},
async get(id, ctx) {
const row = await load(id).catch(() => null);
if (!row) return null;
await assertCan(ctx, collection, 'read', row);
return toRow(row);
},
async create(input, ctx) {
await assertCan(ctx, collection, 'create');
const i = Input.parse(input);
return toRow(await createEntry({ collection, slug: i.slug, locale: i.locale ?? defaultLocale(), data: i.data, authorId: ctx.user.id }));
},
async update(id, input, ctx) {
const row = await load(id);
await assertCan(ctx, collection, 'update', row);
const i = Input.parse(input);
// Renombrar y guardar el borrador van juntos: si los datos no validan, el slug no cambia.
const saved = await withTransaction(async (t) => {
if (i.slug && i.slug !== row.slug && c.kind === 'collection') await renameEntry(id, i.slug, t);
return saveDraft(id, i.data, t);
});
return toRow(saved);
},
async delete(id, ctx) {
await assertCan(ctx, collection, 'delete', await load(id));
await deleteEntry(id);
},
...(opts.path && {
async links(r: ContentAdminRow) {
const path = opts.path!({ slug: r.slug, locale: r.locale, data: r.data });
const out: Array<{ label: string; href: string }> = [];
if (r.status === 'published') out.push({ label: 'View', href: path });
if (process.env.CONTENT_PREVIEW_SECRET)
out.push({ label: 'Preview', href: `${opts.previewRoute ?? '/api/preview'}?token=${encodeURIComponent(await createPreviewToken(r.id, path, { ttlSeconds: 600 }))}` });
return out;
},
}),
actions: [
action('publish', 'Publish', 'publish', (id) => publish(id)),
action('schedule', 'Schedule', 'publish', (id, input) => schedulePublish(id, z.object({ at: z.coerce.date() }).parse(input).at), {
input: z.object({ at: z.coerce.date() }),
}),
action('unschedule', 'Cancel schedule', 'publish', (id) => unschedule(id), { available: (r) => r.scheduledAt !== null }),
action('unpublish', 'Unpublish', 'publish', (id) => unpublish(id), { confirm: true, available: (r) => r.status === 'published' }),
action('archive', 'Archive', 'publish', (id) => archive(id), { confirm: true, available: (r) => r.status !== 'archived' }),
action('restore', 'Restore', 'update', (id) => restore(id), { available: (r) => r.status === 'archived' }),
],
};
}
此包未声明 MCP 服务器。
| 版本 | 提交 | 发布时间 | 扫描 |
|---|---|---|---|
| 1.1.0 | 23978fe | 5小时前 | 扫描通过 |
- npm
- zod ^4.0.0
- 建议
- GenPM 会给出 npm 命令建议,只有你同意时才会运行。
- 扫描
- 扫描通过 · 0 个问题
- 提交
- v1.1.0 → 23978fee0b532853e3112d070817a3e274c4ed49 · 获取后已校验
- 脚本
- 无。GenPM 从不运行包中的代码。
- 许可证
- MIT
- 质量
- 100/100
- 可识别的许可证已满足
- AGENTS.md 说明了用途已满足
- AGENTS.md 包含集成步骤已满足
- AGENTS.md 列出约定或禁止事项已满足
- 包含测试已满足
- 通过安全扫描已满足
- 最近 6 个月内发布已满足
- 已验证的发布者已满足
- 摘要和关键词已满足
- 举报
- 发现问题了吗?